Author SHA1 Message Date
lvmeng c8383af120 feat(meal): 添加餐品列表下拉刷新和上拉加载功能
- 集成 SmartRefreshLayout 实现下拉刷新和上拉加载更多
- 修改 MealListActivity 添加分页加载逻辑,每页30条数据
- 实现空视图显示功能,当无数据时展示暂无数据提示
- 优化 RecyclerView 点击事件处理,使用 startActivity 扩展函数
- 添加加载状态管理,区分首次加载和后续加载情况
- 实现加载失败时的状态回退和错误处理机制
- 统一网络请求回调处理,优化 Loading 状态显示逻辑
2026-06-05 10:13:05 +08:00
mazengfei 2f18376236 feat(task): 添加任务份数默认值设置
- 在任务详情页面为新任务设置默认份数为1
- 确保任务开始组配前包含正确的份数信息
2026-06-04 18:09:24 +08:00
mazengfei 14ccc39bfd fix(config): 更新应用配置和设备方向设置
- 在InitActivity中添加竖屏显示配置
- 注释掉BaseActivity中的动态屏幕方向逻辑
- 修改应用ID为com.shuwei.dish.match.new
- 将应用名称从菜品配比终端改为配比秤新
- 恢复称重设备端口为/dev/ttyS4
2026-06-04 11:22:03 +08:00
lvmeng 01ff7241cd feat(pack): 重构净菜包装页面并集成食材搜索功能
- 替换重量识别功能为食材选择搜索功能
- 新增 FoodSearchV2Activity 页面实现食材搜索选择
- 添加 InboundIngredientV2Adapter 适配器展示食材列表
- 创建 MaterialTypeTool 工具类统一处理食材类型文本
- 更新 CleanPackActivity 集成食材搜索返回逻辑
- 修改布局文件适配新的食材选择流程
- 调整环境配置中的API地址映射关系
- 优化 RecyclerView 显示和交互逻辑
- 移除原有的重量识别相关界面元素和代码
- 更新提交参数收集逻辑支持新食材数据结构
2026-06-03 16:05:19 +08:00
lvmeng ad9291243f feat(clean-pack): 添加配比秤功能和界面重构
- 新增搜索菜品名称功能和菜品搜索按钮
- 添加用量克数显示和清零重量功能
- 集成配比秤重量监听和显示功能
- 重构主页界面添加配比秤入口
- 新增组配任务按钮和相关页面跳转
- 修改餐品净菜包功能跳转至新的列表页面
- 优化净菜包装表单布局和字段配置
- 移除溯源码自动填充净菜名称功能
- 添加重量校准和去皮功能支持
- 重构提交流程增加重量验证和回调处理
2026-06-03 14:25:31 +08:00
lvmeng c81e2d14bb feat(pack): 优化净菜包页面布局和功能
- 调整 activity_pack.xml 中的边距和按钮布局
- 在 DateTimeUtil.kt 中添加日期格式常量 YYYY_MM_DD
- 优化 list_item_form_field.xml 的输入框样式和字体大小
- 将页面标题从"菜品净菜包"改为"餐品净菜包"
- 修改包装日期格式为 yyyy-MM-dd 并移除操作类型字段
- 将操作类型改为下拉选择框,支持设备自动和人工录入选项
- 将表单列表网格布局从2列改为4列显示
- 在 TaskActivity 中添加页面刷新逻辑
- 任务详情页面提交成功后返回并提示"组配成功"
2026-06-03 09:54:21 +08:00
lvmeng 0cc1d9189c feat(pack): 优化菜品净菜包装界面和功能
- 调整布局间距,移除商品名称和材料类型显示
- 修改开始组配按钮文本为完成组配并设为可见
- 更新API服务接口支持可空参数类型
- 添加餐品净菜包装数据模型字段
- 实现包装数据收集和提交功能
- 添加时间工具类导入和设备码字段
- 优化适配器中材料分类显示逻辑
2026-06-02 19:49:21 +08:00
lvmeng 32ec1d9b74 feat(pack): 添加净菜包装功能和食材追溯界面
- 在activity_pack.xml中新增rvFormList RecyclerView用于表单展示
- 在TaskDetailActivity中实现食材明细的增删改查功能
- 添加InboundIngredientSearchDialog用于食材溯源码搜索
- 更新NetViewModelV2中addComboTask方法参数类型
- 添加NutCleanInboundIngredientVO数据模型用于已入库净菜食材
- 在PackActivity中实现包装表单动态加载和设备选择功能
- 添加用量计算和重量识别相关UI组件
- 优化FormFieldAdapter适配器去除Activity上下文依赖
- 新增formatDecimal扩展函数用于格式化用量字符串
- 实现TaskDetailAdapter中食材项的选中和清除逻辑
- 添加组合约束的API接口定义和数据传输对象修改
- 重构表单字段配置为动态加载模式支持多种输入类型
- 添加删除确认对话框提升用户体验和数据安全性
- 优化布局文件结构调整控件间距和显示效果
- 添加设备选项加载回调机制确保数据实时更新
- 实现重量监听器的生命周期管理防止内存泄漏
- 更新界面跳转逻辑支持餐品选择和详情展示
- 修复多个布局和代码中的空格及格式问题
- 添加必要的依赖导入和常量定义完善功能模块
2026-06-02 18:05:47 +08:00
lvmeng 3fe66b513f feat(task): 添加组配任务功能模块
- 新增组配任务列表页面和详情页面
- 实现组配任务查询、搜索和分页功能
- 添加组配任务详情展示和状态管理
- 集成溯源码获取功能到任务详情页面
- 添加组配任务相关的API接口定义和实现
- 创建组配任务数据模型和适配器
- 在设置页面添加组配任务入口
- 重构食材物料列表项布局和显示逻辑
- 更新应用启动时的设备ID初始化逻辑
- 生成配比秤终端接口文档
2026-06-02 14:21:09 +08:00
lvmeng 9801534aad feat(ui): 新增餐品列表和菜品包装页面
- 创建了 MealListActivity 餐品列表页面,用于展示可选的餐品
- 实现了餐品列表的 RecyclerView 展示和点击跳转功能
- 添加了 PackActivity 菜品包装页面,支持食材构成管理
- 集成了相机预览功能用于食材识别
- 实现了重量识别和食材添加功能
- 添加了侧滑删除菜单和食材重量管理
- 创建了 MealListAdapter 适配器用于餐品列表展示
2026-06-02 11:14:48 +08:00
lvmeng 48d331f05d feat(ui): 添加餐品列表和套餐功能并优化食材显示
- 在 AndroidManifest.xml 中注册 MealListActivity 和 PackActivity
- 在 FoodMaterialAdapter 中添加溯源码显示功能
- 修改 GoodsItem 模型中 goodsName 和 materCode 为可变属性
- 更新 list_item_food_material 布局文件以支持溯源码显示
- 在 NutritionModels 中为 NutFoodComposition 添加 isMain 字段
- 将设置页面中的餐包功能重定向到 MealListActivity
2026-06-02 11:14:24 +08:00
lvmeng c2cf7289b7 feat(setting): 添加净菜包功能并优化设置页面
- 在BaseApp中新增ScaleDeviceConfig、AppUtil等依赖导入
- 恢复InitActivity中的原有路由逻辑,移除HomeV2Activity跳转
- 移除SettingActivity中设置项的硬编码标题,改用枚举默认值
- 新增CLEAN_PACKAGE、MEAL_PACKAGE、MEAL_PACKAGE_TEST三种设置类型
- 为净菜包功能添加对应的页面跳转逻辑
- 隐藏数据库查看、秤数据监控等调试功能项
2026-06-02 09:35:30 +08:00
lvmeng 3884738911 feat(form): 实现餐品选择后自动加载食材功能
- 添加 NutFoodOptionVO 模型导入并增加 foodOptionsData 缓存列表
- 新增 ingredientsField 表单字段用于包含食材动态加载
- 将食材名称字段类型从 DROPDOWN 改为 FIXED 类型并移除固定选项
- 实现餐品选中后根据 position 从 compositions 加载包含食材逻辑
- 在 DictType 中新增 position 属性用于索引定位
- 在 FormField 中新增 onDropdownSelected 回调函数属性
- 更新 FormFieldAdapter 中触发选中回调并在日期字段注释中调整说明
- 修改 IngredientRowAdapter 中根据字段类型区分 FIXED 和其他类型处理
- 添加 extraApiKeys 到 extraValues 映射逻辑并更新下拉选中回调
- 调整 list_item_ingredient_row.xml 中食材名称布局去除右侧边距和下拉箭头
- 注释掉 NetViewModelV2 中的 getFoodOptions 方法(暂未使用)
2026-06-01 20:05:00 +08:00
lvmeng a25ee7d987 feat(clean-pack): 实现净菜包装页面功能完善
- 集成称重功能到应用生命周期管理
- 添加全局用户名配置
- 更新测试环境基础URL地址
- 实现表单字段动态加载和回调机制
- 优化设备选项和字典项加载逻辑
- 修复餐品搜索结果显示问题
- 统一字段值映射关系配置
2026-06-01 19:17:32 +08:00
lvmeng 4a2abf6eec feat(form): 重构净菜包装表单实现动态字段和提交功能
- 移除固定 DTO 类型,改用 Map<String, Any> 接收 API 参数
- 实现动态表单字段配置,支持净菜和餐品两种包装类型
- 添加下拉选项动态加载和自动填充溯源码功能
- 实现表单验证和通用提交逻辑
- 添加回调版本的数据加载方法提高性能
- 优化字段绑定和值映射机制
2026-06-01 16:51:47 +08:00
lvmeng 5d6df58e46 feat(form): 实现餐品净菜包装表单功能
- 新增 CHILDREN 字段类型支持动态子列表
- 实现包含食材的嵌套表单结构
- 添加餐品包装相关字段配置
- 集成下拉选项和日期选择器组件
- 创建食材行适配器处理子列表渲染
- 更新表单布局和样式资源文件
2026-06-01 14:18:16 +08:00
lvmeng c4a2105737 feat(form): 新增净菜包装功能并优化表单字段类型
- 添加 CleanPackActivity 页面用于净菜包装表单
- 将 NUMBER 字段类型拆分为 INTEGER 和 DECIMAL 类型
- 更新 FormFieldAdapter 适配新的字段类型
- 在 HomeV2Activity 中添加跳转到净菜包装页面的功能
- 创建 ApiServiceV2、NetViewModelV2 和 RemoteRepositoryV2 处理新接口
- 添加营养相关模型类和分页组件
- 在 AndroidManifest.xml 中注册新 Activity
2026-06-01 11:21:04 +08:00
lvmeng d7a1b75fef feat(app): 添加 HomeV2Activity 并移除设备角色相关功能
- 在 AndroidManifest.xml 中注册新的 HomeV2Activity 页面
- 移除 BaseApp 中的设备角色判断和秤服务相关初始化代码
- 添加蓝色和橙色渐变按钮背景资源文件
- 修改 InitActivity 默认跳转至 HomeV2Activity 页面
- 移除与设备角色相关的路由逻辑代码
2026-06-01 10:42:47 +08:00
lvmeng 11963b8b12 feat(form): 添加动态表单组件和下拉选择功能
- 新增 FormField 数据模型定义五种字段类型(文本、数字、下拉、日期、固定值)
- 实现 FormFieldAdapter 适配器支持动态表单渲染和数据绑定
- 添加 DropdownPopup 弹窗组件和 DropdownAdapter 适配器
- 新增多个圆角背景资源文件(bg_gray_radius10、bg_white_radius10_stroke 等)
- 在 Common.kt 中添加 hideKeyboard 扩展函数用于隐藏软键盘
- 创建 DictType 数据模型用于字典类型数据传输
- 添加表单项和下拉项的布局文件(list_item_form_field、list_item_dropdown 等)
2026-06-01 10:19:07 +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
178 changed files with 11561 additions and 1899 deletions
+7 -6
View File
@@ -16,7 +16,7 @@ android {
compileSdk = 36
defaultConfig {
applicationId = "com.shuwei.dish.match"
applicationId = "com.shuwei.dish.match.new"
minSdk = 29
targetSdk = 36
versionCode = 1
@@ -24,9 +24,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
//设置支持的SO库架构
abiFilters.addAll(listOf("armeabi-v7a"))
//, 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
//设置支持的SO库架构x86_64 仅用于在 Windows 模拟器上调试(秤功能不可用)
abiFilters.addAll(listOf("armeabi-v7a", "x86_64"))
}
// 设置输出APK文件名格式
@@ -48,12 +47,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 +98,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)
+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": []
+34
View File
@@ -45,6 +45,9 @@
<activity
android:name=".ui.InitActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
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" />
@@ -57,6 +60,36 @@
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.HomeV2Activity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.CleanPackActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.MealListActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.PackActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.TaskActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name=".ui.TaskDetailActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.CookingModeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
@@ -126,6 +159,7 @@
android:launchMode="singleTask"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.dish.match.ui.FoodSearchV2Activity" />
<receiver
android:name=".utils.BootReceiver"
@@ -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
/**
* 已采集食材列表适配器
@@ -0,0 +1,31 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemDropdownBinding
import com.shuwei.dish.match.model.DictType
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
class DropdownAdapter(var list: MutableList<DictType>) :
BaseQuickAdapter<DictType, DropdownAdapter.VH>(list) {
inner class VH(var binding: ListItemDropdownBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDropdownBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: DictType?) {
holder.binding.tvDropdown.text = item?.value
holder.binding.divider.run {
if (position == list.size - 1) gone() else visible()
}
}
}
@@ -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.ListItemFood2Binding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
/**
* 菜品搜索页面的适配器
@@ -2,11 +2,12 @@ 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.entity.GoodsItem
import com.shuwei.dish.match.model.GoodsItem
/**
* 向量采集页面的适配器
@@ -24,6 +25,15 @@ class Food3Adapter(list: MutableList<GoodsItem>) :
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(
@@ -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.ListItemFoodBinding
import com.shuwei.dish.match.entity.GoodsItem
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
@@ -25,12 +25,14 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
item: GoodsItem?
) {
item ?: return
holder.binding.tvGoodsInfo.run {
text = item.goodsName
holder.binding.root.run {
setBackgroundResource(
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,
@@ -46,6 +48,15 @@ class FoodAdapter(list: MutableList<GoodsItem>) :
it.visible()
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
}
}
}
@@ -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.ListItemFoodListBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
@@ -86,6 +86,8 @@ class FoodListAdapter(
return@run
}
// 非烹饪中,采样模式
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
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 ?: "-"}次)"
@@ -1,6 +1,7 @@
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
@@ -8,10 +9,12 @@ 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.entity.GoodsItem
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
import com.shuwei.dish.match.ui.MaterialTypeTool
class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodMaterialAdapter.VH>(list) {
@@ -34,9 +37,17 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = data.goodsName
tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else ""
//if (data.isOriginalData) {
// tvDishName.text = data.goodsName
//} else {
// tvDishName.text = data.goodsName + "-${data.rawMaterialsType}"
//}
val showGoodsCode = (data.materCode ?: "").ifBlank { "-" }
tvGoodsCode.text = "溯源码:$showGoodsCode"
val materialText = MaterialTypeTool.getMaterialText(data.materialType)
tvDishType.text = "主辅材:$materialText"
//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(
@@ -49,17 +60,25 @@ class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
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
)
// 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.isItemClicked) R.drawable.shape_item_cook_dish
if (data.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
@@ -0,0 +1,245 @@
package com.shuwei.dish.match.adapter
import android.app.DatePickerDialog
import android.content.Context
import android.text.Editable
import android.text.InputType
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.LinearLayoutManager
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.ListItemFormFieldBinding
import com.shuwei.dish.match.dialog.DropdownPopup
import com.shuwei.dish.match.model.FieldType
import com.shuwei.dish.match.model.FormField
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.hideKeyboard
import com.shuwei.dish.match.utils.ext.visible
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlin.math.min
/**
* 动态表单字段 Adapter,配合 FlexboxLayoutManager 实现两列自动换行布局
* 单一 item 布局,通过 visibility 控制 TEXT/INTEGER/DECIMAL/FIXED/DROPDOWN/DATE_PICKER 六种类型渲染
*
* @param fields 表单字段列表(hidden=false 的字段,由外部过滤后传入)
*/
class FormFieldAdapter(
fields: MutableList<FormField>
) : BaseQuickAdapter<FormField, FormFieldAdapter.VH>(fields) {
private val isoFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
private val displayDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
inner class VH(val binding: ListItemFormFieldBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFormFieldBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FormField?) {
item ?: return
val b = holder.binding
// 设置标签,必填项追加红色星号
b.tvLabel.text = buildLabelSpannable(item.label, item.required)
// 先全部隐藏,再按类型显示对应控件
b.flInputArea.gone()
b.etInput.gone()
b.flDropdown.gone()
b.flDatePicker.gone()
b.llChildren.gone()
// 移除旧的 TextWatcher,防止 RecyclerView 复用时错误触发
(b.etInput.tag as? TextWatcher)?.let { b.etInput.removeTextChangedListener(it) }
b.etInput.tag = null
when (item.type) {
FieldType.TEXT -> bindEditText(b, item, InputType.TYPE_CLASS_TEXT)
FieldType.INTEGER -> bindEditText(b, item, InputType.TYPE_CLASS_NUMBER)
FieldType.DECIMAL -> bindEditText(
b, item,
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED
)
FieldType.FIXED -> bindFixed(b, item)
FieldType.DROPDOWN -> bindDropdown(b, item)
FieldType.DATE_PICKER -> bindDatePicker(b, item)
FieldType.CHILDREN -> bindChildren(b, item)
}
if (item.type == FieldType.CHILDREN) {
b.flInputArea.gone()
b.llChildren.visible()
} else {
b.flInputArea.visible()
b.llChildren.gone()
}
}
/** 绑定可编辑文本框(TEXT / NUMBER */
private fun bindEditText(b: ListItemFormFieldBinding, item: FormField, inputType: Int) {
b.etInput.visible()
b.etInput.isEnabled = true
b.etInput.inputType = inputType
b.etInput.hint = item.hint.ifBlank { item.label }
b.etInput.setBackgroundResource(R.drawable.bg_white_radius10_stroke2)
b.etInput.setText(item.value)
b.etInput.setSelection(b.etInput.text.length)
val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
item.value = s?.toString() ?: ""
}
}
b.etInput.addTextChangedListener(watcher)
b.etInput.tag = watcher
}
/** 绑定固定值(不可编辑,灰色背景区分) */
private fun bindFixed(b: ListItemFormFieldBinding, item: FormField) {
b.etInput.visible()
b.etInput.isEnabled = false
b.etInput.inputType = InputType.TYPE_CLASS_TEXT
b.etInput.hint = ""
b.etInput.setBackgroundResource(R.drawable.bg_gray_radius10)
b.etInput.setText(item.value)
}
/** 绑定下拉选择框,点击时创建 DropdownPopup */
private fun bindDropdown(b: ListItemFormFieldBinding, item: FormField) {
b.flDropdown.visible()
b.tvDropdownValue.hint = item.hint.ifBlank { "请选择${item.label}" }
b.tvDropdownValue.text = item.value.ifBlank { null }
b.flDropdown.setOnClickListener { v ->
v.hideKeyboard()
val popHeight = min(item.options.size.coerceAtLeast(1), 4) * 71.dp
DropdownPopup(
context = context,
list = item.options.toMutableList(),
popWidth = v.width,
popHeight = popHeight
) { dictType ->
item.value = dictType.value ?: ""
b.tvDropdownValue.text = item.value
// 将 extraApiKeys 映射的 DictType 字段值写入 extraValues
item.extraValues.clear()
item.extraApiKeys.forEach { (apiKey, dictField) ->
item.extraValues[apiKey] = dictType[dictField]
}
// 触发选中回调
item.onDropdownSelected?.invoke(dictType)
}.also {
it.bgLayout = b.flDropdown
it.showAsDropDown(v)
}
}
}
/** 绑定日期选择器,点击时弹出 DatePickerDialog */
private fun bindDatePicker(b: ListItemFormFieldBinding, item: FormField) {
b.flDatePicker.visible()
b.tvDateValue.hint = item.hint.ifBlank { "请选择日期" }
b.tvDateValue.text = item.value.ifBlank { null }
b.flDatePicker.setOnClickListener { v ->
v.hideKeyboard()
val cal = Calendar.getInstance()
DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
val selected = Calendar.getInstance().apply {
set(year, month, dayOfMonth, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
}
// value 存日期显示格式,供提交使用
item.value = displayDateFormat.format(selected.time)
b.tvDateValue.text = item.value
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
}
/** 构建带红色星号的必填标签 */
private fun buildLabelSpannable(label: String, required: Boolean): SpannableStringBuilder {
return SpannableStringBuilder().apply {
append(label)
if (required) {
val start = length
append(" *")
setSpan(
ForegroundColorSpan("#FFCC0000".toColorInt()),
start, length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
/** 绑定 CHILDREN 类型:渲染子列表(表头 + 嵌套 RecyclerView + 添加按钮) */
private fun bindChildren(b: ListItemFormFieldBinding, item: FormField) {
b.llChildren.visible()
b.tvAddChild.text = item.childAddLabel ?: "+ 添加"
// 初始化嵌套 RecyclerView
if (b.rvChildren.adapter == null) {
b.rvChildren.layoutManager = LinearLayoutManager(context)
}
b.rvChildren.adapter = createRowAdapter(b, item)
// 添加按钮点击
b.tvAddChild.setOnClickListener {
item.children?.let { template ->
val newRow = template.map { field ->
FormField(
label = field.label,
type = field.type,
apiKey = field.apiKey,
extraApiKeys = field.extraApiKeys,
required = field.required,
hint = field.hint,
options = field.options
)
}.toMutableList()
item.childrenRows.add(newRow)
b.rvChildren.adapter?.notifyItemInserted(item.childrenRows.size - 1)
}
}
}
/** 创建子列表行 Adapter */
private fun createRowAdapter(b: ListItemFormFieldBinding, item: FormField): IngredientRowAdapter {
return IngredientRowAdapter(
rows = item.childrenRows,
onDelete = onDelete@{ position ->
val adapter = b.rvChildren.adapter as? IngredientRowAdapter ?: return@onDelete
item.childrenRows.removeAt(position)
adapter.notifyItemRemoved(position)
if (position < item.childrenRows.size) {
adapter.notifyItemRangeChanged(position, item.childrenRows.size - position)
}
},
onChildClick = { _, _, _ ->
// 子字段值已直接更新到 row 的 FormField 中,无需额外操作
}
)
}
}
@@ -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
@@ -0,0 +1,40 @@
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.R
import com.shuwei.dish.match.databinding.ListItemInboundIngredientBinding
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.utils.ext.formatDecimal
/**
* 已入库净菜食材列表适配器
*/
class InboundIngredientAdapter(list: MutableList<NutCleanInboundIngredientVO>) :
BaseQuickAdapter<NutCleanInboundIngredientVO, InboundIngredientAdapter.VH>(list) {
inner class VH(var binding: ListItemInboundIngredientBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemInboundIngredientBinding.inflate(
LayoutInflater.from(context), parent, false
)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: NutCleanInboundIngredientVO?) {
item ?: return
holder.binding.run {
tvIngredientName.text = item.ingredientName ?: "-"
val traceCode = item.traceCode.takeUnless { it.isNullOrBlank() } ?: "-"
tvTraceCode.text = "溯源码:$traceCode"
val stock = item.currentStock.takeUnless { it.isNullOrBlank() } ?: "0"
tvStock.text = "库存:${stock.formatDecimal()}kg"
}
}
}
@@ -0,0 +1,40 @@
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.ListItemInboundIngredient2Binding
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.utils.ext.formatDecimal
/**
* 已入库净菜食材列表适配器 V2
*/
class InboundIngredientV2Adapter(list: MutableList<NutCleanInboundIngredientVO>) :
BaseQuickAdapter<NutCleanInboundIngredientVO, InboundIngredientV2Adapter.VH>(list) {
inner class VH(var binding: ListItemInboundIngredient2Binding) :
QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemInboundIngredient2Binding.inflate(
LayoutInflater.from(context), parent, false
)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: NutCleanInboundIngredientVO?) {
item ?: return
holder.binding.run {
tvIngredientName.text = item.ingredientName ?: "-"
val traceCode = item.traceCode.takeUnless { it.isNullOrBlank() } ?: "-"
tvTraceCode.text = "溯源码:$traceCode"
val stock = item.currentStock.takeUnless { it.isNullOrBlank() } ?: "0"
tvStock.text = "库存:${stock.formatDecimal()}kg"
}
}
}
@@ -0,0 +1,149 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
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.ListItemIngredientRowBinding
import com.shuwei.dish.match.dialog.DropdownPopup
import com.shuwei.dish.match.model.FormField
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.hideKeyboard
/**
* 食材行 Adapter,用于「包含食材」子列表的嵌套渲染
* @param context Activity 上下文
* @param rows 每行包含多个 FormField,对应溯源码、食材名称、分类、用量
* @param onDelete 删除行回调
* @param onChildClick 子字段点击回调(如下拉框点击)
*/
class IngredientRowAdapter(
rows: MutableList<MutableList<FormField>>,
private val onDelete: (Int) -> Unit,
private val onChildClick: (Int, Int, FormField) -> Unit
) : BaseQuickAdapter<MutableList<FormField>, IngredientRowAdapter.VH>(rows) {
inner class VH(val binding: ListItemIngredientRowBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemIngredientRowBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: MutableList<FormField>?) {
item ?: return
val b = holder.binding
// 假设 children 模板顺序:[溯源码(TEXT), 食材名称(DROPDOWN), 分类(DROPDOWN), 用量(TEXT)]
val traceCodeField = item.getOrNull(0)
val materNameField = item.getOrNull(1)
val classifyField = item.getOrNull(2)
val amountField = item.getOrNull(3)
// 溯源码 - TEXT
traceCodeField?.let { field ->
b.etTraceCode.setText(field.value)
b.etTraceCode.hint = field.hint.ifBlank { field.label }
(b.etTraceCode.tag as? TextWatcher)?.let { b.etTraceCode.removeTextChangedListener(it) }
val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
field.value = s?.toString() ?: ""
}
}
b.etTraceCode.addTextChangedListener(watcher)
b.etTraceCode.tag = watcher
}
// 食材名称
materNameField?.let { field ->
when (field.type) {
com.shuwei.dish.match.model.FieldType.FIXED -> {
b.flMaterName.setOnClickListener { v -> v.hideKeyboard() }
b.flMaterName.isClickable = false
b.tvMaterName.text = field.value
}
else -> {
b.tvMaterName.hint = field.hint.ifBlank { "请选择${field.label}" }
b.tvMaterName.text = field.value.ifBlank { null }
b.flMaterName.setOnClickListener { v ->
v.hideKeyboard()
showDropdown(b.flMaterName, field, b.tvMaterName, holder.getAdapterPosition(), 1)
}
}
}
}
// 分类 - DROPDOWN
classifyField?.let { field ->
b.tvClassify.hint = field.hint.ifBlank { "请选择${field.label}" }
b.tvClassify.text = field.value.ifBlank { null }
b.flClassify.setOnClickListener { v ->
v.hideKeyboard()
showDropdown(b.flClassify, field, b.tvClassify, holder.getAdapterPosition(), 2)
}
}
// 用量 - TEXT
amountField?.let { field ->
b.etAmount.setText(field.value)
b.etAmount.hint = field.hint.ifBlank { field.label }
(b.etAmount.tag as? TextWatcher)?.let { b.etAmount.removeTextChangedListener(it) }
val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
field.value = s?.toString() ?: ""
}
}
b.etAmount.addTextChangedListener(watcher)
b.etAmount.tag = watcher
}
// 删除按钮
b.tvRemove.setOnClickListener {
val pos = holder.layoutPosition
if (pos != RecyclerView.NO_POSITION) {
onDelete(pos)
}
}
}
/** 显示下拉弹出框 */
private fun showDropdown(
anchorView: ViewGroup,
field: FormField,
valueView: TextView,
rowPosition: Int,
childIndex: Int
) {
DropdownPopup(
context = context,
list = field.options.toMutableList(),
popWidth = anchorView.width,
popHeight = kotlin.math.min(field.options.size.coerceAtLeast(1), 4) * 50.dp
) { dictType ->
field.value = dictType.value ?: ""
valueView.text = field.value
// 将 extraApiKeys 映射值写入 extraValues
field.extraValues.clear()
field.extraApiKeys.forEach { (apiKey, dictField) ->
field.extraValues[apiKey] = dictType[dictField]
}
field.onDropdownSelected?.invoke(dictType)
// 回调通知外部更新
onChildClick(rowPosition, childIndex, field)
}.also {
it.bgLayout = anchorView
it.showAsDropDown(anchorView)
}
}
}
@@ -0,0 +1,36 @@
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.ListItemFood2Binding
import com.shuwei.dish.match.model.NutFoodOptionVO
/**
* 餐品列表适配器
*/
class MealListAdapter(list: MutableList<NutFoodOptionVO>) :
BaseQuickAdapter<NutFoodOptionVO, MealListAdapter.VH>(list) {
inner class VH(var binding: ListItemFood2Binding) : QuickViewHolder(binding.root)
override fun onBindViewHolder(
holder: VH,
position: Int,
item: NutFoodOptionVO?
) {
holder.binding.tvDishInfo.text = item?.foodName
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemFood2Binding.inflate(inflater, parent, false)
return VH(binding)
}
}
@@ -3,9 +3,11 @@ 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
@@ -40,11 +42,20 @@ 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 {
@@ -90,16 +90,25 @@ 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)
@@ -110,6 +119,7 @@ class Seasoning18GridAdapter(
holder.b.tvState.setTextColor(sub)
}
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
@@ -164,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,17 +95,25 @@ 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)
@@ -116,6 +124,7 @@ class Seasoning22GridAdapter(
holder.b.tvState.setTextColor(sub)
}
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
@@ -170,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,26 +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.ListItemFoodBinding
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,
@@ -35,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(
@@ -43,10 +43,10 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
viewType: Int
): VH {
val binding =
ListItemFoodBinding.inflate(LayoutInflater.from(context), parent, false)
ListItemSeasoningBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemFoodBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemSeasoningBinding) : QuickViewHolder(binding.root)
}
@@ -6,14 +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(
@@ -23,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 匹配;不存在则追加
*/
@@ -43,18 +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
val showWeight = "${item.useWeight.roundedDecimalPlace(1)}g"
holder.binding.tvSeasoningWeight.text = showWeight
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)
}
@@ -0,0 +1,53 @@
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.R
import com.shuwei.dish.match.databinding.ListItemTaskBinding
import com.shuwei.dish.match.model.NutComboTaskVO
/**
* 组配任务列表适配器
*/
class TaskAdapter(list: MutableList<NutComboTaskVO>) :
BaseQuickAdapter<NutComboTaskVO, TaskAdapter.VH>(list) {
inner class VH(var binding: ListItemTaskBinding) : QuickViewHolder(binding.root)
override fun onBindViewHolder(
holder: VH,
position: Int,
item: NutComboTaskVO?
) {
holder.binding.apply {
tvTaskNo.text = "任务编号:${item?.taskNo}"
tvTargetDish.text = item?.targetDish
tvSpec.text = item?.spec
tvPlanDate.text = "计划日期:${item?.planDate}"
tvPortions.text = "总份数:${item?.portions}"
tvOwnerName.text = "负责人:${item?.ownerName}"
// 状态文本映射
val statusText = when (item?.comboStatus) {
0 -> "待组配"
1 -> "组配中"
2 -> "已完成"
else -> "未知"
}
tvStatus.text = statusText
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemTaskBinding.inflate(inflater, parent, false)
return VH(binding)
}
}
@@ -0,0 +1,96 @@
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.graphics.toColorInt
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.ListItemTaskDetailBinding
import com.shuwei.dish.match.model.NutComboTaskItemDetail
import com.shuwei.dish.match.ui.MaterialTypeTool
import com.shuwei.dish.match.utils.ext.formatDecimal
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.math.BigDecimal
/**
* 组配任务详情食材列表适配器
* Adapter for displaying ingredient list in task details
*/
class TaskDetailAdapter(list: MutableList<NutComboTaskItemDetail>) :
BaseQuickAdapter<NutComboTaskItemDetail, TaskDetailAdapter.VH>(list) {
// ViewHolder class for holding the view bindings
inner class VH(var binding: ListItemTaskDetailBinding) : QuickViewHolder(binding.root)
/**
* Called by RecyclerView to display the item at the specified position.
* This method updates the ViewHolder's contents with the item's data.
*
* @param holder The ViewHolder to be updated
* @param position The position of the item in the adapter
* @param item The NutComboTaskItemDetail to be displayed
*/
override fun onBindViewHolder(
holder: VH,
position: Int,
item: NutComboTaskItemDetail?
) {
item ?: return
holder.binding.apply {
// Set ingredient name
tvIngredientName.text = item.ingredientName
// 食材类别
// val classText = when (item.ingredientClass) {
// 1 -> "主材"
// 2 -> "辅材"
// 3 -> "调料"
// else -> "-"
// }
tvIngredientClass.text = MaterialTypeTool.getMaterialText(item.ingredientClass ?: 0)
val unit = item.unit ?: "g"
tvPerPortionQty.text = "每份用量:${item.perPortionQty.formatDecimal()}${unit}"
tvActualQty.run {
if (item.actualQty.isNullOrBlank()) {
setTextColor("#999999".toColorInt())
text = "-"
} else {
setTextColor("#00BC71".toColorInt())
text = item.actualQty.formatDecimal() + unit
}
}
// tvTotalQty.text = "合计用量:${item?.totalQty.formatDecimal()}${unit}"
tvTraceCode.visible()
tvTraceCode.text = "溯源码:${(item.traceCode ?: "").ifBlank { "-" }}"
// val hasWeight = item.actualQty.isNullOrBlank().not()
// ivClearIcon.imageTintList = if (hasWeight) null else ColorStateList.valueOf("#BFBFBF".toColorInt())
ivOperateIcon.setImageResource(
if (item.isSetFinished == true) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
root.setBackgroundResource(
if (item.isClicked == true) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemTaskDetailBinding.inflate(inflater, parent, false)
return VH(binding)
}
}
@@ -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.entity.FoodCollectionBean
import com.shuwei.dish.match.model.FoodCollectionBean
import com.shuwei.dish.match.utils.ext.load
class VectorCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
@@ -5,6 +5,7 @@ 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
@@ -23,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
@@ -36,6 +38,7 @@ import kotlin.getValue
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)
@@ -48,10 +51,10 @@ open class BaseActivity : AppCompatActivity() {
// 提前隐藏系统栏,避免窗口创建时导航栏闪烁
hideSystemBars()
// 主设备竖屏,子设备横屏
requestedOrientation = if (GlobalData.deviceRole == DeviceRole.MASTER)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
// requestedOrientation = if (GlobalData.deviceRole == DeviceRole.MASTER)
// ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
// else
// ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
ActivityManager.addActivity(this)
binding = ActivityBaseBinding.inflate(layoutInflater)
if (this !is InitActivity || GlobalData.deviceRole == DeviceRole.SLAVE) {
@@ -65,11 +68,13 @@ open class BaseActivity : AppCompatActivity() {
binding.tvRightTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.llTitleBar.gone()
binding.ivBack.setOnClickListener { finish() }
binding.root.setOnClickListener { hideKeyboard() }
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
dismissLoading()
// 只关闭自己弹出的 Loading,防止误关其他 Activity 的 Loading
Loading.dismissIfOwnedBy(this)
super.onDestroy()
ActivityManager.removeActivity(this)
}
@@ -90,13 +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) {
@@ -148,22 +153,25 @@ 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)
@@ -180,7 +188,7 @@ open class BaseActivity : AppCompatActivity() {
val netViewModel: NetViewModel by viewModels()
val appViewModel: DbViewModel by viewModels()
val dbViewModel: DbViewModel by viewModels()
private var singlePermissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var multiplePermissionsCallback: ((isGranted: Boolean) -> Unit)? = null
@@ -264,4 +272,8 @@ open class BaseActivity : AppCompatActivity() {
}
}
fun hideKeyboard() {
KeyboardUtil.hideKeyboard(binding.root)
}
}
@@ -1,82 +1,100 @@
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.utils.AppUtil
import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
import com.shuwei.dish.match.utils.Weigher2
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.WeightUtil
/**
* BaseApp is the main application class that extends Android's Application class.
* It handles initialization of core components, device role determination,
* and service management for the weighing system.
*/
class BaseApp : Application() {
// Lazy initialization of the database instance
val database by lazy { DatabaseProvider(this).instance }
override fun onCreate() {
super.onCreate()
// Set the application instance
instance = this
// Initialize crash handler for error tracking
CrashHandler.init(this)
// Get and store the unique device ID
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
// 主设备是/dev/ttyS7,子设备是/dev/ttyS4
if (deviceId == ScaleDeviceConfig.DEVICE_ID_2) {
GlobalData.deviceRole = DeviceRole.MASTER
Weigher2.setDevicePort("/dev/ttyS7")
} else {
GlobalData.deviceRole = DeviceRole.SLAVE
Weigher2.setDevicePort("/dev/ttyS4")
// 读取本地保存的环境地址,覆盖默认值
val savedBaseUrl = SpTool.baseUrl
if (savedBaseUrl.isNotEmpty()) {
GlobalData.appBaseUrl = savedBaseUrl
}
Log.d(TAG, "设备角色: ${GlobalData.deviceRole}")
CrashHandler.init(this)
GlobalData.deviceId = AppUtil.getUDID(this)
// 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 == ScaleDeviceConfig.DEVICE_ID_2) {
// GlobalData.deviceRole = DeviceRole.MASTER
// Weigher2.setDevicePort("/dev/ttyS7")
// } else {
// GlobalData.deviceRole = DeviceRole.SLAVE
// Weigher2.setDevicePort("/dev/ttyS4")
// }
// Log.d(TAG, "设备角色: ${GlobalData.deviceRole}")
//
// // 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
// ScaleServiceManager.start(this)
//
// // 初始化秤,进程级生命周期管理:最后一个 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) {}
})
// 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
ScaleServiceManager.start(this)
// GlobalData.deviceId = "39a7abdd06b3c7ab"
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter)
ObjectBox.init(this)
// ObjectBox.init(this)
}
companion object {
private val TAG = "BaseApp"
// const val canteenId = "1678234139391512577"
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
}
}
@@ -8,13 +8,16 @@ package com.shuwei.dish.match.base
enum class DeviceRole { MASTER, SLAVE }
object GlobalData {
var appBaseUrl: String = TEST_BASE_URL
var appBaseUrl: String = LOCAL_BASE_URL
/**
* 具体业务 BaseUrl
*/
const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
// const val LOCAL_BASE_URL = "http://192.168.1.201:14801"
const val LOCAL_BASE_URL = "http://192.168.10.101:24801"
const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081"
// const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8083"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
/**
* 设备id
@@ -25,8 +28,8 @@ object GlobalData {
* 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE
*/
var deviceRole: DeviceRole = DeviceRole.SLAVE
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var userName = "张三"
}
/**
@@ -37,4 +40,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 = 9,
version = 13,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
@@ -224,6 +224,38 @@ val MIGRATION_8_9 = MigrationImpl(8, 9) { db ->
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 {
@@ -244,6 +276,10 @@ class DatabaseProvider(private val context: Context) {
// .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,8 +1,9 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
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
@@ -66,6 +67,12 @@ class CookFoodGoodsEntity(
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity, Serializable {
@@ -80,10 +87,12 @@ class CookFoodGoodsEntity(
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode
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
@@ -59,6 +58,12 @@ data class SeasoningEntity(
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var sort: Int = 9999,
/**
* isDel表示数据是否已删除1-已删除为无效数据0-正常使用有效数据
@@ -66,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
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.updateLayoutParams
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
@@ -25,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 }
@@ -42,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
}
@@ -54,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)
@@ -66,9 +99,9 @@ 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()
}
@@ -78,7 +111,7 @@ open class CommonDialog(
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 }
@@ -88,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按钮模式共用)
@@ -102,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() {
@@ -117,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
@@ -0,0 +1,78 @@
package com.shuwei.dish.match.dialog
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.PopupWindow
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DropdownAdapter
import com.shuwei.dish.match.databinding.LayoutPopupDropdownBinding
import com.shuwei.dish.match.model.DictType
import com.shuwei.dish.match.utils.ext.dp
class DropdownPopup(
var context: Context,
var list: MutableList<DictType>,
var popWidth: Int = 280.dp,
var popHeight: Int = 284.dp,
var onPopupItemClick: (DictType) -> Unit
) : PopupWindow() {
init {
// 初始化PopupWindow
// val popupView = LayoutInflater.from(context).inflate(R.layout.layout_popup_dropdown, null)
val binding = LayoutPopupDropdownBinding.inflate(LayoutInflater.from(context))
contentView = binding.root
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
// 设置透明背景,避免覆盖圆角
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
isOutsideTouchable = true
isFocusable = true
// 设置动画样式
// animationStyle = R.style.PopupAnimation
// 初始化视图和事件
initViews(binding.rvWarehouse)
}
private fun initViews(rv: RecyclerView) {
// 获取关闭按钮并设置点击事件
rv.updateLayoutParams {
width = popWidth
height = popHeight
}
rv.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
rv.adapter = DropdownAdapter(list).setOnItemClickListener { adapter, view, position ->
onPopupItemClick(list[position])
dismiss()
}
}
var arrowImage: ImageView? = null
var bgLayout: ViewGroup? = null
var defLayoutBgResId = R.drawable.bg_white_radius10_stroke2
override fun showAsDropDown(anchor: View?) {
super.showAsDropDown(anchor)
bgLayout?.setBackgroundResource(R.drawable.shape_green_stroke)
arrowImage?.setImageResource(R.mipmap.ic_triangle_up)
}
override fun dismiss() {
super.dismiss()
bgLayout?.setBackgroundResource(defLayoutBgResId)
arrowImage?.setImageResource(R.mipmap.ic_triangle_down)
}
}
@@ -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.LOCAL_BASE_URL
contentBinding.rbUat.id -> GlobalData.TEST_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.LOCAL_BASE_URL -> contentBinding.rbTest.isChecked = true
GlobalData.TEST_BASE_URL -> contentBinding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> contentBinding.rbProd.isChecked = true
}
}
/** 显示弹窗 */
fun show() = dialog.show()
/** 关闭弹窗 */
fun dismiss() = dialog.dismiss()
}
@@ -10,24 +10,23 @@ import android.view.MotionEvent
import android.view.View
import android.view.Window
import android.widget.FrameLayout
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.SeasoningSelectDialog.Companion.PAGE_SIZE
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.model.GoodsItem
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.launch
@@ -50,38 +49,27 @@ class FoodSearchDialog(
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<GoodsItem>()
.apply {
add(GoodsItem(goodsId = "200001", goodsName = "土豆丝"))
add(GoodsItem(goodsId = "200002", goodsName = "土豆片"))
add(GoodsItem(goodsId = "200003", goodsName = "土豆丁"))
add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝"))
add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片"))
add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁"))
add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝"))
add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片"))
add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁"))
add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝"))
add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁"))
add(GoodsItem(goodsId = "200012", goodsName = "A"))
add(GoodsItem(goodsId = "200013", goodsName = "B"))
add(GoodsItem(goodsId = "200014", goodsName = "C"))
add(GoodsItem(goodsId = "200015", goodsName = "D"))
add(GoodsItem(goodsId = "200016", goodsName = "E"))
add(GoodsItem(goodsId = "200017", goodsName = "F"))
add(GoodsItem(goodsId = "200018", goodsName = "G"))
add(GoodsItem(goodsId = "200019", goodsName = "H"))
}
private val adapter = FoodAdapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
setOnDebouncedItemClick { _, _, position ->
val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick
// 弹出净材种类单选弹窗,确认后再回调并关闭弹窗
showRawMaterialsDialog(
context = activity,
types = item.rawMaterialsTypes,
checkedType = item.rawMaterialsType
) { selectedType ->
item.rawMaterialsType = selectedType
item.isClicked = true
notifyItemChanged(position)
binding.root.postDelayed({
onItemSelected(list[position])
onItemSelected(item)
dismiss()
}, 300)
}
}
}
private var goodsName: String? = null
private var pageNo = 1
@@ -124,7 +112,7 @@ class FoodSearchDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -135,13 +123,13 @@ class FoodSearchDialog(
initObserver()
// // 若有默认食材名称,自动填充并触发搜索
// //if (!defGoodsName.isNullOrBlank()) {
// binding.etSheetInput.setText(defGoodsName)
// pageNo = 1
// getGoodsList()
// KeyboardUtil.hideKeyboard(binding.root)
// //}
// 若有默认食材名称,自动填充并触发搜索
//if (!defGoodsName.isNullOrBlank()) {
//binding.etSheetInput.setText(defGoodsName)
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(binding.root)
//}
}
/** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */
@@ -183,12 +171,13 @@ class FoodSearchDialog(
}
/**
* 收集 goodsListState,统一处理 Loading / Success / Error 状态
* 收集 foodSearchGoodsListState,统一处理 Loading / Success / Error 状态
* 使用独立的 State,避免与 FoodRecognizeActivity 等页面共用 goodsListState 产生干扰
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.goodsListState.collect { state ->
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
@@ -216,10 +205,10 @@ class FoodSearchDialog(
}
/**
* 请求食材列表
* 请求食材列表,使用弹窗专用接口,结果写入 foodSearchGoodsListState
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "0",
pageNum = pageNo,
pageSize = PAGE_SIZE,
@@ -239,8 +228,20 @@ class FoodSearchDialog(
}
return
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
// 仅当当前不是 GridLayoutManager 时才重新赋值(空态切回网格时)
binding.recyclerView.run {
if (layoutManager !is GridLayoutManager) {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
}
}
if (pageNo == 1) list.clear()
// 将接口返回的 rawMaterialsType(全部种类)移存到 rawMaterialsTypes,供弹窗使用
records.forEach {
it.rawMaterialsTypes = it.rawMaterialsType
it.rawMaterialsType = null
it.oldGoodsId = it.goodsId
it.goodsId = it.materId ?: ""
}
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= PAGE_SIZE
@@ -269,16 +270,19 @@ class FoodSearchDialog(
false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
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 {
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
adapter.stateView = emptyViewBinding!!.root
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)
}
}
}
}
@@ -0,0 +1,214 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Rect
import android.text.Editable
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import android.view.Window
import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
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.InboundIngredientAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogInboundIngredientSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.net.NetViewModelV2
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
/**
* 已入库净菜食材搜索弹窗
* @param activity 宿主 Activity
* @param defIngredientName 默认填充的食材名称(可选)
* @param onItemSelected 用户点击某项食材时的回调
*/
class InboundIngredientSearchDialog(
private val activity: BaseActivity,
private val viewModelV2: NetViewModelV2,
private val defIngredientName: String? = null,
private val onItemSelected: (item: NutCleanInboundIngredientVO) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogInboundIngredientSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<NutCleanInboundIngredientVO>()
private val adapter = InboundIngredientAdapter(list).apply {
isStateViewEnable = true
setOnDebouncedItemClick { _, _, position ->
val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick
onItemSelected(item)
dismiss()
}
}
private var keyword: String? = null
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar() }
binding.tvSheetName.text = "已入库净菜检索"
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入食材名称"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> keyword = text.toString() },
afterTextChanged = { it: Editable? ->
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchIngredient(this) }
}
// RecyclerView 初始化
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@InboundIngredientSearchDialog.adapter
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { getIngredientList() }
}
binding.ivSearch.setOnClickListener { searchIngredient(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
// 若有默认食材名称,自动填充并触发搜索
if (!defIngredientName.isNullOrBlank()) {
binding.etSheetInput.setText(defIngredientName)
getIngredientList()
KeyboardUtil.hideKeyboard(binding.root)
}
}
/** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */
override fun show() {
super.show()
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)
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)
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchIngredient(v: View) {
if (keyword.isNullOrBlank()) {
activity.toast("请输入食材名称")
return
}
getIngredientList()
KeyboardUtil.hideKeyboard(v)
}
/**
* 请求食材列表,使用回调方式发起
*/
private fun getIngredientList() {
viewModelV2.getInboundIngredientOptionsWithCallback(
keyword = keyword.takeUnless { it.isNullOrBlank() },
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> loadIngredientList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
binding.refreshLayout.finishRefresh()
}
else -> {}
}
}
)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadIngredientList(records: List<NutCleanInboundIngredientVO>?) {
binding.refreshLayout.finishRefresh()
if (records.isNullOrEmpty()) {
loadEmptyView()
return
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
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.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -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)
}
}
@@ -24,8 +24,7 @@ import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -40,7 +39,7 @@ import kotlinx.coroutines.launch
*/
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 {
@@ -49,14 +48,15 @@ class SeasoningSelectDialog(
}
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val list = mutableListOf<GoodsItem>()
private val adapter = SeasoningSearchAdapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { _, v, position ->
list[position].isClicked = true
val item = list.getOrNull(position) ?: return@setOnItemClickListener
item.isClicked = true
notifyItemChanged(position)
v.postDelayed({
onItemSelected(list[position])
onItemSelected(item)
dismiss()
}, 300)
}
@@ -103,7 +103,7 @@ class SeasoningSelectDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -118,7 +118,7 @@ class SeasoningSelectDialog(
super.show()
// 启动 StateFlow 收集,弹窗关闭时由 setOnDismissListener 取消
collectJob = activity.lifecycleScope.launch {
activity.netViewModel.goodsListState.collect { state ->
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success -> loadGoodsList(state.data)
@@ -187,7 +187,7 @@ class SeasoningSelectDialog(
* 请求调料列表
*/
private fun getGoodsList() {
activity.netViewModel.queryGoodsList(
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "1",
pageNum = pageNo,
pageSize = PAGE_SIZE,
@@ -195,6 +195,23 @@ class SeasoningSelectDialog(
)
}
/**
* 加载本地模拟数据,仅用于联调时点击空布局快速验证 UI
*/
private fun loadMockData() {
val nameList = listOf(
"", "白砂糖", "冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
)
val mockData = mutableListOf<GoodsItem>()
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name))
}
pageNo = 1
loadGoodsList(mockData)
}
/**
* 将返回数据转换并填充到列表
*/
@@ -210,7 +227,12 @@ class SeasoningSelectDialog(
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
if (pageNo == 1) list.clear()
list.addAll(records.map { it.toSeasoningEntity() })
list.addAll(records)
list.forEach {
it.oldGoodsId = it.goodsId
//materId才是提交数据需要的goodsId数据
it.goodsId = it.materId ?:""
}
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
@@ -239,16 +261,20 @@ class SeasoningSelectDialog(
false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
emptyViewBinding!!.root.layoutParams = FrameLayout.LayoutParams(
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 {
emptyViewBinding!!.root.minimumHeight = binding.recyclerView.height
adapter.stateView = emptyViewBinding!!.root
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -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()
)
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.model
import android.graphics.Bitmap
import android.net.Uri
@@ -50,5 +50,3 @@ data class FoodCollectionBean(
return result
}
}
@@ -0,0 +1,25 @@
package com.shuwei.dish.match.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* 字典类型 如仓库列表 物品类型 存储方式等
*/
@Parcelize
data class DictType(
val id: String? = null,
val value: String? = null,
val type: String = "",
var position: Int = 0
) : Parcelable {
/**
* 按字段名取值,供 extraApiKeys 映射直接使用
*/
operator fun get(fieldName: String): String = when (fieldName) {
"id" -> id ?: ""
"value" -> value ?: ""
else -> type
}
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.model
import java.io.Serializable
@@ -17,4 +17,3 @@ data class FoodRecord(
var isOriginalData: Boolean = true,
var sort: Int = 0
) : Serializable
@@ -0,0 +1,52 @@
package com.shuwei.dish.match.model
/** 表单字段类型枚举 */
enum class FieldType {
/** 普通文本输入 */
TEXT,
/** 整数输入 */
INTEGER,
/** 小数输入 */
DECIMAL,
/** 下拉选择 */
DROPDOWN,
/** 日期选择弹窗 */
DATE_PICKER,
/** 固定值,不可编辑 */
FIXED,
/** 子列表容器,用于动态增减行(如"包含食材" */
CHILDREN
}
/**
* 动态表单字段数据模型
* @param label 字段标签名称
* @param type 字段类型,决定渲染哪种输入控件
* @param apiKey 非下拉字段对应的接口参数名,空字符串表示不提交
* @param extraApiKeys 下拉字段的多接口参数映射,key=接口参数名,value=DictType 字段名(id/value/type
* @param required 是否必填,提交时校验
* @param hidden 是否隐藏,hidden=true 时不渲染该字段
* @param hint 校验失败提示文案,同时作为输入框 hint
* @param options 下拉框选项列表(DROPDOWN 类型使用)
* @param value 当前显示值/输入值;DATE_PICKER 存 yyyy-MM-dd 显示格式
* @param extraValues 下拉选中后按 extraApiKeys 映射存储的提交值,key=接口参数名
* @param children 子字段模板(CHILDREN 类型使用),每行按此模板创建一组字段
* @param childAddLabel 添加按钮文案(CHILDREN 类型使用),如 "+ 添加食材"
* @param childrenRows 子列表实际数据(CHILDREN 类型使用),每行是一个 List<FormField>
*/
data class FormField(
val label: String,
val type: FieldType,
val apiKey: String = "",
val extraApiKeys: Map<String, String> = emptyMap(),
val required: Boolean = false,
val hidden: Boolean = false,
val hint: String = "",
var options: MutableList<DictType> = mutableListOf(),
var value: String = "",
val extraValues: MutableMap<String, String> = mutableMapOf(),
val children: List<FormField>? = null,
val childAddLabel: String? = null,
val childrenRows: MutableList<MutableList<FormField>> = mutableListOf(),
var onDropdownSelected: ((DictType) -> Unit)? = null
)
@@ -1,5 +1,7 @@
package com.shuwei.dish.match.entity
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
/**
@@ -8,11 +10,12 @@ import java.io.Serializable
*/
data class GoodsItem(
// 接口返回字段
val goodsId: String = "",
val goodsName: String? = null,
var goodsId: String = "",
var goodsName: String? = null,
val popularName: String? = null,
val zjmCode: String? = null,
val materId: String? = null,
var materId: String? = null,
var materCode: String? = null,
// 从 CookFoodGoodsEntity 迁移的数据字段(DishPartAdapter 显示及转换用)
var materialType: Int = 0,
@@ -23,16 +26,26 @@ data class GoodsItem(
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
var isItemClicked: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
// true-原始数据,false-非原始数据(识图新增)
var isOriginalData: Boolean = true,
// 识图匹配分数,-1 表示不显示
var foodScore: Int = -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
*/
@@ -42,21 +55,29 @@ data class GoodsItem(
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 SeasoningEntity用于调料选择弹窗
* 转换为 SeasoningEntity用于保存调料完整信息到 dm_seasoning
*/
fun toSeasoningEntity(): SeasoningEntity = SeasoningEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId
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,264 @@
package com.shuwei.dish.match.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* 食堂VO - 食堂下拉列表响应
*/
@Parcelize
data class NutCanteenVO(
val id: Long?,
val canteenCode: String?,
val canteenName: String?,
val orgCode: String?,
val orgName: String?,
val leader: String?,
val leaderPhone: String?,
val useTerminal: Short?,
val address: String?,
val longitude: String?,
val latitude: String?,
val status: Short?
) : Parcelable
/**
* 字典项VO - 字典项下拉响应
*/
@Parcelize
data class NutDictItemVO(
val id: Long?,
val dictType: String?,
val dictCode: String?,
val dictLabel: String?,
val sortOrder: Int?,
val isBuiltin: Short?,
val status: Short?,
val remark: String?
) : Parcelable
/**
* 食材选项VO - 食材下拉响应
*/
@Parcelize
data class NutMaterOptionVO(
val id: Long?,
val materClassName: String?
) : Parcelable
/**
* 包装设备VO - 包装设备下拉响应
*/
@Parcelize
data class NutSupPkgDeviceVO(
val id: Long?,
val deviceNo: String?,
val deviceName: String?
) : Parcelable
/**
* 餐品选项VO - 餐品下拉搜索响应
*/
@Parcelize
data class NutFoodOptionVO(
val foodId: Long?,
val foodCode: String?,
val foodName: String?,
val compositions: List<NutFoodComposition>?
) : Parcelable
/**
* 餐品食材构成
*/
@Parcelize
data class NutFoodComposition(
var materId: Long?,
var ingredientName: String?,
var traceCode: String?,
var amount: Double?,
var isMain: Int?,
//1-主材、2-辅材、3-调料
var mainText: String?=null
) : Parcelable
/**
* 溯源码查食材信息VO
*/
@Parcelize
data class NutSupHygieneIngredientOptionVO(
val traceCode: String?,
val materId: Long?,
val materName: String?,
val vegTypes: String?,
val materClassId: Long?,
val materClassName: String?,
val testNo: String?
) : Parcelable
/**
* 组配任务视频监控VO
*/
@Parcelize
data class NutProdComboVideoVO(
val cameraCode: String?,
val streamUrl: String?,
val snapshotUrl: String?,
val onlineStatus: Int?,
val recordType: Int?,
val startTime: String?,
val endTime: String?
) : Parcelable
/**
* 组配任务DTO - 上报组配任务请求体
*/
data class NutProdComboTaskDTO(
val planDate: String,
val canteenId: Long,
val mealType: Short?,
val foodId: Long?,
val targetDish: String,
val spec: String,
val portions: Int,
val taskDeadline: String?,
val taskSource: Short?,
val ownerName: String?,
val items: List<NutProdComboTaskItem>?
)
/**
* 组配任务食材明细
*/
data class NutProdComboTaskItem(
val ingredientCode: String?,
val ingredientName: String,
val perPortionQty: String,
val unit: String? = "g",
val sortOrder: Int?
)
/**
* 单品净菜包装DTO - 上报单品净菜包装记录请求体
*/
data class NutSupCleanPackageDTO(
val traceCode: String,
val cleanInboundId: Long?,
val cleanName: String,
val packageMethod: String,
val spec: String,
val quantity: Int,
val packageDate: String,
val expiryDate: String,
val storageTemp: String?,
val relatedOrderNo: String?,
val operator: String?,
val deviceId: Long?,
val cameraCode: String?,
val videoUrl: String?
)
/**
* 餐品净菜包装DTO - 上报餐品净菜包装记录请求体
*/
data class NutSupMealPackageDTO(
val foodId: Long,
val mealName: String,
val spec: String,
val packageMethod: String,
val quantity: Int,
val packageDate: String,
val expiryDate: String,
val storageTemp: String?,
val relatedOrderNo: String?,
val operator: String?,
val deviceId: Long?,
val cameraCode: String?,
val videoUrl: String?,
val ingredients: List<NutSupMealPackageIngredient>
)
/**
* 餐品净菜包装食材明细
*/
data class NutSupMealPackageIngredient(
val materId: Long,
val ingredientName: String,
val amount: String?,
val traceCode: String
)
/**
* 组配任务VO - 未完成组配任务列表响应
*/
@Parcelize
data class NutComboTaskVO(
val id: Long?,
val taskNo: String?,
val planDate: String?,
val canteenId: Long?,
val mealType: Int?,
val foodId: Long?,
val targetDish: String?,
val spec: String?,
val portions: Int?,
var comboStatus: Int?,
val ownerName: String?,
val createTime: String?
) : Parcelable
/**
* 组配任务详情VO - 组配任务详情响应
*/
@Parcelize
data class NutComboTaskDetailVO(
var id: Long? = null,
var taskNo: String? = null,
var planDate: String? = null,
var canteenId: Long? = null,
var mealType: Int? = null,
var foodId: Long? = null,
var targetDish: String? = null,
var spec: String? = null,
var portions: Int? = null,
var comboStatus: Int? = null,
var ownerName: String? = null,
var deviceCode: String? = null,
var items: List<NutComboTaskItemDetail>? = null,
) : Parcelable
/**
* 组配任务食材明细
*/
@Parcelize
data class NutComboTaskItemDetail(
val id: Long?,
val ingredientCode: String?,
val ingredientName: String?,
val ingredientClass: Int?,
var traceCode: String?,
val perPortionQty: String?,
var actualQty: String?,
val totalQty: String?,
val unit: String?,
var isClicked: Boolean? = false,
var isSetFinished: Boolean? = false,
) : Parcelable
/**
* 已入库净菜食材VO - 按食材名称查询已入库有库存的净菜记录
*/
@Parcelize
data class NutCleanInboundIngredientVO(
val id: Long?,
val materId: Long?,
val ingredientName: String?,
val traceCode: String?,
val currentStock: String?
) : Parcelable
/**
* ID通用DTO - 用于开始/完成组配等需要id参数的接口
*/
data class IdDTO(
val id: Long
)
@@ -0,0 +1,35 @@
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 = type.label,
val subtitle: String? = null,
val isHidden: Boolean = false,
val onClick: () -> Unit
) {
/** 设置菜单项类型枚举 */
enum class Type(val label: String) {
COOK_MODE("菜品模式"),
SEASONING_CONFIG("调料区设置"),
FOOD_COLLECT("食材采集"),
WEIGHT_CONFIG("减重配置"),
CLEAN_PACKAGE("净菜包"),
MEAL_PACKAGE("餐品净菜包"),
GROUP_TASK("组配任务"),
// MEAL_PACKAGE_TEST("餐品净菜包-测试"),
DB_INSPECT("数据库查看"),
SCALE_OBSERVE("秤数据监控"),
ENV_SWITCH("切换环境"),
CLEAR_DATA("数据清除"),
TEST_SEASONING("测试调料拿取"),
}
}
@@ -1,12 +1,17 @@
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.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
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.Query
import retrofit2.http.Url
@@ -19,7 +24,7 @@ interface ApiService {
suspend fun getFoodDetail(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId",
@Query("foodId") foodId: String
): ApiResponse<CookFoodEntity?>
): ApiResponse<CookFoodDTO?>
/**
@@ -28,7 +33,7 @@ interface ApiService {
@POST
suspend fun submitCookFood(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute",
@Body param: CookFoodEntity
@Body param: CookFoodDTO
): ApiResponse<Any?>
/**
@@ -36,7 +41,7 @@ interface ApiService {
*/
@POST
suspend fun searchFoodList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList",
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
@@ -45,7 +50,7 @@ interface ApiService {
*/
@POST
suspend fun getSamplingList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList",
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
@@ -54,8 +59,18 @@ interface ApiService {
*/
@POST
suspend fun queryGoodsList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<GoodsItem>?>
/**
* 提交采集图片数据
*/
@Multipart
@POST
suspend fun uploadFoodVectorData(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData",
@PartMap params: MutableMap<String, RequestBody>,
@Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?>
}
@@ -0,0 +1,173 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.model.NutSupPkgDeviceVO
import com.shuwei.dish.match.model.NutCanteenVO
import com.shuwei.dish.match.model.NutDictItemVO
import com.shuwei.dish.match.model.NutMaterOptionVO
import com.shuwei.dish.match.model.NutFoodOptionVO
import com.shuwei.dish.match.model.NutSupHygieneIngredientOptionVO
import com.shuwei.dish.match.model.NutProdComboVideoVO
import com.shuwei.dish.match.model.NutProdComboTaskDTO
import com.shuwei.dish.match.model.IdDTO
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.model.NutComboTaskDetailVO
import com.shuwei.dish.match.model.NutComboTaskVO
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
interface ApiServiceV2 {
// ========== 一、下拉选项接口 ==========
/**
* 食堂下拉列表,返回启用状态的食堂列表,支持关键字模糊搜索
*/
@GET
suspend fun getCanteenOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/canteen-options",
@Query("keyword") keyword: String? = null
): ApiResponse<List<NutCanteenVO>>
/**
* 字典项下拉,按 dictType 查询字典项
*/
@GET
suspend fun getDictItems(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/dict-items",
@Query("dictType") dictType: String
): ApiResponse<List<NutDictItemVO>>
/**
* 食材下拉列表,支持关键字模糊搜索
*/
@GET
suspend fun getMaterOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/mater-options",
@Query("keyword") keyword: String? = null
): ApiResponse<List<NutMaterOptionVO>>
/**
* 包装设备下拉(仅在线),返回在线设备的 id、编号、名称
*/
@GET
suspend fun getDeviceOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/device-options"
): ApiResponse<List<NutSupPkgDeviceVO>>
/**
* 餐品下拉搜索(含食材构成),按名称模糊搜索,分页返回
*/
@GET
suspend fun getFoodOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/food-options",
@Query("keyword") keyword: String? = null,
@Query("pageNum") pageNum: Int = 1,
@Query("pageSize") pageSize: Int = 10
): ApiResponse<Page<NutFoodOptionVO>>
/**
* 按溯源码查食材信息
*/
@GET
suspend fun getIngredientByTrace(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/ingredient-by-trace",
@Query("traceCode") traceCode: String
): ApiResponse<List<NutSupHygieneIngredientOptionVO>>
/**
* 按食材名称查询已入库有库存的净菜记录
*/
@GET
suspend fun getInboundIngredientOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound-ingredient-options",
@Query("keyword") keyword: String? = null
): ApiResponse<List<NutCleanInboundIngredientVO>>
// ========== 二、净菜组配接口 ==========
/**
* 上报组配任务(含食材明细)
*/
@POST
suspend fun addComboTask(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/add",
@Body param: NutComboTaskDetailVO
): ApiResponse<Unit>
/**
* 开始组配,组配状态 0 -> 1
*/
@POST
suspend fun startComboTask(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/start",
@Body param: IdDTO
): ApiResponse<Unit>
/**
* 完成组配,组配状态 1 -> 2
*/
@POST
suspend fun finishComboTask(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/finish",
@Body param: IdDTO
): ApiResponse<Unit>
/**
* 获取组配任务视频监控地址
*/
@GET
suspend fun getComboTaskVideo(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/video",
@Query("taskNo") taskNo: String
): ApiResponse<NutProdComboVideoVO>
// ========== 三、单品净菜包装接口 ==========
/**
* 上报单品净菜包装记录
*/
@POST
suspend fun addCleanPackage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/clean-package/add",
@Body param: Map<String, @JvmSuppressWildcards Any?>
): ApiResponse<Unit>
// ========== 四、餐品净菜包装接口 ==========
/**
* 上报餐品净菜包装记录
*/
@POST
suspend fun addMealPackage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/meal-package/add",
@Body param: Map<String, @JvmSuppressWildcards Any?>
): ApiResponse<Unit>
// ========== 五、组配任务接口 ==========
/**
* 查询未完成组配任务(分页)
*/
@GET
suspend fun getIncompleteComboTasks(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/incomplete",
@Query("deviceCode") deviceCode: String? = null,
@Query("keyword") keyword: String? = null,
@Query("pageNum") pageNum: Int = 1,
@Query("pageSize") pageSize: Int = 10
): ApiResponse<Page<NutComboTaskVO>>
/**
* 获取组配任务详情
*/
@GET
suspend fun getComboTaskDetail(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/combo-task/detail",
@Query("id") id: Long
): ApiResponse<NutComboTaskDetailVO>
}
@@ -3,13 +3,22 @@ package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO
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 +37,7 @@ class NetViewModel(
/**
* 提交制作菜品
*/
fun submitCookFood(entity: CookFoodEntity) {
fun submitCookFood(entity: CookFoodDTO) {
viewModelScope.launch {
_submitCookFoodState.value = UiState.Loading
_submitCookFoodState.value = repository.submitCookFood(entity)
@@ -38,8 +47,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 +60,51 @@ class NetViewModel(
}
}
fun resetFoodDetailState() {
_foodDetailState.value = UiState.Idle
}
/**
* 搜索菜品列表的 UI 状态流UI 层通过 collect 监听
* 搜索菜品列表的 UI 状态流
* 使用 replay=1 保证 Fragment 重建后能收到最近一次结果;
* emit 时包一层 Event wrapper 打破 equals 去重,确保相同数据也能触发 collect。
*/
private val _searchFoodState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val searchFoodState: StateFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asStateFlow()
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 监听
*/
private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val samplingListState: StateFlow<UiState<MutableList<FoodRecord>?>> = _samplingListState.asStateFlow()
@@ -86,12 +122,37 @@ class NetViewModel(
}
}
/**
* 查询采样数据列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
fun getSamplingListWithCallback(
param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getSamplingList(param))
}
}
/**
* 查询物品信息列表的 UI 状态流(食材/调料共用),UI 层通过 collect 监听
*/
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=调料
@@ -99,7 +160,7 @@ class NetViewModel(
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称列表批量查询,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryGoodsList(
goodsType: String,
@@ -107,7 +168,7 @@ class NetViewModel(
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<String>? = null
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
@@ -122,4 +183,76 @@ class NetViewModel(
_goodsListState.value = repository.queryGoodsList(param)
}
}
/**
* 食材搜索弹窗专用的查询方法,结果写入 foodSearchGoodsListState,不影响 goodsListState
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
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)
}
}
/**
* 上传采集菜品信息 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)
}
}
@@ -0,0 +1,327 @@
package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.model.NutCanteenVO
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.model.NutComboTaskDetailVO
import com.shuwei.dish.match.model.NutComboTaskVO
import com.shuwei.dish.match.model.NutDictItemVO
import com.shuwei.dish.match.model.NutFoodOptionVO
import com.shuwei.dish.match.model.NutMaterOptionVO
import com.shuwei.dish.match.model.NutProdComboTaskDTO
import com.shuwei.dish.match.model.NutProdComboVideoVO
import com.shuwei.dish.match.model.NutSupHygieneIngredientOptionVO
import com.shuwei.dish.match.model.NutSupPkgDeviceVO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 网络请求 ViewModel V2,所有网络操作通过 RemoteRepositoryV2 发起
*/
class NetViewModelV2(
private val repository: RemoteRepositoryV2 = RemoteRepositoryV2()
) : ViewModel() {
// ========== 下拉选项接口 ==========
// 食堂下拉列表
private val _canteenOptionsState = MutableStateFlow<UiState<List<NutCanteenVO>?>>(UiState.Idle)
val canteenOptionsState: StateFlow<UiState<List<NutCanteenVO>?>> =
_canteenOptionsState.asStateFlow()
fun getCanteenOptions(keyword: String? = null) {
viewModelScope.launch {
_canteenOptionsState.value = UiState.Loading
_canteenOptionsState.value = repository.getCanteenOptions(keyword)
}
}
// 字典项下拉
private val _dictItemsState = MutableStateFlow<UiState<List<NutDictItemVO>?>>(UiState.Idle)
val dictItemsState: StateFlow<UiState<List<NutDictItemVO>?>> = _dictItemsState.asStateFlow()
// fun getDictItems(dictType: String) {
// viewModelScope.launch {
// _dictItemsState.value = UiState.Loading
// _dictItemsState.value = repository.getDictItems(dictType)
// }
// }
// 食材下拉列表
private val _materOptionsState =
MutableStateFlow<UiState<List<NutMaterOptionVO>?>>(UiState.Idle)
val materOptionsState: StateFlow<UiState<List<NutMaterOptionVO>?>> =
_materOptionsState.asStateFlow()
fun getMaterOptions(keyword: String? = null) {
viewModelScope.launch {
_materOptionsState.value = UiState.Loading
_materOptionsState.value = repository.getMaterOptions(keyword)
}
}
// 包装设备下拉
private val _deviceOptionsState =
MutableStateFlow<UiState<List<NutSupPkgDeviceVO>?>>(UiState.Idle)
val deviceOptionsState: StateFlow<UiState<List<NutSupPkgDeviceVO>?>> =
_deviceOptionsState.asStateFlow()
fun getDeviceOptions() {
viewModelScope.launch {
_deviceOptionsState.value = UiState.Loading
_deviceOptionsState.value = repository.getDeviceOptions()
}
}
// 餐品下拉搜索
private val _foodOptionsState = MutableStateFlow<UiState<Page<NutFoodOptionVO>?>>(UiState.Idle)
val foodOptionsState: StateFlow<UiState<Page<NutFoodOptionVO>?>> =
_foodOptionsState.asStateFlow()
// fun getFoodOptions(keyword: String? = null, pageNum: Int = 1, pageSize: Int = 10) {
// viewModelScope.launch {
// _foodOptionsState.value = UiState.Loading
// _foodOptionsState.value = repository.getFoodOptions(keyword, pageNum, pageSize)
// }
// }
/**
* 字典项下拉(回调版本),不依赖 StateFlow,适合多次不同 dictType 的场景
*/
fun getDictItemsWithCallback(
dictType: String,
onLoading: () -> Unit = {},
onResult: (UiState<List<NutDictItemVO>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getDictItems(dictType))
}
}
/**
* 餐品下拉(回调版本),不依赖 StateFlow,适合多次调用的场景
*/
fun getFoodOptionsWithCallback(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 10,
onLoading: () -> Unit = {},
onResult: (UiState<Page<NutFoodOptionVO>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
val state = repository.getFoodOptions(keyword, pageNum, pageSize)
onResult(state)
}
}
/**
* 设备下拉(回调版本),不依赖 StateFlow,适合多次调用的场景
*/
fun getDeviceOptionsWithCallback(
onLoading: () -> Unit = {},
onResult: (UiState<List<NutSupPkgDeviceVO>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getDeviceOptions())
}
}
// 按溯源码查食材信息
private val _ingredientByTraceState =
MutableStateFlow<UiState<List<NutSupHygieneIngredientOptionVO>?>>(UiState.Idle)
val ingredientByTraceState: StateFlow<UiState<List<NutSupHygieneIngredientOptionVO>?>> =
_ingredientByTraceState.asStateFlow()
fun getIngredientByTrace(traceCode: String) {
viewModelScope.launch {
_ingredientByTraceState.value = UiState.Loading
_ingredientByTraceState.value = repository.getIngredientByTrace(traceCode)
}
}
// ========== 净菜组配接口 ==========
// 上报组配任务
private val _addComboTaskState = MutableStateFlow<UiState<Unit?>>(UiState.Idle)
val addComboTaskState: StateFlow<UiState<Unit?>> = _addComboTaskState.asStateFlow()
fun addComboTask(param: NutComboTaskDetailVO, block: (Boolean, String) -> Unit) {
viewModelScope.launch {
val uiState = repository.addComboTask(param)
when (uiState) {
is UiState.Success -> {
block(true, "")
}
is UiState.Error -> {
block(false, uiState.msg)
}
is UiState.Loading -> {}
is UiState.Idle -> {}
}
}
}
// fun addComboTask(param: NutProdComboTaskDTO) {
// viewModelScope.launch {
// _addComboTaskState.value = UiState.Loading
// _addComboTaskState.value = repository.addComboTask(param)
// }
// }
// 开始组配
private val _startComboTaskState = MutableStateFlow<UiState<Unit?>>(UiState.Idle)
val startComboTaskState: StateFlow<UiState<Unit?>> = _startComboTaskState.asStateFlow()
fun startComboTask(id: Long) {
viewModelScope.launch {
_startComboTaskState.value = UiState.Loading
_startComboTaskState.value = repository.startComboTask(id)
}
}
// 完成组配
private val _finishComboTaskState = MutableStateFlow<UiState<Unit?>>(UiState.Idle)
val finishComboTaskState: StateFlow<UiState<Unit?>> = _finishComboTaskState.asStateFlow()
fun finishComboTask(id: Long) {
viewModelScope.launch {
_finishComboTaskState.value = UiState.Loading
_finishComboTaskState.value = repository.finishComboTask(id)
}
}
// 获取组配任务视频监控地址
private val _comboTaskVideoState = MutableStateFlow<UiState<NutProdComboVideoVO?>>(UiState.Idle)
val comboTaskVideoState: StateFlow<UiState<NutProdComboVideoVO?>> =
_comboTaskVideoState.asStateFlow()
fun getComboTaskVideo(taskNo: String) {
viewModelScope.launch {
_comboTaskVideoState.value = UiState.Loading
_comboTaskVideoState.value = repository.getComboTaskVideo(taskNo)
}
}
// ========== 单品净菜包装接口 ==========
private val _addCleanPackageState = MutableStateFlow<UiState<Unit?>>(UiState.Idle)
val addCleanPackageState: StateFlow<UiState<Unit?>> = _addCleanPackageState.asStateFlow()
fun addCleanPackage(param: Map<String, Any?>, callback: (Boolean, String) -> Unit) {
viewModelScope.launch {
val uiState = repository.addCleanPackage(param)
when (uiState) {
is UiState.Success -> {
callback(true, "")
}
is UiState.Error -> {
callback(false, uiState.msg)
}
is UiState.Loading -> {}
is UiState.Idle -> {}
}
}
}
fun addCleanPackage(param: Map<String, Any?>) {
viewModelScope.launch {
_addCleanPackageState.value = UiState.Loading
_addCleanPackageState.value = repository.addCleanPackage(param)
}
}
// ========== 餐品净菜包装接口 ==========
private val _addMealPackageState = MutableStateFlow<UiState<Unit?>>(UiState.Idle)
val addMealPackageState: StateFlow<UiState<Unit?>> = _addMealPackageState.asStateFlow()
fun addMealPackage(param: Map<String, Any?>, callback: (Boolean, String) -> Unit) {
viewModelScope.launch {
val uiState = repository.addMealPackage(param)
when (uiState) {
is UiState.Success -> {
callback(true, "")
}
is UiState.Error -> {
callback(false, uiState.msg)
}
is UiState.Loading -> {}
is UiState.Idle -> {}
}
}
}
fun addMealPackage(param: Map<String, Any>) {
viewModelScope.launch {
_addMealPackageState.value = UiState.Loading
_addMealPackageState.value = repository.addMealPackage(param)
}
}
// ========== 组配任务接口 ==========
/**
* 查询未完成组配任务(回调版本)
*/
fun getIncompleteComboTasksWithCallback(
deviceCode: String? = null,
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 10,
onLoading: () -> Unit = {},
onResult: (UiState<Page<NutComboTaskVO>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(
repository.getIncompleteComboTasks(
deviceCode = deviceCode,
keyword = keyword,
pageNum = pageNum,
pageSize = pageSize
)
)
}
}
/**
* 获取组配任务详情(回调版本)
*/
fun getComboTaskDetailWithCallback(
id: Long,
onLoading: () -> Unit = {},
onResult: (UiState<NutComboTaskDetailVO?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getComboTaskDetail(id))
}
}
// ========== 已入库净菜食材接口 ==========
/**
* 按食材名称查询已入库有库存的净菜记录(回调版本)
*/
fun getInboundIngredientOptionsWithCallback(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 20,
onLoading: () -> Unit = {},
onResult: (UiState<List<NutCleanInboundIngredientVO>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getInboundIngredientOptions(keyword))
}
}
}
@@ -0,0 +1,10 @@
package com.shuwei.dish.match.net
/** 通用分页响应包装 */
data class Page<T>(
val records: List<T>?,
val total: Long?,
val size: Int?,
val current: Int?,
val pages: Int?
)
@@ -1,8 +1,10 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import okhttp3.MultipartBody
import okhttp3.RequestBody
/**
* 网络数据仓库,封装所有 ApiService 调用
@@ -10,84 +12,42 @@ import com.shuwei.dish.match.entity.GoodsItem
*/
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 searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
safeApiCall { apiService.searchFoodList(param = param) }
/**
* 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用)
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<GoodsItem>?> {
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 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 uploadFoodVectorData(
params: MutableMap<String, RequestBody>,
foodPics: List<MultipartBody.Part>
): UiState<List<String>?> =
safeApiCall { apiService.uploadFoodVectorData(params = params, foodPics = foodPics) }
}
@@ -0,0 +1,109 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.model.IdDTO
import com.shuwei.dish.match.model.NutCanteenVO
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.model.NutComboTaskDetailVO
import com.shuwei.dish.match.model.NutComboTaskVO
import com.shuwei.dish.match.model.NutDictItemVO
import com.shuwei.dish.match.model.NutFoodOptionVO
import com.shuwei.dish.match.model.NutMaterOptionVO
import com.shuwei.dish.match.model.NutProdComboTaskDTO
import com.shuwei.dish.match.model.NutProdComboVideoVO
import com.shuwei.dish.match.model.NutSupHygieneIngredientOptionVO
import com.shuwei.dish.match.model.NutSupPkgDeviceVO
import com.shuwei.dish.match.base.GlobalData
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* 网络数据仓库 V2,封装所有 ApiServiceV2 调用
*/
class RemoteRepositoryV2 {
private val apiService: ApiServiceV2 by lazy {
Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl)
.client(HttpManager.instance.client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiServiceV2::class.java)
}
/** 统一封装 API 调用:处理响应映射和异常转换,返回 UiState */
private suspend fun <T> safeApiCall(call: suspend () -> ApiResponse<T>): UiState<T?> {
return try {
val resp = call()
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
UiState.Error("-1", getApiException(e).errorMsg)
}
}
// ========== 下拉选项接口 ==========
suspend fun getCanteenOptions(keyword: String? = null): UiState<List<NutCanteenVO>?> =
safeApiCall { apiService.getCanteenOptions(keyword = keyword) }
suspend fun getDictItems(dictType: String): UiState<List<NutDictItemVO>?> =
safeApiCall { apiService.getDictItems(dictType = dictType) }
suspend fun getMaterOptions(keyword: String? = null): UiState<List<NutMaterOptionVO>?> =
safeApiCall { apiService.getMaterOptions(keyword = keyword) }
suspend fun getDeviceOptions(): UiState<List<NutSupPkgDeviceVO>?> =
safeApiCall { apiService.getDeviceOptions() }
suspend fun getFoodOptions(
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 10
): UiState<Page<NutFoodOptionVO>?> =
safeApiCall { apiService.getFoodOptions(keyword = keyword, pageNum = pageNum, pageSize = pageSize) }
suspend fun getIngredientByTrace(traceCode: String): UiState<List<NutSupHygieneIngredientOptionVO>?> =
safeApiCall { apiService.getIngredientByTrace(traceCode = traceCode) }
suspend fun getInboundIngredientOptions(keyword: String? = null): UiState<List<NutCleanInboundIngredientVO>?> =
safeApiCall { apiService.getInboundIngredientOptions(keyword = keyword) }
// ========== 净菜组配接口 ==========
suspend fun addComboTask(param: NutComboTaskDetailVO): UiState<Unit?> =
safeApiCall { apiService.addComboTask(param = param) }
suspend fun startComboTask(id: Long): UiState<Unit?> =
safeApiCall { apiService.startComboTask(param = IdDTO(id)) }
suspend fun finishComboTask(id: Long): UiState<Unit?> =
safeApiCall { apiService.finishComboTask(param = IdDTO(id)) }
suspend fun getComboTaskVideo(taskNo: String): UiState<NutProdComboVideoVO?> =
safeApiCall { apiService.getComboTaskVideo(taskNo = taskNo) }
// ========== 单品净菜包装接口 ==========
suspend fun addCleanPackage(param: Map<String, Any?>): UiState<Unit?> =
safeApiCall { apiService.addCleanPackage(param = param) }
// ========== 餐品净菜包装接口 ==========
suspend fun addMealPackage(param: Map<String, Any?>): UiState<Unit?> =
safeApiCall { apiService.addMealPackage(param = param) }
// ========== 组配任务接口 ==========
suspend fun getIncompleteComboTasks(
deviceCode: String? = null,
keyword: String? = null,
pageNum: Int = 1,
pageSize: Int = 10
): UiState<Page<NutComboTaskVO>?> =
safeApiCall { apiService.getIncompleteComboTasks(deviceCode = deviceCode, keyword = keyword, pageNum = pageNum, pageSize = pageSize) }
suspend fun getComboTaskDetail(id: Long): UiState<NutComboTaskDetailVO?> =
safeApiCall { apiService.getComboTaskDetail(id = id) }
}
@@ -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
}
}
@@ -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
/**
@@ -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")
@@ -162,14 +188,14 @@ object ScaleServiceManager {
}
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)
}
}
@@ -228,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 服务端已停止")
}
@@ -118,13 +158,15 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
WebSocketServer(InetSocketAddress(port)) {
/** 当前活跃连接计数,用于替代 connections.isNullOrEmpty() 避免竞态 */
private var connectionCount = 0
private val connectionCount = AtomicInteger(0)
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
connectionCount++
connectionCount.incrementAndGet()
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
if (latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
@@ -132,13 +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")
// 用计数器判断是否还有活跃连接,避免依赖 connections 集合的移除时机导致竞态
if (--connectionCount <= 0) {
connectionCount = 0
if (connectionCount.decrementAndGet() <= 0) {
connectionCount.set(0)
onConnectionChanged?.invoke(false)
}
}
@@ -151,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
}
@@ -0,0 +1,716 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import androidx.core.content.IntentCompat
import androidx.recyclerview.widget.GridLayoutManager
import com.shuwei.dish.match.adapter.FormFieldAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivityCleanPackBinding
import com.shuwei.dish.match.model.DictType
import com.shuwei.dish.match.model.FieldType
import com.shuwei.dish.match.model.FormField
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.model.NutFoodOptionVO
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.SpTool
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.roundedOneDecimalPlace
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 kotlinx.coroutines.Job
/**
* 净菜包装表单页面,根据类型显示不同的表单字段
*/
class CleanPackActivity : BaseActivity() {
companion object {
private const val TAG = "CleanPackActivity"
/** 单品净菜包装 */
const val TYPE_CLEAN = 1
/** 餐品净菜包装 */
const val TYPE_MEAL = 2
const val EXTRA_TYPE = "extra_type"
}
private lateinit var binding: ActivityCleanPackBinding
private val fields = mutableListOf<FormField>()
private val formAdapter by lazy { FormFieldAdapter(fields) }
private val viewModelV2 by lazy { NetViewModelV2() }
private var packType: Int = TYPE_CLEAN
// 字段引用,用于跨方法访问
private var traceCodeField: FormField? = null
private var cleanNameField: FormField? = null
private var packageMethodField: FormField? = null
private var deviceField: FormField? = null
private var foodOptionsField: FormField? = null
// private var packagingSpecField: FormField? = null
private var ingredientsField: FormField? = null
private var traceCodeJob: Job? = null
// 缓存餐品选项完整数据,用于选择后获取 compositions
private val foodOptionsData = mutableListOf<NutFoodOptionVO>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
statusBarDarkFont(enable = true)
binding = ActivityCleanPackBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
packType = intent.getIntExtra(EXTRA_TYPE, TYPE_CLEAN)
// 根据类型设置标题
val title = when (packType) {
TYPE_CLEAN -> "净菜包"
TYPE_MEAL -> "餐品净菜包"
else -> return
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
hideKeyboard()
}
}, titleAction = {
it.text = title
}, rightIconAction = {
it.gone()
}, backAction = {
it.visible()
it.setOnClickListener {
finish()
}
})
addWeightListener()
// 初始化 RecyclerView
// val flexboxLayoutManager = FlexboxLayoutManager(this).apply {
// flexDirection = FlexDirection.ROW
// flexWrap = FlexWrap.WRAP
// alignItems = AlignItems.STRETCH
// }
val layoutManager = GridLayoutManager(this, 2)
binding.rvForm.layoutManager = layoutManager
binding.rvForm.adapter = formAdapter
// 根据类型初始化表单数据
when (packType) {
TYPE_CLEAN -> initCleanPackFields()
TYPE_MEAL -> initMealPackFields()
}
binding.btnSubmit.setOnClickListener {
hideKeyboard()
if (!validateFields()) return@setOnClickListener
// when (packType) {
// TYPE_CLEAN -> submitCleanPackage()
// TYPE_MEAL -> submitMealPackage()
// }
submitCleanPackage()
}
binding.ivClearWeight.clickWithDebounce {
initialWeight = 0.0
SpTool.configWeight = 0
WeightUtil.tareTwo(1)
}
binding.flSearchBar.clickWithDebounce {
val launchIntent = Intent(this, FoodSearchV2Activity::class.java)
startActivity(launchIntent) { data ->
data?:return@startActivity
IntentCompat.getParcelableExtra<NutCleanInboundIngredientVO>(
data,
FoodSearchV2Activity.INGREDIENT_ITEM,
NutCleanInboundIngredientVO::class.java
)?.let { food ->
this@CleanPackActivity.currentFood = food
binding.tvFoodName.text = food.ingredientName
}
}
}
}
private var currentFood: NutCleanInboundIngredientVO? = null
/**
* 初始化单品净菜包装表单字段
* 对应接口 NutSupCleanPackageDTO
*/
private fun initCleanPackFields() {
fields.clear()
// // 溯源码
// traceCodeField = FormField(
// label = "溯源码",
// type = FieldType.TEXT,
// apiKey = "traceCode",
// required = true
// )
// fields.add(traceCodeField!!)
// 净菜名称
// cleanNameField = FormField(
// label = "净菜名称",
// type = FieldType.DROPDOWN,
//// apiKey = "cleanName",
// extraApiKeys = mapOf("cleanName" to "value", "traceCode" to "type"),
// required = true
// )
// fields.add(cleanNameField!!)
// 包装方式(下拉)
packageMethodField = FormField(
label = "包装方式",
type = FieldType.DROPDOWN,
// extraApiKeys = mapOf("packageMethodId" to "id", "packageMethod" to "value"),
extraApiKeys = mapOf("packageMethod" to "type"),
required = true
)
fields.add(packageMethodField!!)
fields.add(
FormField(label = "包装规格", type = FieldType.TEXT, apiKey = "spec", required = false)
)
fields.add(
FormField(
label = "包装份数",
type = FieldType.INTEGER,
apiKey = "quantity",
required = true,
hint = "请输入整数"
)
)
fields.add(
FormField(
label = "包装日期",
type = FieldType.DATE_PICKER,
apiKey = "packageDate",
required = true
)
)
fields.add(
FormField(
label = "保质期至",
type = FieldType.DATE_PICKER,
apiKey = "expiryDate",
required = true
)
)
fields.add(
FormField(
label = "存放温度(℃)",
type = FieldType.DECIMAL,
apiKey = "storageTemp",
required = false,
hint = "请输入小数"
)
)
fields.add(
FormField(
label = "包装人",
type = FieldType.TEXT,
apiKey = "operator",
required = false
)
)
// 终端设备(下拉)
deviceField = FormField(
label = "终端设备",
type = FieldType.DROPDOWN,
extraApiKeys = mapOf("deviceId" to "id"),
required = false
)
fields.add(deviceField!!)
formAdapter.submitList(fields.filter { !it.hidden })
// 监听溯源码输入,自动填充净菜名称
// setupTraceCodeQuery()
// 加载下拉选项
// loadDictItems("sup_package_method", packageMethodField!!)
loadDictItemsWithCallback("sup_package_method", packageMethodField!!)
loadDeviceOptionsWithCallback(deviceField)
}
/**
* 初始化餐品净菜包装表单字段
* 对应接口 NutSupMealPackageDTO
*/
private fun initMealPackFields() {
fields.clear()
// 包装餐品(下拉)
foodOptionsField = FormField(
label = "包装餐品", type = FieldType.DROPDOWN,
extraApiKeys = mapOf("foodId" to "id"),
required = true
)
fields.add(foodOptionsField!!)
// 包装规格(下拉)
// packagingSpecField = FormField(
// label = "包装规格", type = FieldType.DROPDOWN,
// extraApiKeys = mapOf("spec" to "value"),
// required = true
// )
// fields.add(packagingSpecField!!)
// 包装方式(下拉)
packageMethodField =
FormField(
label = "包装方式", type = FieldType.DROPDOWN,
// extraApiKeys = mapOf("packageMethodId" to "id", "packageMethod" to "value"),
extraApiKeys = mapOf("packageMethod" to "type"),
required = true
)
fields.add(packageMethodField!!)
// 包装份数
fields.add(
FormField(
label = "包装份数", type = FieldType.INTEGER,
apiKey = "quantity", required = true, hint = "请输入份数"
)
)
// 包含食材(子列表容器)
val ingredientTemplate = listOf(
FormField(
label = "食材溯源码",
type = FieldType.TEXT,
apiKey = "traceCode",
required = true,
hint = "溯源码"
),
FormField(
label = "食材名称",
type = FieldType.FIXED,
extraApiKeys = mapOf("materId" to "id", "ingredientName" to "value"),
required = true
),
FormField(
label = "分类", type = FieldType.FIXED,
// extraApiKeys = mapOf("classify" to "value"),
apiKey = "classify",
required = true,
// options = mutableListOf(
// DictType(id = "1", value = "蔬菜类", type = "蔬菜类"),
// DictType(id = "2", value = "肉禽类", type = "肉禽类"),
// DictType(id = "3", value = "水产类", type = "水产类"),
// )
),
FormField(
label = "每份用量",
type = FieldType.TEXT,
apiKey = "amount",
required = true,
hint = "如 120g"
),
)
ingredientsField = FormField(
label = "包含食材", type = FieldType.CHILDREN,
required = true,
children = ingredientTemplate,
childAddLabel = "+ 添加食材"
)
fields.add(ingredientsField!!)
// 餐品选中后,根据 position 从 compositions 加载包含食材
foodOptionsField!!.onDropdownSelected = { dictType ->
val position = dictType.position
if (position >= 0 && position < foodOptionsData.size) {
val compositions = foodOptionsData[position].compositions ?: emptyList()
val ingredients = ingredientsField!!
ingredients.childrenRows.clear()
for (comp in compositions) {
// 每行复制模板的4个字段,将 composition 数据填充进去
val newRow = ingredients.children?.map { template ->
// 根据 extraApiKeys 判断是否为食材名称字段
val hasMaterId = template.extraApiKeys.containsKey("materId")
val hasIngredientName = template.extraApiKeys.containsKey("ingredientName")
val fieldValue = if (hasIngredientName) comp.ingredientName ?: "" else ""
val fieldExtraValues = mutableMapOf<String, String>()
template.extraApiKeys.forEach { (apiKey, dictField) ->
fieldExtraValues[apiKey] = when (dictField) {
"id" -> comp.materId?.toString() ?: ""
"value" -> comp.ingredientName ?: ""
else -> ""
}
}
FormField(
label = template.label,
type = template.type,
apiKey = template.apiKey,
extraApiKeys = template.extraApiKeys,
required = template.required,
hint = template.hint,
options = template.options,
value = fieldValue,
extraValues = fieldExtraValues
)
}?.toMutableList() ?: mutableListOf()
ingredients.childrenRows.add(newRow)
}
val idx = fields.indexOf(ingredients)
if (idx >= 0) formAdapter.notifyItemChanged(idx)
}
}
// 包装日期
fields.add(
FormField(
label = "包装日期", type = FieldType.DATE_PICKER,
apiKey = "packageDate", required = true
)
)
// 保质期至
fields.add(
FormField(
label = "保质期至", type = FieldType.DATE_PICKER,
apiKey = "expiryDate", required = true
)
)
// 存放温度
fields.add(
FormField(
label = "存放温度(℃)", type = FieldType.DECIMAL,
apiKey = "storageTemp", required = false, hint = "如 3.8"
)
)
// 包装人
fields.add(
FormField(
label = "包装人",
type = FieldType.FIXED,
apiKey = "operator",
required = false,
hint = "请输入包装人",
value = GlobalData.userName
)
)
// 终端设备(下拉)
deviceField = FormField(
label = "终端设备", type = FieldType.DROPDOWN,
extraApiKeys = mapOf("deviceId" to "id"),
required = false
)
fields.add(deviceField!!)
// 操作类型(暂时固定)
fields.add(
FormField(
label = "操作类型", type = FieldType.FIXED,
apiKey = "opType",
required = true,
value = "设备自动"
)
)
formAdapter.submitList(fields.filter { !it.hidden })
// 加载下拉选项
loadFoodOptionsWithKeyword()
loadDictItemsWithCallback("sup_package_method", packageMethodField!!)
// loadDictItemsWithCallback("sup_package_spec", packagingSpecField!!)
loadDeviceOptionsWithCallback(deviceField)
}
/**
* 监听溯源码字段变化,输入后 800ms 无变化则触发查询
*/
private fun setupTraceCodeQuery() {
// val field = traceCodeField ?: return
// val cleanField = cleanNameField ?: return
//
// traceCodeJob = lifecycleScope.launch {
// var lastValue = ""
// while (true) {
// val current = field.value.trim()
// if (current != lastValue && current.isNotBlank()) {
// lastValue = current
// viewModelV2.getIngredientByTrace(current)
// }
// delay(800)
// }
// }
//
// lifecycleScope.launch {
// viewModelV2.ingredientByTraceState.collect { state ->
// when (state) {
// is UiState.Success -> {
// val data = state.data
// if (!data.isNullOrEmpty()) {
// val info = data[0]
// cleanField.value = info.materName ?: ""
// // 通知 adapter 刷新净菜名称字段(索引为 1)
// val idx = fields.indexOf(cleanField)
// if (idx >= 0) formAdapter.notifyItemChanged(idx)
// }
// }
//
// is UiState.Error -> {
// toast(state.msg)
// }
//
// else -> {}
// }
// }
// }
}
override fun onDestroy() {
super.onDestroy()
traceCodeJob?.cancel()
WeightUtil.removeWeightListener(TAG)
}
/**
* 加载餐品下拉选项(回调版本)
*/
private fun loadFoodOptionsWithKeyword() {
val field = foodOptionsField ?: return
viewModelV2.getFoodOptionsWithCallback(
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> {
val records = state.data?.records ?: emptyList()
foodOptionsData.clear()
foodOptionsData.addAll(records)
val options = records.mapIndexed { index, vo ->
DictType(
id = vo.foodId?.toString() ?: "",
value = vo.foodName ?: "",
type = vo.foodCode ?: "",
position = index
)
}
field.options.clear()
field.options.addAll(options)
val index = fields.indexOf(field)
if (index >= 0) formAdapter.notifyItemChanged(index)
}
is UiState.Error -> {
toast("加载餐品列表失败:${state.msg}")
}
else -> {}
}
}
)
}
/**
* 加载字典项下拉(回调版本),适合多次不同 dictType 的场景
*/
private fun loadDictItemsWithCallback(dictType: String, targetField: FormField) {
viewModelV2.getDictItemsWithCallback(
dictType = dictType,
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> {
val options = state.data?.map { vo ->
DictType(
id = vo.id?.toString() ?: "",
value = vo.dictLabel ?: "",
type = vo.dictCode ?: ""
)
} ?: emptyList()
targetField.options.clear()
targetField.options.addAll(options)
val index = fields.indexOf(targetField)
if (index >= 0) formAdapter.notifyItemChanged(index)
}
is UiState.Error -> {
toast("加载字典项失败:${state.msg}")
}
else -> {}
}
}
)
}
/**
* 加载设备下拉(回调版本),用于餐品净菜包装
*/
private fun loadDeviceOptionsWithCallback(deviceField: FormField? = null) {
val field = deviceField ?: return
viewModelV2.getDeviceOptionsWithCallback(
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> {
val options = state.data?.map { vo ->
DictType(
id = vo.id?.toString() ?: "",
value = vo.deviceName ?: "",
type = vo.deviceNo ?: ""
)
} ?: emptyList()
field.options.clear()
field.options.addAll(options)
val index = fields.indexOf(field)
if (index >= 0) formAdapter.notifyItemChanged(index)
}
is UiState.Error -> {
toast("加载设备列表失败:${state.msg}")
}
else -> {}
}
}
)
}
/**
* 校验必填字段
*/
private fun validateFields(): Boolean {
for (field in fields) {
if (field.hidden) continue
if (field.required) {
if (field.type == FieldType.CHILDREN) {
if (field.childrenRows.isEmpty()) {
toast("请至少添加一条${field.label}")
return false
}
// 校验子行必填
for ((rowIdx, row) in field.childrenRows.withIndex()) {
for (child in row) {
if (child.required && child.value.isBlank()) {
toast("${rowIdx + 1}${child.label}不能为空")
return false
}
}
}
} else if (field.value.isBlank()) {
toast("${field.label}不能为空")
return false
}
}
}
return true
}
/**
* 收集字段提交值,根据 apiKey / extraApiKeys 规则汇总
* @return 提交参数 Mapkey 为接口参数名,value 为提交值
*/
private fun collectSubmitValues(): MutableMap<String, String?> {
val params = mutableMapOf<String, String?>()
for (field in fields) {
if (field.hidden) continue
// 跳过 CHILDREN 类型,暂不处理
if (field.type == FieldType.CHILDREN) continue
if (field.extraApiKeys.isNotEmpty()) {
// 下拉字段:直接读取 bindDropdown 已解析好的 extraValues
field.extraValues.forEach { (apiKey, value) ->
params[apiKey] = value
}
} else if (field.apiKey.isNotBlank()) {
// 普通字段
params[field.apiKey] = field.value
}
}
return params
}
/**
* 提交单品净菜包装
*/
private fun submitCleanPackage() {
val params = collectSubmitValues()
if (currentWeight <= 0.0) {
toast("未识别到重量")
return
}
if (currentFood == null || currentFood!!.ingredientName.isNullOrBlank() || currentFood!!.traceCode.isNullOrBlank()) {
toast("食材信息错误")
return
}
val food = currentFood!!
params["traceCode"] = food.traceCode
params["cleanInboundId"] = food.id.toString()
params["cleanName"] = food.ingredientName
params["weightPerUnit"] = currentWeight.toString()
viewModelV2.addCleanPackage(params) { state, msg ->
if (state.not()) {
toast("提交失败:$msg")
return@addCleanPackage
}
toast("提交成功")
finish()
}
}
/**
* 提交餐品净菜包装
*/
private fun submitMealPackage() {
val params = collectSubmitValues()
viewModelV2.addMealPackage(params) { state, msg ->
if (state.not()) {
toast("提交失败:$msg")
return@addMealPackage
}
toast("提交成功")
finish()
}
}
private var initialWeight = SpTool.configWeight.toDouble()
private var currentWeight = 0.0
private fun addWeightListener() {
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
currentWeight = weight - initialWeight
binding.tvShowWeight.run {
if (tag != currentWeight) {
val weightText = "${currentWeight.roundedOneDecimalPlace()}g"
text = weightText
tag = currentWeight
}
}
}
}
})
}
}
@@ -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
@@ -3,6 +3,7 @@ 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
@@ -46,7 +47,7 @@ class CookingModeActivity : BaseActivity() {
it.visible()
}, titleAction = {
it.text = "选择菜品"
}, rightIconActon = {
}, rightIconAction = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
@@ -62,7 +63,7 @@ class CookingModeActivity : BaseActivity() {
fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
dbViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
}
private var configFinished = false
@@ -72,7 +73,7 @@ class CookingModeActivity : BaseActivity() {
// block()
// return
// }
// appViewModel.loadSeasoning {
// dbViewModel.loadSeasoning {
// if (it.isEmpty()) {
// showDeviceConfigDialog()
// return@loadSeasoning
@@ -92,22 +93,44 @@ class CookingModeActivity : BaseActivity() {
.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()
}
/** 切换餐次时清空输入框,用此标志跳过 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() {
@@ -116,34 +139,31 @@ class CookingModeActivity : BaseActivity() {
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)
currentFragment()?.getDishList(pageNo = 1, dinnerType = dinnerType, foodName = searchText)
hideKeyboard()
}
private fun loadFragment() {
runCatching {
fragmentList[dinnerType.toInt() - 1].let {
val target = fragmentList[dinnerType.toInt() - 1]
supportFragmentManager.beginTransaction().apply {
if (fragmentList.contains(it)) {
replace(R.id.flSubPage, it)
fragmentList.forEach { fragment ->
if (fragment.isAdded) {
if (fragment == target) show(fragment) else hide(fragment)
} else {
add(R.id.flSubPage, it)
if (fragment == target) add(R.id.flSubPage, fragment)
}
}
commit()
}
}
}.onFailure {
it.printStackTrace()
}
}
fun deleteCookFoodAndGoods(foodId: String, action: () -> Unit) {
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
appViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId)
dbViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -151,19 +171,19 @@ class CookingModeActivity : BaseActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
runCatching {
val isConfigPage = intent?.getBooleanExtra(IS_CONFIG_PAGE, false) ?: false
val isConfigPage = intent.getBooleanExtra(IS_CONFIG_PAGE, 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)
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()
@@ -181,8 +201,10 @@ class CookingModeActivity : BaseActivity() {
isVisible = false
}
public fun getFoodList(param: MutableMap<String, Any>) {
netViewModel.searchFoodList(param = param)
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)
@@ -10,9 +10,12 @@ import androidx.recyclerview.widget.RecyclerView
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.GoodsItem
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
@@ -24,6 +27,7 @@ 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
/**
@@ -36,6 +40,7 @@ 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"
@@ -52,6 +57,7 @@ class FoodRecognizeActivity : BaseActivity() {
activity: BaseActivity,
imageUri: String? = null,
currentWeight: Double = 0.0,
initialWeight: Double = 0.0,
nameScoreList: ArrayList<FoodModule.IdNameScore> = arrayListOf(),
finishCallback: (Boolean, GoodsItem?) -> Unit = { _, _ -> }
) {
@@ -59,6 +65,7 @@ class FoodRecognizeActivity : BaseActivity() {
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
@@ -92,14 +99,17 @@ class FoodRecognizeActivity : BaseActivity() {
/** 当前选中的列表位置,-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() }
@@ -107,11 +117,14 @@ class FoodRecognizeActivity : BaseActivity() {
/** 食材搜索弹窗,持有引用防止重复打开 */
private var foodSearchDialog: FoodSearchDialog? = null
/** 数据不稳定提示弹窗,持有引用通过 isShowing 防止重复弹出 */
private var unstableDialog: CommonDialog? = null
/** 通过name查询IdNameScore */
private val nameScoreMap = mutableMapOf<String, FoodModule.IdNameScore>()
/** 识别物品名称列表 */
private val goodsNameList = mutableListOf<String>()
/** 识别物品名称列表(名称 + 净材种类,用于批量查询接口) */
private val goodsNameList = mutableListOf<GoodsNameQueryDTO>()
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
@@ -121,12 +134,32 @@ class FoodRecognizeActivity : BaseActivity() {
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 ?: "-"
}
}
@@ -167,29 +200,55 @@ 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()
goodsNameList.add(goodsName)
val rawMaterialsType = it.rawMaterialsType
goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType))
nameScoreMap.put(goodsName, it)
}
recognizeNameList = null
}
netViewModel.queryGoodsList(
goodsType = "0",
goodsNames = goodsNameList
)
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 {
@@ -227,10 +286,16 @@ class FoodRecognizeActivity : BaseActivity() {
binding.rvRecognizeList.visible()
binding.tvNoData.gone()
list.clear()
goodsList.forEach {
val food = nameScoreMap[it.goodsName]
val foodScore = ((1 - (food?.score ?: 0.0)) * 10000).toInt()
list.add(it.also { it.foodScore = foodScore })
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()
@@ -368,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)
@@ -428,7 +522,6 @@ class FoodRecognizeActivity : BaseActivity() {
override fun onDestroy() {
super.onDestroy()
cancelPendingWeightUpdate()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -14,9 +14,10 @@ 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.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
@@ -24,6 +25,7 @@ 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
/**
* 菜品搜索页面
@@ -32,7 +34,7 @@ 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"
@@ -41,22 +43,21 @@ class FoodSearchActivity : BaseActivity() {
private lateinit var binding: ActivityFoodSearchBinding
private var list: MutableList<FoodRecord> = mutableListOf()
private var pageType = 0
private var dinnerType = "1"
private val recordAdapter by lazy {
Food2Adapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { adapter, view, position ->
if (pageType == 1) {
val jumpItem = list[position].also { it.dinnerType = dinnerType }
var jumpItem = list.getOrNull(position) ?: return@setOnItemClickListener
if (SpTool.cookMode == 0) {
jumpItem.dinnerType = dinnerType
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, jumpItem as Serializable)
}
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()
}
@@ -69,7 +70,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)
@@ -78,7 +78,7 @@ class FoodSearchActivity : BaseActivity() {
it.visible()
}, titleAction = {
it.text = "菜品搜索"
}, rightIconActon = {
}, rightIconAction = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
@@ -122,7 +122,7 @@ class FoodSearchActivity : BaseActivity() {
binding.ivDishSearch.setOnClickListener { v ->
pageNo = 1
getInputAndSearch()
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
@@ -139,13 +139,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()
}
}
}
@@ -0,0 +1,204 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.InboundIngredientV2Adapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityFoodSearchV2Binding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.NutCleanInboundIngredientVO
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.net.UiState
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 kotlinx.coroutines.launch
/**
* 食材搜索页面 V2,使用 NetViewModelV2 的回调接口查询已入库净菜食材
*/
class FoodSearchV2Activity : BaseActivity() {
companion object {
const val INGREDIENT_ITEM = "ingredientItem"
const val INGREDIENT_NAME = "ingredientName"
const val PAGE_SIZE = 30
}
private lateinit var binding: ActivityFoodSearchV2Binding
private val viewModelV2 by lazy { NetViewModelV2() }
private val list = mutableListOf<NutCleanInboundIngredientVO>()
private val recordAdapter by lazy {
InboundIngredientV2Adapter(list).apply {
isStateViewEnable = true
setOnDebouncedItemClick { _, _, position ->
val item = list.getOrNull(position) ?: return@setOnDebouncedItemClick
setResult(RESULT_OK, Intent().apply {
putExtra(INGREDIENT_ITEM, item)
})
finish()
}
}
}
private var ingredientName: String? = null
private var currentPage = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFoodSearchV2Binding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
ingredientName = intent.getStringExtra(INGREDIENT_NAME)
binding.etInputIngredient.setText(ingredientName)
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "食材搜索"
}, rightIconAction = {
it.gone()
})
binding.rvIngredientList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
it.adapter = recordAdapter
}
addViewListener()
// 若有传入食材名称,自动触发搜索
queryIngredientList(ingredientName)
}
private fun addViewListener() {
binding.ivIngredientSearch.setOnClickListener {
getInputAndSearch()
}
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
binding.refreshLayout.run {
setOnRefreshListener {
currentPage = 1
val input = binding.etInputIngredient.text.toString().trim()
queryIngredientList(input)
}
setOnLoadMoreListener {
val input = binding.etInputIngredient.text.toString().trim()
queryIngredientList(input)
}
}
binding.root.setOnClickListener {
hideKeyboard()
}
binding.etInputIngredient.run {
setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
getInputAndSearch()
true
} else {
false
}
}
addTextChangedListener(onTextChanged = { text, _, _, _ ->
if (text.isNullOrBlank()) {
currentPage = 1
queryIngredientList()
}
})
}
}
private fun getInputAndSearch() {
val input = binding.etInputIngredient.text.toString().trim()
if (input.isBlank()) {
toast("请输入食材名称")
binding.refreshLayout.finishRefresh()
return
}
queryIngredientList(input)
}
/**
* 查询已入库净菜食材列表
*/
private fun queryIngredientList(keyword: String? = null) {
hideKeyboard()
showLoading()
viewModelV2.getInboundIngredientOptionsWithCallback(
keyword = keyword,
pageNum = currentPage,
pageSize = PAGE_SIZE,
onLoading = {},
onResult = { state ->
lifecycleScope.launch {
when (state) {
is UiState.Success -> handleSearchResult(state.data)
is UiState.Error -> {
delayDismissLoading()
if (currentPage == 1) {
binding.refreshLayout.finishRefresh()
} else {
binding.refreshLayout.finishLoadMore()
}
toast(state.msg)
loadEmptyView()
}
else -> {}
}
}
})
}
/**
* 处理搜索结果,填充列表或展示空视图
*/
@SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(records: List<NutCleanInboundIngredientVO>?) {
delayDismissLoading()
if (currentPage == 1 && records.isNullOrEmpty()) {
loadEmptyView()
return
}
list.clear()
list.addAll(records!!)
recordAdapter.notifyDataSetChanged()
val isLoadMore = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMore)
if (isLoadMore) {
currentPage++
binding.refreshLayout.finishLoadMore()
}
else {
binding.refreshLayout.finishLoadMoreWithNoMoreData()
}
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
recordAdapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvIngredientList, false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "可以尝试换个名称重新搜索"
recordAdapter.stateView = emptyViewBinding!!.root
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
}
@@ -10,11 +10,12 @@ 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
/**
* 主页,包括三种模式:制作模式、采样模式、品控模式
@@ -42,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)
@@ -63,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 {
@@ -95,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()
}
}
}
@@ -138,14 +141,14 @@ class HomeActivity : BaseActivity() {
private fun goSampling() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingModeActivity>()
finish()
return@launch
}
val count = appViewModel.countCookFood(cookMode = 1)
val count = dbViewModel.countCookFood(cookMode = 1)
if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingModeActivity>()
@@ -0,0 +1,40 @@
package com.shuwei.dish.match.ui
import android.os.Bundle
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityHomeV2Binding
import com.shuwei.dish.match.utils.ext.startActivity
/**
* 主页V2 - 净菜包装页面
*/
class HomeV2Activity : BaseActivity() {
private lateinit var binding: ActivityHomeV2Binding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
statusBarDarkFont(enable = true)
binding = ActivityHomeV2Binding.inflate(layoutInflater)
setContentView(binding.root)
binding.tvTitle.setOnClickListener {
startActivity<SettingActivity> {}
}
binding.btnCleanPack.setOnClickListener {
startActivity<CleanPackActivity> {
putExtra(CleanPackActivity.EXTRA_TYPE, CleanPackActivity.TYPE_CLEAN)
}
}
binding.btnMealPack.setOnClickListener {
startActivity<MealListActivity> {}
}
binding.btnTask.setOnClickListener {
startActivity<TaskActivity> {}
}
}
}
@@ -11,16 +11,11 @@ import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivityInitBinding
import com.shuwei.dish.match.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.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
@@ -55,7 +50,7 @@ class InitActivity : BaseActivity() {
countdownHandler.postDelayed(this, 100)
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this@InitActivity)) {
if (NetworkUtil.isNetworkConnected(this@InitActivity)) {
startNextPage()
} else {
// 网络未连接,显示"连接网络"按钮
@@ -80,14 +75,8 @@ 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()
}
@@ -98,52 +87,60 @@ class InitActivity : BaseActivity() {
lifecycleScope.launch {
if (withDelay) delay(2000)
// 子设备:直接进入小屏专属页面,不走原有大屏业务流程
if (GlobalData.deviceRole == DeviceRole.SLAVE) {
startActivity<SlaveActivity>()
return@launch
}
// 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<CookingModeActivity>()
}
1 -> {
goSampling()
}
else -> {
startActivity<HomeActivity>()
}
}
// when (SpTool.cookMode) {
// 0 -> {
// startActivity<CookingModeActivity>()
// }
//
// 1 -> {
// goSampling()
// }
//
// else -> {
// startActivity<HomeActivity>()
// }
// }
startActivity<HomeV2Activity>()
// 延迟到下一帧 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<SamplingModeActivity>()
return@launch
return
}
val count = appViewModel.countCookFood(cookMode = 1)
val count = dbViewModel.countCookFood(cookMode = 1)
if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingModeActivity>()
return@launch
return
}
// 无烹饪数据,打开新增采样页面
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
}
}
}
override fun onDestroy() {
countdownHandler.removeCallbacksAndMessages(null)
WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
super.onDestroy()
}
@@ -176,7 +173,7 @@ class InitActivity : BaseActivity() {
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
if (NetworkUtil.isNetworkConnected(this)) {
// 有网络时隐藏网络检测区域,直接跳转
binding.llNetwork.gone()
startNextPage(withDelay = false)
@@ -220,7 +217,7 @@ class InitActivity : BaseActivity() {
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
if (NetworkUtil.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
countdownHandler.removeCallbacks(countdownTask)
startNextPage()
@@ -251,4 +248,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
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.ui
object MaterialTypeTool {
fun getMaterialText(type: Int) = when (type) {
1 -> "主料"
2 -> "辅料"
3 -> "调料"
else -> ""
}
}
@@ -0,0 +1,153 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.adapter.MealListAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityMealListBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.NutFoodOptionVO
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.net.UiState
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 MealListActivity : BaseActivity() {
companion object {
const val MEAL_ITEM = "mealItem"
const val PAGE_SIZE = 30
}
private val binding by lazy { ActivityMealListBinding.inflate(layoutInflater) }
private val viewModelV2 by lazy { NetViewModelV2() }
private val mealList = mutableListOf<NutFoodOptionVO>()
private val mealAdapter by lazy { MealListAdapter(mealList).apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
val item = mealList.getOrNull(position) ?: return@setOnItemClickListener
startActivity<PackActivity>{
putExtra(PackActivity.MEAL_ITEM, item)
}
}
} }
private var emptyViewBinding: LayoutEmptyViewBinding? = null
private var currentPage = 1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "餐品列表"
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
finish()
}
})
initRecyclerView()
loadMealList()
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.rvMealList.layoutManager = LinearLayoutManager(this)
binding.rvMealList.adapter = mealAdapter
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(true)
binding.refreshLayout.setOnRefreshListener {
currentPage = 1
loadMealList()
}
binding.refreshLayout.setOnLoadMoreListener {
currentPage++
loadMealList()
}
}
/**
* 加载餐品列表
*/
private fun loadMealList() {
showLoading("加载中……")
viewModelV2.getFoodOptionsWithCallback(
keyword = null,
pageNum = currentPage,
pageSize = PAGE_SIZE,
onLoading = {},
onResult = { state ->
dismissLoading()
when (state) {
is UiState.Success -> {
val records = state.data?.records
if (currentPage == 1 && records.isNullOrEmpty()) {
binding.refreshLayout.finishRefresh()
loadEmptyView()
return@getFoodOptionsWithCallback
}
if (currentPage == 1) {
mealList.clear()
}
mealList.addAll(records!!)
mealAdapter.notifyDataSetChanged()
val isLoadMore = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMore)
if (isLoadMore) {
binding.refreshLayout.finishLoadMore()
} else {
binding.refreshLayout.finishLoadMoreWithNoMoreData()
}
}
is UiState.Error -> {
toast(state.msg)
if (currentPage == 1) {
binding.refreshLayout.finishRefresh()
loadEmptyView()
} else {
currentPage--
binding.refreshLayout.finishLoadMore()
}
}
else -> {}
}
}
)
}
/**
* 加载空视图
*/
private fun loadEmptyView() {
mealList.clear()
mealAdapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvMealList, false
)
}
emptyViewBinding?.run {
tvContent.text = "暂无数据"
tvSubContent.text = "暂无餐品数据"
mealAdapter.stateView = root
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
}
}
}
@@ -0,0 +1,868 @@
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.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.GridLayoutManager
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.adapter.FormFieldAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityPackBinding
import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.dialog.InboundIngredientSearchDialog
import com.shuwei.dish.match.model.DictType
import com.shuwei.dish.match.model.FieldType
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.FormField
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.NutFoodOptionVO
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.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.math.abs
import com.shuwei.dish.match.model.NutFoodComposition
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.utils.DateTimeUtil
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import java.time.LocalDateTime
@SuppressLint("NotifyDataSetChanged")
class PackActivity : BaseActivity() {
companion object {
const val TAG = "PackActivity"
const val MEAL_ITEM = "mealItem"
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 { ActivityPackBinding.inflate(layoutInflater) }
private val cameraUtils: CameraUtils by lazy { CameraUtils(this) }
private var mealItem: NutFoodOptionVO? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
private var goodsList: MutableList<CookFoodGoodsEntity>? = null
private val viewModelV2 by lazy { NetViewModelV2() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
pageFrom = intent.getStringExtra(PAGE_FROM)
mealItem = IntentCompat.getParcelableExtra(intent, MEAL_ITEM, NutFoodOptionVO::class.java)
mealItem?.let {
binding.etInputDish.run {
setText(it.foodName)
tag = it.foodName
}
}
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "餐品净菜包"
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
// 不可编辑,仅展示菜名
binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone()
// binding.tvGoodsName.text = "食材名称"
addViewClickListener()
addBackKeyListener()
initCamera(binding.flCameraContainer)
initRecyclerView()
initObserver()
// 根据传入的餐品数据加载食材列表
mealItem?.let {
val compositions = it.compositions
if (!compositions.isNullOrEmpty()) {
loadDishCompositions(compositions)
} else {
toast("该餐品无食材构成信息")
}
}
}
private var currentWeight = 0.0
private fun addViewClickListener() {
// ========== 重量识别相关功能已注释 ==========
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
currentWeight = weight - initialWeight
binding.tvShowWeight.run {
if (tag != currentWeight) {
// text = "称重:${netWeight}g"
val weightText = "${currentWeight.roundedOneDecimalPlace()}g"
text = weightText
tag = currentWeight
}
}
//recognizeFood(netWeight)
}
}
})
if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.visible()
binding.etInputDish.isFocusable = true
binding.etInputDish.isFocusableInTouchMode = true
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 = FoodRecord().apply {
// foodId = mealItem?.foodId?.toString()
// foodName = showFoodName
// cookMode = 1
// }
// Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
// 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()
// }
// }
binding.ivClearWeight.clickWithDebounce {
initialWeight = 0.0
SpTool.configWeight = 0
WeightUtil.tareTwo(1)
}
binding.ivAddWeight.clickWithDebounce {
if (currentWeight <= 0.0) {
toast("当前秤重量无效")
return@clickWithDebounce
}
list.getOrNull(clickIndex)?.let {
it.useWeight = currentWeight
it.isSetFinished = true
materialAdapter.notifyItemChanged(clickIndex)
}
}
binding.btnGetTraceCode.clickWithDebounce {
val item = list.getOrNull(clickIndex) ?: return@clickWithDebounce
InboundIngredientSearchDialog(
activity = this,
viewModelV2 = viewModelV2,
defIngredientName = item.goodsName
) { vo ->
materialAdapter.getItem(clickIndex)?.let {
it.materCode = vo.traceCode
materialAdapter.notifyItemChanged(clickIndex)
}
}.show()
}
binding.btnSubmit.clickWithDebounce {
submit()
}
}
/**
* 收集字段提交值,根据 apiKey / extraApiKeys 规则汇总
* @return 提交参数 Mapkey 为接口参数名,value 为提交值
*/
private fun collectSubmitValues(): MutableMap<String, String> {
val params = mutableMapOf<String, String>()
for (field in fields) {
if (field.hidden) continue
// 跳过 CHILDREN 类型,暂不处理
if (field.type == FieldType.CHILDREN) continue
if (field.extraApiKeys.isNotEmpty()) {
// 下拉字段:直接读取 bindDropdown 已解析好的 extraValues
field.extraValues.forEach { (apiKey, value) ->
params[apiKey] = value
}
} else if (field.apiKey.isNotBlank()) {
// 普通字段
params[field.apiKey] = field.value
}
}
return params
}
/**
* 提交餐品净菜包装
*/
private fun submit() {
val food = mealItem ?: return
//包装方式、包装份数、保质期至、包装人、存放温度、终端设备
val values = collectSubmitValues()
val params = mutableMapOf<String, Any?>()
values.forEach { (key, value) ->
params[key] = value
}
//关联餐品id
params["foodId"] = food.foodId
//包装餐品名称
params["mealName"] = food.foodName
//包装日期
params["packageDate"] = DateTimeUtil.formatDateTime(
dateTime = LocalDateTime.now(),
pattern = DateTimeUtil.YYYY_MM_DD
)
// //操作类型
// params["opType"] = "设备自动"
//食材明细列表
params["ingredients"] = list.map {
NutFoodComposition(
materId = it.goodsId.toLongOrNull(),
ingredientName = it.goodsName,
traceCode = it.materCode,
amount = it.useWeight,
isMain = it.materialType,
mainText = MaterialTypeTool.getMaterialText(it.materialType)
)
}.toMutableList()
viewModelV2.addMealPackage(params) { state, msg ->
if (state.not()) {
toast("提交失败:$msg")
return@addMealPackage
}
toast("提交成功")
finish()
}
}
// private val isTakingPhoto = AtomicBoolean(false)
// ========== 重量识别相关变量已注释 ==========
// private var showRecognizePage = false
// private var pageVisible = true
// private var manualCancelFlag = false
// private var lastPhotoUri: Uri? = null
// private var lastWeight = 0.0
// private var currentWeight = 0.0
private var initialWeight = SpTool.configWeight.toDouble()
// private fun recognizeFood(weight: Double) {
// // 重量识别逻辑已注释
// }
/**
* 重新计算 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 }
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()
}
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, FoodRecord().apply {
foodId = mealItem?.foodId?.toString()
foodName = mealItem?.foodName
})
}
}
private fun getGoodsList() {
if (goodsList == null) {
goodsList = mutableListOf()
} else {
goodsList!!.clear()
}
// 将 Goods Item 转换为 CookFoodGoodsEntity 并补充菜品相关字段
list.filter { it.isSetFinished }.forEach {
goodsList?.add(it.toCookFoodGoodsEntity().apply {
foodId = mealItem?.foodId?.toString()
})
}
}
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 {
netViewModel.foodDetailState.collect { state ->
when (state) {
is UiState.Success -> {
val detail = state.data
if (detail == null) {
toast("查询菜品信息为空")
return@collect
}
loadDishDetail(detail)
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
}
// ========== getDishDetail 方法已删除,不再通过接口查询 ==========
private fun loadDishDetail(detail: CookFoodDTO) {
val voList = detail.foodConstituteList
if (voList.isNullOrEmpty()) {
return
}
// 筛选出主材和辅材,转换为 Goods ItemuseWeight 置零)
val tempData = voList.filter { it.materialType == 1 || it.materialType == 2 }
.map { it.toGoodsItem().also { item -> item.useWeight = 0.0 } }
// 直接替换 adapter.items
list.clear()
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
/**
* 加载餐品食材构成(从 NutFoodOptionVO.compositions 解析)
*/
private fun loadDishCompositions(compositions: List<NutFoodComposition>) {
// 将 NutFoodComposition 转换为 Goods Item
val tempData = compositions.map { comp ->
GoodsItem().apply {
goodsId = comp.materId?.toString() ?: ""
goodsName = comp.ingredientName ?: ""
materialType = comp.isMain ?: 0
useWeight = 0.0
isOriginalData = true
isSetFinished = false
}
}.toMutableList()
list.clear()
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
private var firstReqSize = 0
private val list = mutableListOf<GoodsItem>()
private var clickIndex = -1
private val fields = mutableListOf<FormField>()
private val formAdapter by lazy {
FormFieldAdapter(fields)
}
private val materialAdapter by lazy {
FoodMaterialAdapter(list).apply {
onItemClick = onItemClick@{ positon ->
val item = getItem(positon) ?: return@onItemClick
this@PackActivity.clickIndex = positon
list.forEachIndexed { index, entity ->
entity.isClicked = index == positon
}
notifyDataSetChanged()
// binding.tvGoodsName.text = item.goodsName
// binding.tvMaterialType.text = when (item.materialType) {
// 1 -> "主材"
// 2 -> "辅材"
// else -> "未知"
// }
}
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)
}
}
}
}
private var packageMethodField: FormField? = null
private var deviceField: FormField? = null
private fun initFormList() {
packageMethodField =
FormField(
label = "包装方式", type = FieldType.DROPDOWN,
// extraApiKeys = mapOf("packageMethodId" to "id", "packageMethod" to "value"),
extraApiKeys = mapOf("packageMethod" to "type"),
required = true
)
fields.add(packageMethodField!!)
fields.add(
FormField(
label = "包装份数", type = FieldType.INTEGER,
apiKey = "quantity", required = true, hint = "请输入份数"
)
)
// fields.add(
// FormField(
// label = "包装日期",
// type = FieldType.DATE_PICKER,
// apiKey = "packageDate",
// required = true
// )
// )
fields.add(
FormField(
label = "保质期至",
type = FieldType.DATE_PICKER,
apiKey = "expiryDate",
required = true
)
)
fields.add(
FormField(
label = "存放温度(℃)",
type = FieldType.DECIMAL,
apiKey = "storageTemp",
required = false,
hint = "请输入小数"
)
)
fields.add(
FormField(
label = "包装人",
type = FieldType.TEXT,
apiKey = "operator",
required = false
)
)
deviceField = FormField(
label = "终端设备",
type = FieldType.DROPDOWN,
extraApiKeys = mapOf("deviceId" to "id"),
required = false
)
fields.add(deviceField!!)
fields.add(
FormField(
label = "操作类型", type = FieldType.DROPDOWN,
apiKey = "opType",
extraApiKeys = mapOf("opType" to "id"),
required = true,
options = listOf(
DictType(id = "1", value = "设备自动"),
DictType(id = "2", value = "人工录入")
).toMutableList()
)
)
loadDictItemsWithCallback("sup_package_method", packageMethodField!!)
loadDeviceOptionsWithCallback(deviceField)
}
/**
* 加载字典项下拉(回调版本),适合多次不同 dictType 的场景
*/
private fun loadDictItemsWithCallback(dictType: String, targetField: FormField) {
viewModelV2.getDictItemsWithCallback(
dictType = dictType,
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> {
val options = state.data?.map { vo ->
DictType(
id = vo.id?.toString() ?: "",
value = vo.dictLabel ?: "",
type = vo.dictCode ?: ""
)
} ?: emptyList()
targetField.options.clear()
targetField.options.addAll(options)
val index = fields.indexOf(targetField)
if (index >= 0) formAdapter.notifyItemChanged(index)
}
is UiState.Error -> {
toast("加载字典项失败:${state.msg}")
}
else -> {}
}
}
)
}
/**
* 加载设备下拉(回调版本),用于餐品净菜包装
*/
private fun loadDeviceOptionsWithCallback(deviceField: FormField? = null) {
val field = deviceField ?: return
viewModelV2.getDeviceOptionsWithCallback(
onLoading = {},
onResult = { state ->
when (state) {
is UiState.Success -> {
val options = state.data?.map { vo ->
DictType(
id = vo.id?.toString() ?: "",
value = vo.deviceName ?: "",
type = vo.deviceNo ?: ""
)
} ?: emptyList()
field.options.clear()
field.options.addAll(options)
val index = fields.indexOf(field)
if (index >= 0) formAdapter.notifyItemChanged(index)
}
is UiState.Error -> {
toast("加载设备列表失败:${state.msg}")
}
else -> {}
}
}
)
}
/**
* RecyclerView初始化
*/
@SuppressLint("ClickableViewAccessibility")
private fun initRecyclerView() {
initFormList()
binding.rvFormList.let {
it.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.VERTICAL, false)
it.adapter = formAdapter
}
binding.rvMaterialList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// 仅 isNewDishType=true 的 item 显示侧滑删除菜单(必须在 setAdapter 之前调用)
// it.setSwipeMenuCreator { _, rightMenu, position ->
// if (list.getOrNull(position)?.isNewDishType == true) {
// 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@PackActivity)
.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
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<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 ->
// // 拍照成功回调已注释
// }
// private var notRecognizeDialog: CommonDialog? = null
// private fun showNotRecognizeDialog() {
// // 未识别弹窗已注释
// }
// private val cameraFailureCallback: (String) -> Unit = { errMsg ->
// // 拍照失败回调已注释
// }
// private fun loadRecognizeResultPage(nameScoreList: List<FoodModule.IdNameScore>) {
// // 识别结果页加载已注释
// }
/**
* 跳转食物搜索页(采集模式专用)
*/
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
val food = IntentCompat.getSerializableExtra(
resultIntent,
FoodSearchActivity.FOOD_ITEM,
FoodRecord::class.java
)
Log.d(TAG, "jumpSearch: record=$food")
if (food == null) return@startActivity
binding.etInputDish.run {
setText(food.foodName)
tag = food.foodName
setSelection(text.length)
}
}
hideKeyboard()
}
private fun handleRvTouchHideKeyboard() {
val gestureDetector =
GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
hideKeyboard()
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
}
})
}
override fun onDestroy() {
super.onDestroy()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -5,29 +5,29 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.activity.addCallback
import androidx.lifecycle.Lifecycle
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
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.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.GoodsItem
import com.shuwei.dish.match.model.CookFoodDTO
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.KeyboardUtil
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -51,13 +51,15 @@ import kotlin.math.abs
class PrepareFoodActivity : BaseActivity() {
companion object {
const val TAG = "CookActivity"
const val TAG = "PrepareFoodActivity"
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_CHANGE_VALUE = 5
const val WEIGHT_RECOGNIZE_VALUE = 10
/** 主材数量上限 */
const val MATERIAL_COUNT = 3
}
private val binding by lazy { ActivityPrepareFoodBinding.inflate(layoutInflater) }
@@ -66,9 +68,6 @@ class PrepareFoodActivity : BaseActivity() {
private var food: FoodRecord? = null
/** 采集模式下通过搜索页回填的 foodId */
private var foodId: String? = null
/** 来源页面标识,HOME 时返回跳转 SamplingListActivity */
private var pageFrom: String? = null
@@ -78,31 +77,36 @@ class PrepareFoodActivity : BaseActivity() {
setContentView(binding.root)
setHeaderBackground()
pageFrom = intent.getStringExtra(PAGE_FROM)
food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
//food = intent.extras?.getSerializable(FOOD_ITEM) as FoodRecord?
food = IntentCompat.getSerializableExtra(intent, FOOD_ITEM, FoodRecord::class.java)
val isSamplingMode = SpTool.cookMode == 1
val titleText = if (isSamplingMode) "菜品采集" else "菜品制作"
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = titleText
}, rightIconActon = {
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
food?.let {
binding.etInputDish.run {
setText(it.foodName)
tag = it.foodName
}
}
// 根据 cookMode 设置搜索栏可编辑性
if (isSamplingMode) {
// 采集模式:可编辑,支持搜索
val foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName ?: food?.foodName ?: "")
binding.ivDishSearch.visible()
binding.etInputDish.let {
it.setSelection(it.length())
}
} else {
// 制作模式:不可编辑,仅展示菜名
binding.etInputDish.setText(food?.foodName ?: "")
binding.etInputDish.isFocusable = false
binding.etInputDish.isFocusableInTouchMode = false
binding.ivDishSearch.gone()
@@ -114,37 +118,55 @@ class PrepareFoodActivity : BaseActivity() {
initRecyclerView()
initObserver()
if (!isSamplingMode) {
getDishDetail()
if (food?.foodId.isNullOrBlank().not()) {
getDishDetail(food!!.foodId!!)
}
}
private fun addViewClickListener() {
binding.tvShowWeight.setOnClickListener {
showLoading("正在清零……")
WeightUtil.tareTwo(AddressUtil.ONE)
delayDismissLoading()
}
// 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
binding.tvShowWeight.run {
if (tag != weight) {
text = "称重:${weight}g"
tag = weight
if (tag != netWeight) {
text = "称重:${netWeight}g"
tag = netWeight
}
}
recognizeFood(weight)
recognizeFood(netWeight)
}
}
})
if (SpTool.cookMode == 1) {
// 采集模式:绑定搜索栏交互
binding.ivDishSearch.setOnClickListener { v -> jumpSearch(v) }
binding.etInputDish.addOnActionSearchListener { jumpSearch(binding.etInputDish) }
binding.ivDishSearch.setOnClickListener { jumpSearch() }
binding.etInputDish.addOnActionSearchListener { jumpSearch() }
}
binding.btnCook.clickWithDebounce {
@@ -159,10 +181,11 @@ class PrepareFoodActivity : BaseActivity() {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val foodRecord = FoodRecord()
val foodRecord = food ?: FoodRecord()
binding.etInputDish.let {
if (it.tag != null && it.tag.toString() == it.text.toString()) {
foodRecord.foodId = foodId
if (it.tag == null || it.tag.toString() != it.text.toString()) {
// 输入框内容与回填名称不一致,说明用户手动修改了菜名,清除 foodId
foodRecord.foodId = null
}
}
foodRecord.foodName = showFoodName
@@ -187,17 +210,25 @@ class PrepareFoodActivity : BaseActivity() {
}
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 当前稳定重量
@@ -211,8 +242,15 @@ class PrepareFoodActivity : BaseActivity() {
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()}")
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("识别中……")
@@ -228,20 +266,60 @@ class PrepareFoodActivity : BaseActivity() {
* - list 中不含该 goodsId:直接新增,isNewDishType = true
* - list 中已含该 goodsId:累加 useWeight
*/
private val foodSelectCallback: (GoodsItem) -> 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
materialAdapter.notifyItemChanged(list.indexOf(existing))
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 {
materialAdapter.add(entity.also {
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 }
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()
}
private fun openSubmitPage() {
@@ -260,6 +338,8 @@ class PrepareFoodActivity : BaseActivity() {
private fun getGoodsList() {
if (goodsList == null) {
goodsList = mutableListOf()
} else {
goodsList!!.clear()
}
// 将 GoodsItem 转换为 CookFoodGoodsEntity 并补充菜品相关字段
list.filter { it.isSetFinished }.forEach {
@@ -282,35 +362,29 @@ class PrepareFoodActivity : BaseActivity() {
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() {
netViewModel.getFoodDetail(food?.foodId ?: "")
private fun getDishDetail(foodId: String) {
netViewModel.getFoodDetail(foodId)
}
private fun loadDishDetail(detail: CookFoodEntity) {
private fun loadDishDetail(detail: CookFoodDTO) {
// val voList = detail.stFoodInfoConstituteList
val voList = detail.foodConstituteList
if (voList.isNullOrEmpty()) {
@@ -319,36 +393,48 @@ class PrepareFoodActivity : BaseActivity() {
// 筛选出主材和辅材,转换为 GoodsItemuseWeight 置零)
val tempData = voList.filter { it.materialType == 1 || it.materialType == 2 }
.map { it.toGoodsItem().also { item -> item.useWeight = 0.0 } }
// 直接替换 adapter.items,确保 adapter 数据源被完全替换
// 同时同步外部 list,保证后续 updateMaterialTypes/removeFood 等操作正确
list.clear()
firstReqSize = list.size
list.addAll(tempData)
materialAdapter.items = list
materialAdapter.notifyDataSetChanged()
firstReqSize = list.size
}
private var firstReqSize = 0
private var isDetailLoaded = false
private val list = mutableListOf<GoodsItem>()
private val materialAdapter by lazy {
FoodMaterialAdapter(list = list).apply {
val adapter = this
FoodMaterialAdapter(list).apply {
onItemClick = { positon ->
list.forEachIndexed { index, entity ->
entity.isItemClicked = index == positon
entity.isClicked = index == positon
}
notifyDataSetChanged()
}
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, positon ->
Log.d(TAG, "onFoodItemClick: ${list[positon].toJsonString()}")
if (list[positon].isOriginalData) {
list[positon].run {
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(positon)
notifyItemChanged(position)
}
return@addOnItemChildClickListener
}
adapter.removeAt(positon)
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
}
}
@@ -356,37 +442,55 @@ class PrepareFoodActivity : BaseActivity() {
/**
* RecyclerView初始化
*/
@SuppressLint("ClickableViewAccessibility")
private fun initRecyclerView() {
binding.rvMaterialList.run {
layoutManager =
LinearLayoutManager(this@PrepareFoodActivity, LinearLayoutManager.VERTICAL, false)
binding.rvMaterialList.let {
it.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
// 仅 isNewDishType=true 的 item 显示侧滑删除菜单(必须在 setAdapter 之前调用)
setSwipeMenuCreator { _, rightMenu, position ->
it.setSwipeMenuCreator { _, rightMenu, position ->
if (list.getOrNull(position)?.isNewDishType == true) {
rightMenu.addMenuItem(buildDeleteMenuItem())
}
}
// 点击侧滑菜单项:先关闭菜单,再弹窗确认删除
setOnItemMenuClickListener { menuBridge, position ->
it.setOnItemMenuClickListener { menuBridge, position ->
menuBridge.closeMenu()
val item = list.getOrNull(position) ?: return@setOnItemMenuClickListener
CommonDialog(this@PrepareFoodActivity)
.setTitle("删除确认")
.setContent("确定删除食材「${item.goodsName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
materialAdapter.removeAt(position)
toast("已删除")
}.show()
deleteRemindDialog("删除确认", "确定删除食材「${item.goodsName}」吗?") {
removeFood(position)
}
}
// item 点击事件
setOnItemClickListener { _, position ->
it.setOnItemClickListener { _, position ->
materialAdapter.onItemClick?.invoke(position)
}
adapter = materialAdapter
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()
}
/**
* 构建侧滑删除菜单项
*/
@@ -486,6 +590,9 @@ class PrepareFoodActivity : BaseActivity() {
dismissLoading()
Log.d(TAG, "takePhoto foodScoreList is empty")
isTakingPhoto.set(false)
withContext(Dispatchers.Main) {
showNotRecognizeDialog()
}
return@withContext
}
withContext(Dispatchers.Main) {
@@ -498,6 +605,24 @@ class PrepareFoodActivity : BaseActivity() {
}
}
}
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()
}
}
/**
* 拍照失败回调
@@ -524,9 +649,13 @@ class PrepareFoodActivity : BaseActivity() {
activity = this,
imageUri = lastPhotoUri?.toString(),
currentWeight = currentWeight,
initialWeight = initialWeight,
nameScoreList = ArrayList(nameScoreList),
finishCallback = { isManualCancel, entity ->
Log.d(TAG, "queryFood: isManualCancel=$isManualCancel, entity=${entity.toJsonString()}")
Log.d(
TAG,
"queryFood: isManualCancel=$isManualCancel, entity=${entity.toJsonString()}"
)
manualCancelFlag = isManualCancel
if (isManualCancel) {
return@start
@@ -542,24 +671,67 @@ class PrepareFoodActivity : BaseActivity() {
/**
* 跳转食物搜索页(采集模式专用)
* @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())
private fun jumpSearch() {
val searchContent = binding.etInputDish.text.toString().trim()
if (searchContent.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
startActivity(intent) {
foodId = it?.getStringExtra(FoodSearchActivity.FOOD_ID)
val name = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
Log.d(TAG, "jumpSearch: foodId=$foodId, name=$name")
if (name.isNullOrBlank()) return@startActivity
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(name)
tag = name
setText(food!!.foodName)
tag = food!!.foodName
setSelection(text.length)
}
// 有 foodId 时请求菜品构成
if (!food!!.foodId.isNullOrBlank()) {
getDishDetail(food!!.foodId!!)
}
KeyboardUtil.hideKeyboard(v)
}
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
}
})
}
}
@@ -3,13 +3,12 @@ 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.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.FoodListAdapter
@@ -18,7 +17,7 @@ import com.shuwei.dish.match.base.BaseApp
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.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.DateTimeUtil
import com.shuwei.dish.match.utils.ext.dp
@@ -49,23 +48,35 @@ class SamplingModeActivity : BaseActivity() {
setHeaderBackground()
initRecyclerView()
addViewListener()
initObserver()
requestData(true)
onBackPressedDispatcher.addCallback(this) { }
}
/**
* 收集 cookFoodListState,将本地烹饪中数据渲染到列表
*/
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.cookFoodListState.collect { cookFoodEntities ->
if (binding.dishRadioGroup.checkedRadioButtonId != R.id.rbSamplingCooking) return@collect
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@collect
return@getCookFoodListWithCallback
}
val tempList = mutableListOf<FoodRecord>()
cookFoodEntities.sortedByDescending { DateTimeUtil.convert(dateStr = it.createTime) }
@@ -75,6 +86,7 @@ class SamplingModeActivity : BaseActivity() {
foodName = entity.foodName
cookMode = 1
isCooking = true
dinnerType = entity.dinnerType ?: "0"
})
}
list.clear()
@@ -86,66 +98,12 @@ class SamplingModeActivity : BaseActivity() {
setEnableLoadMore(false)
}
}
// }
}
}
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.samplingListState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> {
if (binding.dishRadioGroup.checkedRadioButtonId != R.id.rbSamplingFinished) {
delayDismissLoading()
return@collect
}
loadDishList(state.data)
}
is UiState.Error -> {
if (binding.dishRadioGroup.checkedRadioButtonId != R.id.rbSamplingFinished) {
delayDismissLoading()
return@collect
}
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)
dbViewModel.deleteCookFoodAndGoods(cookMode = 1, foodId = foodId, dinnerType = dinnerType)
action()
}
}
@@ -153,10 +111,13 @@ class SamplingModeActivity : BaseActivity() {
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)
}
@@ -182,7 +143,7 @@ class SamplingModeActivity : BaseActivity() {
return
}
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.FOOD_NAME, item.foodName)
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
}
}
@@ -206,7 +167,7 @@ class SamplingModeActivity : BaseActivity() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
if (list.size > 1) {
dishAdapter.removeAt(position)
} else {
@@ -247,7 +208,7 @@ class SamplingModeActivity : BaseActivity() {
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
if (binding.dishRadioGroup.checkedRadioButtonId == R.id.rbSamplingFinished) {
if (binding.rbSamplingFinished.isChecked) {
getSamplingList()
} else {
finishRefresh()
@@ -255,30 +216,33 @@ class SamplingModeActivity : BaseActivity() {
}
}
binding.refreshLayout.setOnLoadMoreListener {
if (binding.dishRadioGroup.checkedRadioButtonId == R.id.rbSamplingFinished) {
if (binding.rbSamplingFinished.isChecked) {
getSamplingList()
} else {
finishRefresh()
}
}
binding.rbSamplingCooking.setOnClickListener {
// Log.d(TAG, "addViewListener: cooking:${binding.rbSamplingCooking.isChecked}")
if (it.tag == true) {
binding.rbSamplingCooking.let {
it.setOnClickListener { _ ->
if (it.isChecked) {
return@setOnClickListener
}
binding.rbSamplingCooking.isChecked = true
binding.rbSamplingFinished.isChecked = false
requestData(true)
binding.rbSamplingCooking.tag = binding.rbSamplingCooking.isChecked
binding.rbSamplingFinished.tag = binding.rbSamplingFinished.isChecked
}
binding.rbSamplingFinished.setOnClickListener {
}
binding.rbSamplingFinished.let {
it.setOnClickListener { _ ->
// Log.d(TAG, "addViewListener: finish:${binding.rbSamplingFinished.isChecked}")
if (it.tag == true) {
if (it.isChecked) {
return@setOnClickListener
}
binding.rbSamplingCooking.isChecked = false
binding.rbSamplingFinished.isChecked = true
requestData(false)
binding.rbSamplingCooking.tag = binding.rbSamplingCooking.isChecked
binding.rbSamplingFinished.tag = binding.rbSamplingFinished.isChecked
}
}
}
@@ -287,13 +251,37 @@ class SamplingModeActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged")
fun getSamplingList() {
netViewModel.getSamplingList(
netViewModel.getSamplingListWithCallback(
param = mutableMapOf(
"pageNum" to pageNo,
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId
)
)
),
onLoading = { showLoading() }
) { state ->
when (state) {
is UiState.Success -> {
// 回调期间用户可能已切换 Tab,需再次校验
if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading()
return@getSamplingListWithCallback
}
loadDishList(state.data)
}
is UiState.Error -> {
if (binding.rbSamplingFinished.isChecked.not()) {
delayDismissLoading()
return@getSamplingListWithCallback
}
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
finishRefresh()
delayDismissLoading()
if (pageNo == 1) loadEmptyView()
}
else -> {}
}
}
}
private fun finishRefresh() {
@@ -331,18 +319,18 @@ class SamplingModeActivity : BaseActivity() {
//dishAdapter.setStateViewLayout(requireContext(), R.layout-sw800dp.layout_empty_view)
binding.refreshLayout.run {
setEnableRefresh(binding.dishRadioGroup.checkedRadioButtonId == R.id.rbSamplingFinished)
setEnableRefresh(binding.rbSamplingFinished.isChecked)
setEnableLoadMore(false)
}
}
fun refreshPage(isCooking: Boolean) {
val checkedId = binding.dishRadioGroup.checkedRadioButtonId
if (checkedId == R.id.rbSamplingFinished) {
if (binding.rbSamplingFinished.isChecked) {
//当前是已采样
if (isCooking) {
//需要切换到烹饪中
binding.dishRadioGroup.check(R.id.rbSamplingCooking)
binding.rbSamplingCooking.isChecked = true
binding.rbSamplingFinished.isChecked = false
requestData(true)
return
}
@@ -353,7 +341,8 @@ class SamplingModeActivity : BaseActivity() {
//当前是烹饪中
if (isCooking.not()) {
//需要切换到已采样
binding.dishRadioGroup.check(R.id.rbSamplingFinished)
binding.rbSamplingCooking.isChecked = false
binding.rbSamplingFinished.isChecked = true
requestData(false)
return
}
@@ -361,21 +350,17 @@ class SamplingModeActivity : BaseActivity() {
requestData(true)
}
@SuppressLint("NotifyDataSetChanged")
fun refreshLocalData() {
showLoading()
appViewModel.resetCookFoodList()
getCookFoodList()
}
private fun requestData(isCooking: Boolean) {
list.clear()
dishAdapter.notifyDataSetChanged()
if (isCooking) {
// 清除上次网络请求的残留状态
netViewModel.resetSamplingListState()
refreshLocalData()
} else {
// 同理,清除本地数据残留状态
appViewModel.resetCookFoodList()
pageNo = 1
getSamplingList()
}
@@ -414,7 +399,7 @@ class SamplingModeActivity : BaseActivity() {
return
}
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
showDeviceConfigDialog()
return@launch
@@ -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,131 @@ 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,
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,
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.SEASONING_CONFIG)
}
binding.llFoodCollect.clickWithDebounce {
checkCameraPermission()
),
SettingItem(
type = SettingItem.Type.FOOD_COLLECT,
onClick = { checkCameraPermission() }
),
SettingItem(
type = SettingItem.Type.WEIGHT_CONFIG,
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.WEIGHT_CONFIG)
}
),
SettingItem(
type = SettingItem.Type.CLEAN_PACKAGE,
onClick = {
startActivity<CleanPackActivity> {
putExtra(CleanPackActivity.EXTRA_TYPE, CleanPackActivity.TYPE_CLEAN)
}
}
),
SettingItem(
type = SettingItem.Type.MEAL_PACKAGE,
onClick = {
startActivity<MealListActivity> { }
}
),
SettingItem(
type = SettingItem.Type.GROUP_TASK,
onClick = {
startActivity<TaskActivity> { }
}
),
// SettingItem(
// type = SettingItem.Type.MEAL_PACKAGE_TEST,
// onClick = {
// startActivity<CleanPackActivity> {
// putExtra(CleanPackActivity.EXTRA_TYPE, CleanPackActivity.TYPE_MEAL)
// }
// }
// ),
SettingItem(
type = SettingItem.Type.DB_INSPECT,
isHidden = true,
onClick = {
SingleFragmentActivity.start(this, SingleFragmentActivity.PageType.DB_INSPECT)
}
),
SettingItem(
type = SettingItem.Type.SCALE_OBSERVE,
isHidden = true,
onClick = { startActivity<MasterScaleActivity>() }
),
SettingItem(
type = SettingItem.Type.ENV_SWITCH,
isHidden = true,
onClick = { EnvSwitchDialog(this).show() }
),
SettingItem(
type = SettingItem.Type.CLEAR_DATA,
isHidden = true,
onClick = { showClearDataDialog() }
),
SettingItem(
type = SettingItem.Type.TEST_SEASONING,
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 +184,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 +197,12 @@ class SettingActivity : BaseActivity() {
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限未授予,申请权限
requestPermission(Manifest.permission.CAMERA) { isGranted ->
if (isGranted) {
// 权限已授予,直接打开
openCollectPage()
} else {
// 权限被拒绝
showNotGrantedCameraPermissionDialog()
}
}
@@ -128,197 +224,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,9 +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.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
@@ -35,6 +37,12 @@ class SingleFragmentActivity : BaseActivity() {
/** 调料配置页 */
SEASONING_CONFIG,
/** 数据库调试查看页 */
DB_INSPECT,
/** 默认减重配置页 */
WEIGHT_CONFIG,
}
companion object {
@@ -77,7 +85,7 @@ class SingleFragmentActivity : BaseActivity() {
setTitleBar(
titleBarAction = { it.visible() },
titleAction = { it.text = getTitleForPage(pageType) },
rightIconActon = { it.gone() }
rightIconAction = { it.gone() }
)
// 首次创建时加载 Fragment,避免屏幕旋转重复添加
@@ -96,6 +104,8 @@ class SingleFragmentActivity : BaseActivity() {
PageType.FOOD_COLLECT -> "食材采集"
PageType.COOK_MODE -> "菜品模式"
PageType.SEASONING_CONFIG -> "调料区设置"
PageType.DB_INSPECT -> "数据库查看"
PageType.WEIGHT_CONFIG -> "减重配置"
}
/**
@@ -105,6 +115,8 @@ class SingleFragmentActivity : BaseActivity() {
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,12 +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.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))
}
}
}
}
@@ -153,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)
}
}
@@ -170,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
@@ -12,14 +12,17 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
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.dialog.GoodsPreviewDialog
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
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
@@ -28,14 +31,12 @@ 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.toast
import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@SuppressLint("UseSparseArrays")
@@ -45,6 +46,11 @@ class SubmitFoodActivity : BaseActivity() {
const val TAG = "SubmitFoodActivity"
const val FOOD_ITEM = "foodItem"
const val GOODS_LIST = "goodsList"
/** 本地临时 foodId 前缀;以此开头说明菜品尚未在服务器登记,提交时需清空让服务器分配真实 ID */
const val LOCAL_ID_PREFIX = "localId_"
const val WEIGHT_THRESHOLD = 0.01
}
private lateinit var binding: ActivitySubmitFoodBinding
@@ -91,6 +97,17 @@ 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
/** initWeight 是否已完成初始化 */
// private var initWeightSet: Boolean = false
@Suppress("unchecked_cast", "DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -110,8 +127,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()
@@ -147,7 +171,7 @@ class SubmitFoodActivity : BaseActivity() {
}
}
/** 初始化 RecyclerView24列 GridLayoutManager */
/** 初始化 RecyclerView默认 4 列 GridLayoutManager */
private fun setupRecyclerView() {
binding.rvSeasoning.let {
it.itemAnimator = null
@@ -158,11 +182,12 @@ class SubmitFoodActivity : BaseActivity() {
/**
* 从 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
}
@@ -170,12 +195,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,
@@ -184,11 +224,6 @@ class SubmitFoodActivity : BaseActivity() {
)
)
}
observeScaleData()
} else {
observeScaleData()
}
}
}
/**
@@ -198,14 +233,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) {
@@ -214,20 +256,29 @@ 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) {
Log.d(TAG, "observeScaleData,新增:$newTriple")
rawWeightMap[key] = newTriple
refreshAdapterByName(goodsName)
}
}
}
}
}
}
@@ -235,35 +286,57 @@ 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("未识别到菜品熟重")
@@ -276,12 +349,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 = ""
}
}
@@ -293,25 +373,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"
@@ -320,16 +393,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
@@ -344,20 +413,16 @@ 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 }
// 主辅材 foodId 统一以菜品 foodId 为准;新加调料行的 foodId 由 buildSeasoningEntity 内部设置
goodsList?.forEach { it.foodId = cookFoodEntity.foodId }
seasoningAdapter.items
.filter { it.useWeight > 0.0 }
.forEach { goodsList?.add(buildSeasoningEntity(it)) }
cookFoodEntity.let {
it.matchingConstituteInfoList = this@SubmitFoodActivity.goodsList
@@ -369,14 +434,38 @@ class SubmitFoodActivity : BaseActivity() {
}
}
val isSamplingData = food!!.cookMode == 1
if (isSamplingData) {
// 仅本地生成的临时 foodId 需清空,由服务器分配真实 ID;服务器下发的真实 foodId 直接保留
if (cookFoodEntity.foodId.startsWith(LOCAL_ID_PREFIX)) {
cookFoodEntity.foodId = ""
goodsList?.forEach { it.foodId = "" }
}
Log.d(TAG, "submit: json=${cookFoodEntity.toJsonString()}")
netViewModel.submitCookFood(entity = cookFoodEntity)
// 接口提交使用 DTO,与本地 Room 实体解耦
netViewModel.submitCookFood(entity = cookFoodEntity.toDTO())
}
}
/**
* 将 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) {
@@ -385,15 +474,16 @@ 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) {
@@ -409,6 +499,19 @@ class SubmitFoodActivity : BaseActivity() {
}
/**
* 构建用于预览弹窗的 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()
@@ -0,0 +1,212 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.adapter.TaskAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivityTaskBinding
import com.shuwei.dish.match.model.NutComboTaskVO
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.net.UiState
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.toast
import com.shuwei.dish.match.utils.ext.visible
/**
* 组配任务列表页面
*/
class TaskActivity : BaseActivity() {
companion object {
const val TAG = "TaskActivity"
const val TASK_ITEM = "taskItem"
}
private val binding by lazy { ActivityTaskBinding.inflate(layoutInflater) }
private val viewModelV2 by lazy { NetViewModelV2() }
private val taskList = mutableListOf<NutComboTaskVO>()
private val taskAdapter by lazy {
TaskAdapter(taskList).apply {
setOnItemClickListener { adapter, view, position ->
startActivity<TaskDetailActivity> {
putExtra(TaskDetailActivity.TASK_ITEM, taskList[position])
}
}
}
}
private var currentPage = 1
private var keyword: String? = null
private var isLoading = false
private var hasMore = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "组配任务"
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
finish()
}
})
initRecyclerView()
initRefreshLayout()
initSearchListener()
}
override fun onResume() {
super.onResume()
currentPage = 1
loadTaskList()
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.rvTaskList.layoutManager = LinearLayoutManager(this)
binding.rvTaskList.adapter = taskAdapter
taskAdapter.setOnItemClickListener { _, _, position ->
val item = taskList.getOrNull(position) ?: return@setOnItemClickListener
val intent = Intent(this, TaskDetailActivity::class.java).apply {
putExtra(TASK_ITEM, item)
}
startActivity(intent)
}
}
/**
* 初始化 SmartRefreshLayout 下拉刷新和上拉加载
*/
private fun initRefreshLayout() {
binding.refreshLayout.apply {
// 下拉刷新
setOnRefreshListener {
currentPage = 1
hasMore = true
loadTaskList()
}
// 上拉加载
setOnLoadMoreListener { refreshLayout ->
if (isLoading || !hasMore) {
refreshLayout.finishLoadMoreWithNoMoreData()
return@setOnLoadMoreListener
}
currentPage++
loadTaskList()
}
}
}
/**
* 搜索栏交互
*/
private fun initSearchListener() {
binding.ivSearch.clickWithDebounce {
performSearch()
}
binding.etInputKeyword.addOnActionSearchListener {
performSearch()
}
}
/**
* 执行搜索
*/
private fun performSearch() {
keyword = binding.etInputKeyword.text.toString().trim()
if (keyword.isNullOrBlank()) {
toast("请输入搜索关键字")
return
}
currentPage = 1
hasMore = true
taskList.clear()
taskAdapter.notifyDataSetChanged()
binding.rvTaskList.visible()
binding.tvEmpty.gone()
loadTaskList()
}
/**
* 加载组配任务列表
*/
private fun loadTaskList() {
isLoading = true
viewModelV2.getIncompleteComboTasksWithCallback(
deviceCode = GlobalData.deviceId,
keyword = keyword,
pageNum = currentPage,
pageSize = 10,
onLoading = {
if (currentPage == 1) {
showLoading("加载中……")
}
},
onResult = onResult@{ state ->
dismissLoading()
isLoading = false
when (state) {
is UiState.Success -> {
val records = state.data?.records
if (currentPage == 1 && records.isNullOrEmpty()) {
binding.rvTaskList.gone()
binding.tvEmpty.visible()
binding.refreshLayout.finishRefresh()
binding.refreshLayout.finishLoadMoreWithNoMoreData()
return@onResult
}
if (!records.isNullOrEmpty()) {
binding.rvTaskList.visible()
binding.tvEmpty.gone()
if (currentPage == 1) {
taskList.clear()
}
taskList.addAll(records)
taskAdapter.notifyDataSetChanged()
// 判断是否还有更多数据
if (records.size < 10) {
hasMore = false
binding.refreshLayout.finishLoadMoreWithNoMoreData()
}
} else {
hasMore = false
binding.refreshLayout.finishLoadMoreWithNoMoreData()
}
binding.refreshLayout.finishRefresh()
binding.refreshLayout.finishLoadMore()
}
is UiState.Error -> {
toast(state.msg)
if (currentPage > 1) {
currentPage--
}
if (currentPage == 1 && taskList.isEmpty()) {
binding.rvTaskList.gone()
binding.tvEmpty.visible()
}
binding.refreshLayout.finishRefresh()
binding.refreshLayout.finishLoadMore(false)
}
else -> {}
}
}
)
}
}
@@ -0,0 +1,319 @@
package com.shuwei.dish.match.ui
import android.os.Bundle
import android.util.Log
import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.TaskDetailAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivityTaskDetailBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.InboundIngredientSearchDialog
import com.shuwei.dish.match.model.NutComboTaskDetailVO
import com.shuwei.dish.match.model.NutComboTaskItemDetail
import com.shuwei.dish.match.model.NutComboTaskVO
import com.shuwei.dish.match.net.NetViewModelV2
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.SpTool
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.roundedOneDecimalPlace
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.launch
/**
* 组配任务详情页面
*/
class TaskDetailActivity : BaseActivity() {
companion object {
const val TAG = "TaskDetailActivity"
const val TASK_ITEM = "taskItem"
}
private var taskDetailVO: NutComboTaskDetailVO? = null
private val binding by lazy { ActivityTaskDetailBinding.inflate(layoutInflater) }
private val viewModelV2 by lazy { NetViewModelV2() }
private var taskItem: NutComboTaskVO? = null
private var clickIndex = -1
private val comboTaskList = mutableListOf<NutComboTaskItemDetail>()
private val itemAdapter by lazy {
TaskDetailAdapter(comboTaskList).apply {
setOnItemClickListener { adapter, view, position ->
clickItem(this, position)
}
addOnItemChildClickListener(R.id.ivClearIcon) { _, _, position ->
val item = getItem(position) ?: return@addOnItemChildClickListener
Log.d(PrepareFoodActivity.Companion.TAG, "onFoodItemClick: ${item.toJsonString()}")
// 无重量信息时转为 item 整体点击(触发选中效果)
if (item.actualQty.isNullOrBlank()) {
clickItem(this, position)
return@addOnItemChildClickListener
}
deleteRemindDialog("清除确认", "确定清除食材「${item.ingredientName}」添加的溯源码和重量吗?") {
item.run {
actualQty = null
traceCode = null
isSetFinished = false
}
notifyItemChanged(position)
}
}
}
}
/**
* 删除提醒
*/
private fun deleteRemindDialog(title: String, content: String, action: () -> Unit) {
CommonDialog(this)
.setTitle(title)
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
action()
}.show()
}
private fun clickItem(adapter: TaskDetailAdapter, position: Int) {
adapter.getItem(position) ?: return
comboTaskList.forEachIndexed { index, detail ->
detail.isClicked = index == position
}
adapter.notifyDataSetChanged()
this@TaskDetailActivity.clickIndex = position
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
taskItem = IntentCompat.getParcelableExtra(intent, TASK_ITEM, NutComboTaskVO::class.java)
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "任务详情"
}, rightIconAction = {
it.gone()
}, backAction = {
it.setOnClickListener {
finish()
}
})
initRecyclerView()
addWeightListener()
initView()
loadDetail()
lifecycleScope.launch {
viewModelV2.startComboTaskState.collect { state ->
when (state) {
is UiState.Success -> {
dismissLoading()
toast("已开始组配")
// 刷新详情
loadDetail()
// 更新本地状态
taskItem?.comboStatus = 1
}
is UiState.Error -> {
dismissLoading()
toast(state.msg)
}
else -> {}
}
}
}
}
/**
* 初始化任务概要信息
*/
private fun initView() {
taskItem?.let {
binding.tvTaskNo.text = "任务编号:${it.taskNo}"
binding.tvTargetDish.text = it.targetDish
binding.tvSpec.text = it.spec
binding.tvPlanDate.text = "计划日期:${it.planDate}"
binding.tvPortions.text = "总份数:${it.portions}"
binding.tvOwnerName.text = "负责人:${it.ownerName}"
binding.tvStatus.text = getStatusText(it.comboStatus)
// 仅待组配状态显示"开始组配"按钮
// if (it.comboStatus == 0) {
// binding.btnStart.visible()
binding.btnStart.clickWithDebounce {
//val taskId = it.id ?: return@clickWithDebounce
startComboTask()
}
// } else {
// binding.btnStart.gone()
// }
}
binding.btnGetTraceCode.clickWithDebounce {
val item = itemAdapter.getItem(clickIndex) ?: return@clickWithDebounce
InboundIngredientSearchDialog(
activity = this,
viewModelV2 = viewModelV2,
defIngredientName = item.ingredientName
) { vo ->
itemAdapter.getItem(clickIndex)?.let {
it.traceCode = vo.traceCode
itemAdapter.notifyItemChanged(clickIndex)
}
}.show()
}
binding.ivAddWeight.clickWithDebounce {
if (currentWeight <= 0.0) {
toast("当前秤重量无效")
return@clickWithDebounce
}
itemAdapter.getItem(clickIndex)?.let {
it.actualQty = currentWeight.toString()
it.isSetFinished = true
itemAdapter.notifyItemChanged(clickIndex)
}
}
binding.ivClearWeight.clickWithDebounce {
initialWeight = 0.0
SpTool.configWeight = 0
WeightUtil.tareTwo(1)
}
}
private var currentWeight: Double = 0.0
/** 初始秤读数,净重 = 秤读数 - initialWeight,从 SpTool.configWeight 读取 */
private var initialWeight = SpTool.configWeight.toDouble()
private fun addWeightListener() {
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
runOnUiThread {
currentWeight = weight - initialWeight
binding.tvShowWeight.run {
if (tag != currentWeight) {
// text = "称重:${netWeight}g"
val weightText = "${currentWeight.roundedOneDecimalPlace()}g"
text = weightText
tag = currentWeight
}
}
//recognizeFood(netWeight)
}
}
})
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.rvItemList.layoutManager = LinearLayoutManager(this)
binding.rvItemList.adapter = itemAdapter
}
/**
* 加载任务详情(食材清单)
*/
private fun loadDetail() {
val taskId = taskItem?.id ?: return
viewModelV2.getComboTaskDetailWithCallback(
id = taskId,
onLoading = {
showLoading("加载中……")
},
onResult = onResult@{ state ->
dismissLoading()
when (state) {
is UiState.Success -> {
taskDetailVO = state.data
if (taskDetailVO == null) {
toast("详情为空")
return@onResult
}
// 更新状态(可能已被其他设备修改)
binding.tvStatus.text = getStatusText(taskDetailVO!!.comboStatus)
if (taskDetailVO!!.comboStatus != 0) {
binding.btnStart.gone()
}
// 加载食材列表
val items = taskDetailVO!!.items
if (!items.isNullOrEmpty()) {
comboTaskList.clear()
comboTaskList.addAll(items)
itemAdapter.notifyDataSetChanged()
} else {
toast("该任务无食材信息")
}
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
)
}
/**
* 开始组配
*/
private fun startComboTask() {
val task = taskDetailVO ?: return
val count = comboTaskList.count { it.traceCode.isNullOrBlank() }
if (count > 0) {
toast("存在未设置的溯源码")
return
}
task.deviceCode = GlobalData.deviceId
task.portions = 1
showLoading("正在开始组配……")
lifecycleScope.launch {
//viewModelV2.startComboTask(taskId)
task.items = comboTaskList
viewModelV2.addComboTask(param = task) { state, msg ->
dismissLoading()
if (state.not()) {
toast(msg)
return@addComboTask
}
toast("组配成功")
finish()
}
}
}
/**
* 状态文本映射
*/
private fun getStatusText(status: Int?): String {
return when (status) {
0 -> "待组配"
1 -> "组配中"
2 -> "已完成"
else -> "未知"
}
}
override fun onDestroy() {
super.onDestroy()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -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)
}
}
}
@@ -3,12 +3,11 @@ package com.shuwei.dish.match.ui.fragment
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
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.FoodListAdapter
@@ -17,19 +16,23 @@ import com.shuwei.dish.match.base.BaseFragment
import com.shuwei.dish.match.databinding.FragmentFoodListBinding
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.db.entity.CookFoodEntity
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.ui.CookingModeActivity
import com.shuwei.dish.match.ui.PrepareFoodActivity
import com.shuwei.dish.match.ui.SubmitFoodActivity
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.yanzhenjie.recyclerview.SwipeMenuItem
import androidx.fragment.app.viewModels
import kotlinx.coroutines.Dispatchers
import java.io.Serializable
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
@@ -46,13 +49,24 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
}
private val netViewModel: NetViewModel by viewModels()
private var pageNo = 1
private val pageSize = 30
private val pageSize = 100
private var dinnerType = "0"
/** 跟踪当前请求协程,新请求发起前取消旧的,防止并发竞态导致数据错乱 */
private var currentJob: Job? = null
/** 缓存最后一次搜索词,返回二级页面后 onResume 可复用,避免搜索态被重置为全量查询 */
private var currentFoodName: String? = null
private lateinit var activity: CookingModeActivity
/** 第一页时先查数据库存入此字段,网络结果回来后直接使用,避免时序问题 */
private var pendingLocalList: MutableList<CookFoodEntity>? = null
private var list: MutableList<FoodRecord> = mutableListOf()
private val dishAdapter by lazy {
FoodListAdapter(list = list).apply {
@@ -68,7 +82,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
private fun onItemClick(position: Int) {
val item = list[position]
val item = list.getOrNull(position) ?: return
if (item.isCooking) {
// 烹饪中,跳到称熟重页面
activity.startActivity<SubmitFoodActivity> {
@@ -78,7 +92,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
item.dinnerType = dinnerType
activity.startActivity<PrepareFoodActivity> {
putExtra(SubmitFoodActivity.FOOD_ITEM, item as Serializable)
putExtra(PrepareFoodActivity.FOOD_ITEM, item as Serializable)
}
}
@@ -109,7 +123,7 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
.setContent("确定要删除「${item.foodName}」吗?")
.setNegativeButton("取消")
.setPositiveButton("删除") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "") {
activity.deleteCookFoodAndGoods(foodId = item.foodId ?: "", dinnerType = item.dinnerType) {
activity.toast("已删除")
pageNo = 1
getDishList()
@@ -117,23 +131,30 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
.show()
}
// item 点击
setOnItemClickListener { _, position ->
if (isAdded && isVisible) {
activity.judgeDeviceConfig { onItemClick(position) }
}
}
// // item 点击
// setOnItemClickListener { _, position ->
// if (isAdded && isVisible) {
// activity.judgeDeviceConfig { onItemClick(position) }
// }
// }
adapter = dishAdapter
}
addViewListener()
initObserver()
pageNo = 1
activity.showLoading()
getDishList()
binding.refreshLayout.setEnableRefresh(true)
}
override fun onResume() {
super.onResume()
Log.d(TAG, "onResume, dinnerType=$dinnerType, isVisible=${isVisible}, isHidden=${isHidden}")
if (isHidden) return // 过滤 Activity 回前台时隐藏 tab 的无效触发
runCatching {
pageNo = 1
getDishList(currentFoodName)
}.onFailure {
it.printStackTrace()
}
}
/**
* 构建统一样式的删除菜单项
*/
@@ -146,50 +167,12 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
}
/**
* 用 combine 同时监听网络结果与本地数据,网络成功时统一合并渲染
* pageNo > 1(加载更多)时 cookFoodListState 不参与合并,直接追加网络数据
*/
private fun initObserver() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
activity.netViewModel.searchFoodState,
activity.appViewModel.cookFoodListState
) { networkState, localList -> Pair(networkState, localList) }
.collect { (networkState, localList) ->
when (networkState) {
is UiState.Loading -> activity.showLoading()
is UiState.Success -> {
activity.delayDismissLoading()
finishRefresh()
loadAndMergeDishList(networkState.data, localList)
}
is UiState.Error -> {
if (isAdded.not()) return@collect
toast(networkState.msg)
finishRefresh()
activity.delayDismissLoading()
if (pageNo == 1) {
if (localList.isNullOrEmpty()) {
loadEmptyView()
} else {
loadAndMergeDishList(null, localList)
}
}
}
is UiState.Idle -> {}
}
}
}
}
}
private fun addViewListener() {
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener {
Log.d("FoodList", "下拉刷新触发, pageNo reset to 1")
pageNo = 1
getDishList()
}
@@ -199,10 +182,19 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
}
}
fun getDishList(pageNo: Int, dinnerType: String) {
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (!hidden) {
Log.d("FoodList", "显示")
pageNo = 1
getDishList()
}
}
fun getDishList(pageNo: Int, dinnerType: String, foodName: String? = null) {
this.pageNo = pageNo
this.dinnerType = dinnerType
getDishList()
getDishList(foodName)
}
fun getDinnerTypeText(): String {
@@ -216,15 +208,51 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
@SuppressLint("NotifyDataSetChanged")
@Suppress("unchecked_cast")
fun getDishList() {
fun getDishList(foodName: String? = null) {
currentFoodName = foodName
currentJob?.cancel()
currentJob = viewLifecycleOwner.lifecycleScope.launch {
val param = mutableMapOf<String, Any>(
"pageNum" to pageNo,
"pageSize" to pageSize,
"placeId" to BaseApp.canteenId,
"dinnerType" to getDinnerTypeText()
)
activity.getFoodList(param = param)
if (pageNo == 1) activity.getCookFoodList()
foodName?.let { param["foodName"] = it }
if (pageNo == 1) {
pendingLocalList = activity.dbViewModel.getCookFoodListDirect(
cookMode = 0,
dinnerType = dinnerType
)
}
val localList = if (pageNo == 1) pendingLocalList else null
netViewModel.searchFoodListWithCallback(
param = param,
onLoading = { activity.showLoading(timeoutMs = 35_000L) }
) { result ->
when (result) {
is UiState.Success -> {
if (isAdded.not()) return@searchFoodListWithCallback
Log.d(TAG, "getDishList, UiState.Success")
activity.delayDismissLoading()
finishRefresh()
loadAndMergeDishList(result.data, localList)
}
is UiState.Error -> {
if (isAdded.not()) return@searchFoodListWithCallback
Log.d(TAG, "getDishList, UiState.Error")
toast(result.msg)
finishRefresh()
activity.delayDismissLoading()
if (pageNo == 1) {
if (localList.isNullOrEmpty()) loadEmptyView()
else loadAndMergeDishList(null, localList)
}
}
else -> {}
}
}
}
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@@ -279,22 +307,39 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
val effectiveLocalList = if (pageNo == 1) localList else null
if (records.isNullOrEmpty() && effectiveLocalList.isNullOrEmpty()) {
if (pageNo == 1) loadEmptyView()
finishRefresh()
binding.refreshLayout.setEnableLoadMore(false)
return
}
if (pageNo == 1) list.clear()
val mergedList = records?.toMutableList() ?: mutableListOf()
mergeCookingItems(mergedList, effectiveLocalList)
deduplicateForPaging(mergedList, localList)
renderList(mergedList, records)
} catch (e: Exception) {
e.printStackTrace()
}
}
if (!effectiveLocalList.isNullOrEmpty()) {
/**
* 将本地烹饪中条目置顶合并到网络列表:
* 网络列表中已有的条目标记 isCooking 并移至顶部,没有的则新建占位条目
*/
private fun mergeCookingItems(
mergedList: MutableList<FoodRecord>,
cookingEntities: List<CookFoodEntity>?
) {
if (cookingEntities.isNullOrEmpty()) return
val cookingItems = mutableListOf<FoodRecord>()
effectiveLocalList.forEachIndexed { index, entity ->
val food = mergedList.firstOrNull { it.foodId == entity.foodId }
if (food != null) {
mergedList.remove(food)
food.isCooking = true
food.sort = index
food.dinnerType = entity.dinnerType ?: "0"
cookingItems.add(food)
cookingEntities.forEachIndexed { index, entity ->
val existing = mergedList.firstOrNull { it.foodId == entity.foodId }
if (existing != null) {
mergedList.remove(existing)
existing.isCooking = true
existing.sort = index
existing.dinnerType = entity.dinnerType ?: "0"
cookingItems.add(existing)
} else {
cookingItems.add(
FoodRecord(
@@ -310,18 +355,28 @@ class FoodListFragment : BaseFragment<FragmentFoodListBinding>() {
mergedList.addAll(0, cookingItems)
}
if (pageNo >= 2 && !localList.isNullOrEmpty()) {
/**
* 翻页时从网络结果中剔除第一页已加载的本地烹饪中条目,避免重复出现
*/
private fun deduplicateForPaging(
mergedList: MutableList<FoodRecord>,
localList: List<CookFoodEntity>?
) {
if (pageNo < 2 || localList.isNullOrEmpty()) return
val localIds = localList.map { it.foodId }.toHashSet()
mergedList.removeAll { it.foodId in localIds }
}
/**
* 将合并结果追加到列表并刷新 UI,按网络返回条数决定是否开启加载更多
*/
@SuppressLint("NotifyDataSetChanged")
private fun renderList(mergedList: List<FoodRecord>, records: List<FoodRecord>?) {
list.addAll(mergedList)
dishAdapter.notifyDataSetChanged()
val isLoadMoreEnable = (records?.size ?: 0) >= pageSize
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -19,7 +19,7 @@ import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.FragmentSeasoningConfigBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.dialog.SeasoningSelectDialog
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleEvent
import com.shuwei.dish.match.scale.ScaleServiceManager
@@ -50,7 +50,7 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
) = FragmentSeasoningConfigBinding.inflate(inflater, container, false)
private lateinit var currentActivity: SingleFragmentActivity
// private lateinit var appViewModel: DbViewModel
// private lateinit var dbViewModel: DbViewModel
private val adapter22 by lazy { buildAdapter22() }
private val adapter18 by lazy {
@@ -184,7 +184,9 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
onItemSelected = { item ->
val name = item.goodsName ?: ""
onUpdateName(name)
saveSlotAndBroadcast(
lifecycleScope.launch {
// 保存槽位配置
currentActivity.dbViewModel.upsertSeasoningSlot(
SeasoningSlotEntity(
deviceId = deviceId,
address = address,
@@ -192,6 +194,10 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
goodsName = name
)
)
// 按 goodsId 去重保存完整调料信息到 dm_seasoning
currentActivity.dbViewModel.upsertSeasoningByGoodsId(item.toSeasoningEntity())
broadcastAllSlots()
}
}
).show()
}
@@ -199,7 +205,7 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
/** 将单个槽位写入 Room,再广播全量配置给子设备 */
private fun saveSlotAndBroadcast(slot: SeasoningSlotEntity) {
lifecycleScope.launch {
currentActivity.appViewModel.upsertSeasoningSlot(slot)
currentActivity.dbViewModel.upsertSeasoningSlot(slot)
broadcastAllSlots()
}
}
@@ -227,14 +233,14 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
/** 从 Room 删除指定槽位,再广播全量配置给子设备 */
private fun deleteSlotAndBroadcast(deviceId: String, address: Int) {
lifecycleScope.launch {
currentActivity.appViewModel.deleteSeasoningSlot(deviceId, address)
currentActivity.dbViewModel.deleteSeasoningSlot(deviceId, address)
broadcastAllSlots()
}
}
/** 读取全量槽位配置并通过 wsClient 推送给所有已连接子设备 */
private suspend fun broadcastAllSlots() {
val allSlots = currentActivity.appViewModel.loadSeasoningSlot().map {
val allSlots = currentActivity.dbViewModel.loadSeasoningSlot().map {
SlotConfig(it.deviceId, it.address, it.goodsId, it.goodsName)
}
ScaleServiceManager.sendSeasoningConfig(allSlots)
@@ -243,7 +249,7 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
// /** 初始化 ViewModel */
// private fun initViewModel() {
// val factory = AppFactory(DbRepository(BaseApp.instance!!.database.appDao()))
// appViewModel = ViewModelProvider(this, factory)[DbViewModel::class.java]
// dbViewModel = ViewModelProvider(this, factory)[DbViewModel::class.java]
// }
@@ -286,9 +292,9 @@ class SeasoningConfigFragment : BaseFragment<FragmentSeasoningConfigBinding>() {
adapter22.update(emptyList())
adapter18.update(emptyList())
lifecycleScope.launch {
val slots22 = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_22)
val slots18 = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_18)
val slotsOil = currentActivity.appViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_1)
val slots22 = currentActivity.dbViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_22)
val slots18 = currentActivity.dbViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_18)
val slotsOil = currentActivity.dbViewModel.getSeasoningSlotsByDeviceId(ScaleDeviceConfig.DEVICE_ID_1)
slots22.forEach { slot ->
val pos = ScaleDeviceConfig.SCALE_ORDER_22.indexOf(slot.address)
if (pos >= 0) adapter22.updateItemName(pos, slot.goodsName)

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