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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:18:32 +08:00
lvmeng a958262dff refactor(adapter): 新增 FoodListAdapter 合并 DishShowAdapter 与 SamplingAdapter 2026-04-29 15:16:59 +08:00
lvmeng fb691c508d refactor(entity): 重构数据实体类结构
- 将 FoodCollectionBean 从 objbox 包移动到 entity 包
- 在 DataBean.kt 中添加必要的 Android 图像处理相关导入
- 更新 VectorCollectionAdapter 中 FoodCollectionBean 的导入路径
- 更新 VectorCollectionFragment 中 FoodCollectionBean 的导入路径
- 重命名 DataBean.kt 为 FoodClassInfo.kt
- 移除 objbox 包中的重复 FoodCollectionBean 定义
2026-04-29 14:32:26 +08:00
lvmeng 8113c1d830 refactor(activity): 重构 BaseActivity 并优化相关页面
- 将 handler 改为 private,onDestroy 统一清理所有待执行任务
- 新增 protected postDelayed 方法供子类安全调度延时任务
- 将 DateTimeFormatter 提取为 companion object 常量,避免每秒重复创建
- setTitleBar 调用时自动显示标题栏
- 拆分 permissionCallback 为单权限和多权限独立回调,避免覆盖
- 将权限 launcher 改为 private,收敛访问入口
- 删除死代码 bgState/useBackground 及注释掉的旧代码
- InitActivity 倒计时改用私有 countdownHandler/countdownTask,解除对父类 handler 的依赖
- PrepareFoodActivity 替换 handler.postDelayed 为 BaseActivity.postDelayed
- FoodRecognizeActivity 持有弹窗引用防止重复打开
- BaseApp 使用 ScaleDeviceConfig.DEVICE_ID_2 替换硬编码设备 ID
2026-04-29 14:23:22 +08:00
lvmeng db839fd596 style(ui): 更新设置页面和调料列表界面样式
- 调整设置页面布局间距和背景样式
- 更换箭头图标资源文件
- 将调料列表改为网格布局显示
- 优化调料项目卡片样式和尺寸
- 修改RecyclerView为4列网格布局
2026-04-29 10:10:57 +08:00
lvmeng c2fd14cb6f fix(ui): 修复界面文本显示和布局问题
- 将"餐品模式"统一修改为"菜品模式"
- 优化列表项布局结构,使用ConstraintLayout替代LinearLayout
- 调整间距属性,将marginTop改为layout_marginVertical
- 重新设置文本和图标的位置约束关系
- 添加多点击检测器用于快速跳转到主页面的临时功能
2026-04-29 09:10:32 +08:00
lvmeng 95895e6390 remove(dish): 删除菜品采样相关功能代码
- 移除 activity_dish_sampling.xml 布局文件及其全部UI组件
- 删除 DishSamplingActivity 类的所有业务逻辑代码
- 移除 BaseReq 基础请求类定义
- 将 loading 图片资源引用从 loading_spinner 更新为 loading
2026-04-29 08:48:12 +08:00
lvmeng 9a0b066490 refactor(ui): 对页面名称进行统一重命名
- 将 SelectDishActivity 重命名为 CookingModeActivity
- 将 PrepareCookActivity 重命名为 PrepareFoodActivity
- 将 SamplingListActivity 重命名为 SamplingModeActivity
- 更新 AndroidManifest.xml 中的活动声明
- 修改 DishListFragment 中的相关引用
- 注释掉 DishSamplingActivity 的完整实现代码
- 更新 DeviceConfigFragment 中的活动跳转逻辑
- 优化 CookingModeActivity 的视图绑定方式
- 更新相关导入包路径和类引用
2026-04-28 18:13:50 +08:00
lvmengandClaude Sonnet 4.6 e8933fa578 fix(activity): 修复采样与菜品列表页异常场景的数据展示
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:46:19 +08:00
lvmeng 425f485b95 feat(ui): 将加载动画图标替换为 VectorDrawable,清理无用代码 2026-04-28 11:56:28 +08:00
lvmeng 264198463c style(ui): 优化界面布局和颜色配置
- 修改 RecyclerView 高度设置以改进布局
- 更新颜色值,移除 alpha 通道简化颜色定义
- 重构调味配置界面布局,提升视觉效果
- 调整项目条目样式,包括文字颜色和字体粗细
- 更新分割线为 Material Divider 组件
- 统一调整文本颜色和大小以增强可读性
- 优化调味网格适配器中的文字颜色显示
- 调整布局间距和尺寸参数以改善界面美观
- 优化重量数据显示,增加小数位数控制功能
2026-04-28 10:49:10 +08:00
lvmeng 89fa681722 style(ui): 优化食材识别页面界面布局和交互体验
- 将内边距设置从统一padding改为水平和底部独立设置
- 增加卡片圆角大小从8dp到12dp提升视觉效果
- 为商品列表添加白色圆角背景提升美观性
- 调整滚动条垂直边距和顶部间距
- 将选中食材和重量显示区域包裹在圆角容器中
- 添加食材名称文本的最大行数和省略效果
- 统一按钮边距使用水平和垂直边距设置
- 动态调整RecyclerView父容器高度适配内容
- 注释掉食材搜索弹窗的默认商品名称传递
- 在食材搜索弹窗中预设测试数据便于调试
2026-04-28 09:25:31 +08:00
lvmeng 7a75e943db fix(ui): 优化菜品制作界面布局和称重功能
- 调整 activity_prepare_cook.xml 中的边距和布局参数
- 更新称重显示文本为“称重:0g”并调整样式
- 在 BaseActivity 中将 loading 显示和隐藏操作移到主线程执行
- 为称重按钮添加防抖点击监听器和清零功能
- 在食物识别过程中添加加载提示和调试日志
- 修复食物识别流程中的加载框显示和隐藏逻辑
- 添加识别结果页面加载功能和取消判断
2026-04-27 14:32:03 +08:00
lvmeng 634f23a76a style(ui): 优化食物识别界面布局和对话框标题
- 将食物照片区域包装在 CardView 中并调整样式
- 为食材列表容器添加滚动指示条功能
- 替换 RecyclerView 的滚动条为自定义指示条
- 更新多个对话框的标题文本以提高一致性
- 在准备烹饪界面添加重量显示文本
- 优化适配器数据更新和列表滚动逻辑
- 防止 StateFlow 重放导致的重复数据加载
- 添加新的滚动条绘制资源文件
2026-04-27 13:50:56 +08:00
lvmeng b110cc97a0 refactor(collect): 重构食材采集功能界面和适配器
- 将按钮组件替换为TextView以支持更好的交互效果
- 添加selectableItemBackground实现点击反馈效果
- 重命名食材采样为食材采集保持术语一致性
- 替换FoodCollectionAdapter为VectorCollectionAdapter适配器
- 更新列表项布局从list_item_food_collection到list_item_vector_collection
- 添加FoodSearchAdapter适配器用于食材搜索功能
- 重构CollectFragment中的数据绑定和UI逻辑
- 修改相机容器布局位置优化界面结构
- 更新资源文件名和颜色定义提升代码可读性
- 调整保存按钮样式增强视觉效果
- 修改提示对话框内容精确表达功能意图
2026-04-27 10:21:37 +08:00
lvmeng fda88fff33 feat(ui): 添加地址显示功能到调味品网格适配器
- 在 Seasoning22GridAdapter 中启用地址显示
- 在 Seasoning18GridAdapter 中启用地址显示
- 更新点击事件以显示地址对话框
- 为网格项目添加地址显示支持
2026-04-27 08:57:30 +08:00
lvmeng c4fbf9dcd6 feat(ui): 优化通用弹窗样式并修复初始化清零逻辑
- 重构 CommonDialog 弹窗视觉效果:新增带阴影投影的背景、缩放淡入淡出动画、取消/确认按钮主次色区分
- 修复 setCancelable 调用位置错误(原写在 window.run 内不生效)
- 新增弹窗按钮圆角 ripple drawable,防止涟漪溢出圆角
- InitActivity 启动时对净材秤和熟食秤执行清零操作
- PrepareCookActivity 弹窗补充内容文案
- 修正 activity_dish_sampling.xml 中 MaterialDivider 颜色属性写法
2026-04-24 18:03:43 +08:00
238 changed files with 15732 additions and 5411 deletions
+356
View File
@@ -0,0 +1,356 @@
# SwipeRecyclerView
作者的主页:[https://www.yanzhenjie.com](https://www.yanzhenjie.com)
技术交流群:[46505645](https://jq.qq.com/?_wv=1027&k=5wY8UWl)
----
本库是基于RecyclerView的封装,提供了Item侧滑菜单、Item滑动删除、Item长按拖拽、添加HeaderView/FooterView、加载更多、Item点击监听等基本功能。
## 特性
1. Item侧滑菜单,支持水平分布、垂直分布
2. Item长按拖拽、侧滑删除
3. 添加/移除HeaderView/FooterView
4. **自动/点击**加载更多的功能
5. 支持二级列表,List形式、Grid形式、Staggered形式
6. Sticky普通布局黏贴和ReyclerView分组黏贴
7. 支持AndroidX
> 使用本库只需要使用SwipeRecyclerView即可,用法和原生RecyclerView一模一样,本库比原生的RecyclerView多了几个扩展方法。
## 截图
对上面提到的效果基本都有演示,但不是全部,更多效果可以下载Demo查看。
### Item侧滑菜单
<img src="./image/1.gif" width="180px"/> <img src="./image/2.gif" width="180px"/> <img src="./image/3.gif" width="180px"/>
### Item侧滑删除、拖拽
<img src="./image/4.gif" width="180px"/> <img src="./image/5.gif" width="180px"/> <img src="./image/6.gif" width="180px"/>
### 下拉刷新和加载更多
<img src="./image/7.gif" width="180px"/>
### HeaderView和FooterView
<img src="./image/8.gif" width="180px"/>
### Sticky效果和Item分组
<img src="./image/9.gif" width="180px"/> <img src="./image/10.gif" width="180px"/>
### 和DrawerLayout嵌套
<img src="./image/11.gif" width="180px"/>
## 如何使用
如果你使用的是android support库,那么请添加下述依赖:
```groovy
implementation 'com.yanzhenjie.recyclerview:support:1.3.2'
```
如果你使用的是android x库,那么请添加下述依赖:
```groovy
implementation 'com.yanzhenjie.recyclerview:x:1.3.2'
```
> **1. SwipeRecyclerView从1.3.0版本开始支持AndroidX和二级列表,因此相对于低版本的包名和类名有所改动,从低版本升级的开发者需要考量是否要升级。**
**2. 为了让开发者方便切换support库和x库,SwipeRecyclerView的support库和x库除了依赖时的名称不一样外,包名、控件名和类名都是一样的,因此两个库不能共存。**
### 加入布局
在布局的xml中加入`SwipeRecyclerView`
```xml
<com.yanzhenjie.recyclerview.SwipeRecyclerView
.../>
```
### ItemDecoration
也就是分割线,支持Grid形式和Linear形式,可以选择某个ViewType不画分割线:
```java
// 默认构造,传入颜色即可。
ItemDecoration itemDecoration = new DefaultDecoration(color);
// 或者:颜色,宽,高,最后一个参数是不画分割线的ViewType,可以传入多个。
itemDecoration = new DefaultDecoration(color, width, height, excludeViewType);
// 或者:例如下面的123都是不画分割线的ViewType
itemDecoration = new DefaultDecoration(color, width, height, 1, 2, 3);
SwipeRecyclerView recyclerView = ...;
recyclerView.setDecoration(itemDecoration);
```
### Item点击监听
```java
recyclerView.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(View view, int position) {
// TODO...
}
});
```
### 侧滑菜单
```java
// 设置监听器。
swipeRecyclerView.setSwipeMenuCreator(mSwipeMenuCreator);
// 创建菜单:
SwipeMenuCreator mSwipeMenuCreator = new SwipeMenuCreator() {
@Override
public void onCreateMenu(SwipeMenu leftMenu, SwipeMenu rightMenu, int position) {
SwipeMenuItem deleteItem = new SwipeMenuItem(mContext)
...; // 各种文字和图标属性设置。
leftMenu.addMenuItem(deleteItem); // 在Item左侧添加一个菜单。
SwipeMenuItem deleteItem = new SwipeMenuItem(mContext)
...; // 各种文字和图标属性设置。
leftMenu.addMenuItem(deleteItem); // 在Item右侧添加一个菜单。
// 注意:哪边不想要菜单,那么不要添加即可。
}
};
// 菜单点击监听。
swipeRecyclerView.setOnItemMenuClickListener(mItemMenuClickListener);
OnItemMenuClickListener mItemMenuClickListener = new OnItemMenuClickListener() {
@Override
public void onItemClick(SwipeMenuBridge menuBridge, int position) {
// 任何操作必须先关闭菜单,否则可能出现Item菜单打开状态错乱。
menuBridge.closeMenu();
// 左侧还是右侧菜单:
int direction = menuBridge.getDirection();
// 菜单在Item中的Position
int menuPosition = menuBridge.getPosition();
}
};
```
**注意**:菜单需要设置高度,关于菜单高度:
1. `MATCH_PARENT`,自动适应Item高度,保持和Item一样高,比较推荐;
2. 指定具体的高,比如80;
3. `WRAP_CONTENT`,自身高度,极不推荐;
### 侧滑删除和拖拽
拖拽和侧滑删除的功能默认关闭的,所以先要打开功能:
```java
recyclerView.setLongPressDragEnabled(true); // 拖拽排序,默认关闭。
recyclerView.setItemViewSwipeEnabled(true); // 侧滑删除,默认关闭。
```
只需要设置上面两个属性就可以进行相应的动作了,如果不需要哪个,不要打开就可以了。
然后监听拖拽和侧滑的动作,进行数据更新:
```java
recyclerView.setOnItemMoveListener(mItemMoveListener);// 监听拖拽,更新UI。
OnItemMoveListener mItemMoveListener = new OnItemMoveListener() {
@Override
public boolean onItemMove(ViewHolder srcHolder, ViewHolder targetHolder) {
// 此方法在Item拖拽交换位置时被调用。
// 第一个参数是要交换为之的Item,第二个是目标位置的Item。
// 交换数据,并更新adapter。
int fromPosition = srcHolder.getAdapterPosition();
int toPosition = targetHolder.getAdapterPosition();
Collections.swap(mDataList, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
// 返回true,表示数据交换成功,ItemView可以交换位置。
return true;
}
@Override
public void onItemDismiss(ViewHolder srcHolder) {
// 此方法在Item在侧滑删除时被调用。
// 从数据源移除该Item对应的数据,并刷新Adapter。
int position = srcHolder.getAdapterPosition();
mDataList.remove(position);
adapter.notifyItemRemoved(position);
}
};
```
**特别注意**:如果`LayoutManager``List`形式,那么Item拖拽时只能从1-2-3-4这样走,如果你的`LayoutManager``Grid`形式的,那么Item可以从1直接到3或者5或者6...,这样数据就会错乱,所以**当`LayoutManager`是Grid形式时**这里要特别注意转换数据位置的算法:
```java
@Override
public boolean onItemMove(ViewHolder srcHolder, ViewHolder targetHolder) {
int fromPosition = srcHolder.getAdapterPosition();
int toPosition = targetHolder.getAdapterPosition();
if (fromPosition < toPosition) {
for (int i = fromPosition; i < toPosition; i++) {
Collections.swap(mDataList, i, i + 1);
}
} else {
for (int i = fromPosition; i > toPosition; i--) {
Collections.swap(mDataList, i, i - 1);
}
}
mMenuAdapter.notifyItemMoved(fromPosition, toPosition);
return true;
}
```
我们还可以监听用户的侧滑删除和拖拽Item时的手指状态:
```java
recyclerView.setOnItemStateChangedListener(mStateChangedListener);
...
private OnItemStateChangedListener mStateChangedListener = (viewHolder, actionState) -> {
if (actionState == OnItemStateChangedListener.ACTION_STATE_DRAG) {
// 状态:正在拖拽。
} else if (actionState == OnItemStateChangedListener.ACTION_STATE_SWIPE) {
// 状态:滑动删除。
} else if (actionState == OnItemStateChangedListener.ACTION_STATE_IDLE) {
// 状态:手指松开。
}
};
```
想用户触摸到某个`Item`时就开始拖拽或者侧滑删除时,只需要调用`startDrag()``startSwipe()`并转入当前`Item``ViewHoler`即可。
触摸拖拽:
```java
swipeRecyclerView.startDrag(ViewHolder);
```
触摸侧滑删除:
```java
swipeRecyclerView.startSwipe(ViewHolder);
```
### HeaderView和FooterView
主要方法:
```java
addHeaderView(View); // 添加HeaderView。
removeHeaderView(View); // 移除HeaderView。
addFooterView(View); // 添加FooterView。
removeFooterView(View); // 移除FooterView。
getHeaderItemCount(); // 获取HeaderView个数。
getFooterItemCount(); // 获取FooterView个数。
getItemViewType(int); // 获取Item的ViewType,包括HeaderView、FooterView、普通ItemView。
```
添加/移除`HeaderView`/`FooterView``setAdapter()`的调用不分先后顺序。
**特别注意**
1. 如果添加了`HeaderView`,凡是通过`ViewHolder`拿到的`position`都要减掉`HeaderView`的数量才能得到正确的`position`
### 加载更多
本库默认提供了加载更多的动画和View,开发者也可以自定义,默认支持`RecyclerView`自带的三种布局管理器。
默认加载更多:
```java
RecyclerView recyclerView = ...
...
recyclerView.useDefaultLoadMore(); // 使用默认的加载更多的View。
recyclerView.setLoadMoreListener(mLoadMoreListener); // 加载更多的监听。
LoadMoreListener mLoadMoreListener = new LoadMoreListener() {
@Override
public void onLoadMore() {
// 该加载更多啦。
... // 请求数据,并更新数据源操作。
mMainAdapter.notifyDataSetChanged();
// 数据完更多数据,一定要调用这个方法。
// 第一个参数:表示此次数据是否为空。
// 第二个参数:表示是否还有更多数据。
mRecyclerView.loadMoreFinish(false, true);
// 如果加载失败调用下面的方法,传入errorCode和errorMessage。
// errorCode随便传,你自定义LoadMoreView时可以根据errorCode判断错误类型。
// errorMessage是会显示到loadMoreView上的,用户可以看到。
// mRecyclerView.loadMoreError(0, "请求网络失败");
}
};
```
自定义加载更多View也很简单,自定义一个View,并实现一个接口即可:
```java
public class DefineLoadMoreView extends LinearLayout
implements SwipeRecyclerView.LoadMoreView,
View.OnClickListener {
private LoadMoreListener mLoadMoreListener;
public DefineLoadMoreView(Context context) {
super(context);
...
setOnClickListener(this);
}
/**
* 马上开始回调加载更多了,这里应该显示进度条。
*/
@Override
public void onLoading() {
// 展示加载更多的动画和提示信息。
...
}
/**
* 加载更多完成了。
*
* @param dataEmpty 是否请求到空数据。
* @param hasMore 是否还有更多数据等待请求。
*/
@Override
public void onLoadFinish(boolean dataEmpty, boolean hasMore) {
// 根据参数,显示没有数据的提示、没有更多数据的提示。
// 如果都不存在,则都不用显示。
}
/**
* 加载出错啦,下面的错误码和错误信息二选一。
*
* @param errorCode 错误码。
* @param errorMessage 错误信息。
*/
@Override
public void onLoadError(int errorCode, String errorMessage) {
}
/**
* 调用了setAutoLoadMore(false)后,在需要加载更多的时候,此方法被调用,并传入listener。
*/
@Override
public void onWaitToLoadMore(SwipeRecyclerView.LoadMoreListener loadMoreListener) {
this.mLoadMoreListener = loadMoreListener;
}
/**
* 非自动加载更多时mLoadMoreListener才不为空。
*/
@Override
public void onClick(View v) {
if (mLoadMoreListener != null) mLoadMoreListener.onLoadMore();
}
}
```
## 感谢与参考
* [cube-sdk](https://github.com/liaohuqiu/cube-sdk)
* [SwipeMenu](https://github.com/TUBB/SwipeMenu/)
* [HeaderAndFooterWrapper](https://github.com/hongyangAndroid/baseAdapter/blob/master/baseadapter-recyclerview/src/main/java/com/zhy/adapter/recyclerview/wrapper/HeaderAndFooterWrapper.java)
加载更多的灵感来自`cube-sdk`,侧滑菜单参考了`SwipeMenu`,添加`HeaderView`参考了`HeaderAndFooterWrapper`类,特别感谢上述开源库及其作者。
## License
```text
Copyright 2019 Zhenjie Yan
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
+7 -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)
Binary file not shown.
+6 -1
View File
@@ -5,7 +5,7 @@
"entities": [
{
"id": "1:6465153867413579585",
"lastPropertyId": "4:3660727688686055762",
"lastPropertyId": "5:7592685238899911344",
"name": "Food",
"properties": [
{
@@ -30,6 +30,11 @@
"indexId": "1:5497952887792054610",
"type": 28,
"flags": 8
},
{
"id": "5:7592685238899911344",
"name": "version",
"type": 9
}
],
"relations": []
+42 -8
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,24 +60,54 @@
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.SelectDishActivity"
android:name=".ui.CookingModeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.PrepareCookActivity"
android:name=".ui.PrepareFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<!-- <activity-->
<!-- android:name=".ui.DishSamplingActivity"-->
<!-- android:theme="@style/Theme.DishMatch.NoSplash"-->
<!-- android:screenOrientation="portrait"-->
<!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />-->
<activity
android:name=".ui.DishSamplingActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.SamplingListActivity"
android:name=".ui.SamplingModeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="portrait"
@@ -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
/**
* 已采集食材列表适配器
@@ -1,62 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemDishCookBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, DishPartAdapter.VH>(list) {
inner class VH(var binding: ListItemDishCookBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishCookBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CookFoodGoodsEntity?) {
holder.binding.run {
tvDishName.text = item!!.goodsName
tvDishType.text = if (item.materialType == 1) "主辅材:主材" else if (item.materialType == 2) "主辅材:辅材" else ""
//"${DecimalFormat("#").format(item.useWeight)}克"
tvDishWeight.text =
if (item.useWeight == null || item.useWeight == 0.toDouble()) "" else "${item.useWeight!!.roundedOneDecimalPlace()}"
tvDishWeight.setTextColor(
ContextCompat.getColor(
context,
if (item.isSamplingPage) R.color.black999 else R.color.dish_green
)
)
ivOperateIcon.run {
if (item.isSamplingPage) gone() else visible()
}
ivClearIcon.visible()
ivClearIcon.setImageResource(
if (item.isOriginalData) R.drawable.ic_dish_clear
else R.drawable.ic_delete
)
ivOperateIcon.setImageResource(
if (item.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
root.run {
setBackgroundResource(
if (item.isItemClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
}
}
}
@@ -1,62 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class DishShowAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, DishShowAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.toDouble()
tvDishName.text = item!!.foodName
if (isCooking) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else if (totalWeight > 0.toDouble()) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.dish_green)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text =
"累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format((item.foodWeight ?: 0.toDouble()) / 1000.0F)}kg"
} else {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black999)
)
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -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()
}
}
}
@@ -5,13 +5,16 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemDishRecordBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemFood2Binding
import com.shuwei.dish.match.model.FoodRecord
class FoodRecordAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, FoodRecordAdapter.VH>(list) {
/**
* 菜品搜索页面的适配器
*/
class Food2Adapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, Food2Adapter.VH>(list) {
inner class VH(var binding: ListItemDishRecordBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemFood2Binding) : QuickViewHolder(binding.root)
override fun onBindViewHolder(
holder: VH,
@@ -27,7 +30,7 @@ class FoodRecordAdapter(list: MutableList<FoodRecord>) :
viewType: Int
): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishRecordBinding.inflate(inflater, parent, false)
val binding = ListItemFood2Binding.inflate(inflater, parent, false)
return VH(binding)
}
}
@@ -0,0 +1,50 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemFood3Binding
import com.shuwei.dish.match.model.GoodsItem
/**
* 向量采集页面的适配器
*/
class Food3Adapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, Food3Adapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: GoodsItem?
) {
item ?: return
holder.binding.tvFoodName.run {
text = item.goodsName
isChecked = item.isClicked
}
// 显示当前选中的净材种类,无数据时隐藏
holder.binding.tvRawMaterialsType.run {
if (item.rawMaterialsType.isNullOrEmpty()) {
visibility = View.GONE
} else {
visibility = View.VISIBLE
text = item.rawMaterialsType
}
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val binding = ListItemFood3Binding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemFood3Binding) : QuickViewHolder(binding.root)
}
@@ -7,41 +7,56 @@ import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.databinding.ListItemFoodBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, GoodsInfoSearchAdapter.VH>(list) {
/**
* 识别页面和搜索弹窗的适配器
*/
class FoodAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: CookFoodGoodsEntity?
item: GoodsItem?
) {
item ?: return
holder.binding.tvGoodsInfo.run {
text = item.goodsName
holder.binding.root.run {
setBackgroundResource(
if (item.isClicked == true) R.drawable.shape_green_stroke
if (item.isClicked) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
}
holder.binding.tvGoodsInfo.run {
text = item.goodsName
setTextColor(
ContextCompat.getColor(
context,
if (item.isClicked == true) R.color.dish_green
if (item.isClicked) R.color.dish_green
else R.color.black666
)
)
}
if (item.foodScore == -1){
if (item.foodScore == -1) {
holder.binding.tvFoodScore.gone()
} else {
holder.binding.tvFoodScore.let {
it.visible()
it.text = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
val foodScoreText = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
it.text = foodScoreText
}
}
if (item.rawMaterialsType.isNullOrBlank()) {
holder.binding.tvRawMaterialsType.gone()
} else {
holder.binding.tvRawMaterialsType.let {
it.visible()
it.text = item.rawMaterialsType
}
}
}
@@ -52,11 +67,10 @@ class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>)
viewType: Int
): VH {
val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
ListItemFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemFoodBinding) : QuickViewHolder(binding.root)
}
}
@@ -0,0 +1,100 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemFoodListBinding
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
/**
* 菜品列表通用 Adapter,合并了 DishShowAdapter 与 SamplingAdapter 的功能。
*
* @param list 数据列表
* @param mode 显示模式:
* - [DisplayMode.COOKING_MODE]:制作模式,支持空状态(虚线边框、隐藏统计行、灰色文字)
* - [DisplayMode.SAMPLING_MODE]:采样模式,始终显示统计行,重量为零时显示 `-`
*/
class FoodListAdapter(
list: MutableList<FoodRecord>,
private val mode: DisplayMode = DisplayMode.COOKING_MODE
) : BaseQuickAdapter<FoodRecord, FoodListAdapter.VH>(list) {
/** 显示模式枚举 */
enum class DisplayMode {
/** 制作模式(原 DishShowAdapter 逻辑) */
COOKING_MODE,
/** 采样模式(原 SamplingAdapter 逻辑) */
SAMPLING_MODE
}
/** item 主体点击回调 */
var onItemClick: ((position: Int) -> Unit)? = null
inner class VH(var binding: ListItemFoodListBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFoodListBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
/**
* - 烹饪中:黑色文字、实线背景、显示"烹饪中"
* - 有累计重量:绿色文字、实线背景、显示累计统计
* - 空状态:灰色文字、虚线背景、隐藏统计行
*/
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.0
val foodWeight = item.foodWeight ?: 0.0
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = item.foodName
// 烹饪中
if (isCooking) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
return@run
}
// 非烹饪中,制作模式
if (mode == DisplayMode.COOKING_MODE) {
if (totalWeight > 0.0) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format(foodWeight / 1000.0F)}kg"
} else {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black999))
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
return@run
}
// 非烹饪中,采样模式
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
val realTotalWeight = if (totalWeight > 0.0) totalWeight else foodWeight
val showTotalWeight = if (realTotalWeight == 0.0) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
val showFoodWeight = if (foodWeight == 0.0) "-" else df.format(foodWeight / 1000.0F)
tvShowState.text = "${showFoodWeight}kg"
}
}
private val df = DecimalFormat("0.000")
}
@@ -0,0 +1,86 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemFoodMaterialBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.graphics.toColorInt
import com.shuwei.dish.match.ui.MaterialTypeTool
class FoodMaterialAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, FoodMaterialAdapter.VH>(list) {
/** item 主体点击回调 */
var onItemClick: ((position: Int) -> Unit)? = null
inner class VH(var binding: ListItemFoodMaterialBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemFoodMaterialBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: GoodsItem?) {
holder.binding.run {
val data = item ?: return
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = data.goodsName
//if (data.isOriginalData) {
// tvDishName.text = data.goodsName
//} else {
// tvDishName.text = data.goodsName + "-${data.rawMaterialsType}"
//}
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(
ContextCompat.getColor(
context,
if (data.isSamplingPage) R.color.black999 else R.color.dish_green
)
)
ivOperateIcon.run {
if (data.isSamplingPage) gone() else visible()
}
// ivClearIcon.visible()
// ivClearIcon.setImageResource(
// if (data.isOriginalData) R.drawable.ic_dish_clear
// else R.drawable.ic_delete
// )
// isOriginalData 时根据是否有重量信息控制图标颜色:无重量染灰色,有重量恢复原色
if (data.isOriginalData) {
val hasWeight = data.useWeight != null && data.useWeight != 0.0
ivClearIcon.imageTintList = if (hasWeight) null
else ColorStateList.valueOf("#BFBFBF".toColorInt())
} else {
ivClearIcon.imageTintList = null
}
ivOperateIcon.setImageResource(
if (data.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
clBlock.setBackgroundResource(
if (data.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
}
}
@@ -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)
}
}
@@ -1,58 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class SamplingAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, SamplingAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
val totalWeight = item.totalWeight ?: 0.toDouble()
tvDishName.run {
text = (item.foodName?:"").ifBlank { "--" }
setTextColor(
ContextCompat.getColor(
holder.itemView.context,
if (isCooking) R.color.black else R.color.dish_green
)
)
}
tvDishCount.visible()
if (isCooking) {
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else {
val realTotalWeight = if (totalWeight > 0.toDouble()) totalWeight else (item.foodWeight
?: 0.toDouble())
val showTotalWeight =
if (realTotalWeight == 0.toDouble()) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
val foodWeight = item.foodWeight ?: 0.toDouble()
val showFoodWeight = if (foodWeight == 0.toDouble()) "-" else df.format(foodWeight / 1000.0F)
tvShowState.text = "${showFoodWeight}kg"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -3,14 +3,19 @@ package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
import com.shuwei.dish.match.utils.WeightUtil
/**
* 线性列表秤数据 Adapter
* 用于 SlaveActivity 非22/18格子设备的秤列表展示
* 通过 updateItem / updateName / removeByAddress 同步更新,避免 submitList 的
* AsyncListDiffer 异步窗口期导致点击事件丢失
*/
class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdapter.VH>() {
@@ -23,6 +28,9 @@ class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdap
*/
data class ScaleItem(val address: Int, val weight: Double, val state: Int, val name: String = "")
/** item 点击回调 */
var onItemClick: ((ScaleItem) -> Unit)? = null
inner class VH(val b: ListItemScaleRowBinding) : QuickViewHolder(b.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
@@ -34,11 +42,63 @@ class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdap
holder.b.tvScaleLabel.text = "${item.address}"
holder.b.tvName.text = item.name.ifEmpty { "-" }
holder.b.tvWeight.text = "${item.weight} g"
holder.b.tvState.text = when (item.state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> "${item.state}"
holder.b.tvState.text = WeightUtil.getStateStr(item.state)
if (item.state == WeightUtil.STATE_OVER_WEIGHT) {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_over_weight)
val white = ContextCompat.getColor(holder.b.root.context, R.color.white)
holder.b.tvScaleLabel.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
holder.b.tvScaleLabel.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.black))
holder.b.tvName.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.black))
holder.b.tvWeight.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.home_title))
holder.b.tvState.setTextColor(ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title))
}
// 点击时通过 bindingAdapterPosition 取最新数据,避免闭包捕获过期 item
holder.b.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.let { onItemClick?.invoke(it) }
}
}
}
/**
* 同步更新单条秤数据:存在则定点刷新,不存在则按 address 升序插入
* 使用 set/add 替代 submitList,消除 AsyncListDiffer 异步窗口期
* @param item 最新秤数据
*/
fun updateItem(item: ScaleItem) {
val idx = items.indexOfFirst { it.address == item.address }
if (idx >= 0) {
set(idx, item)
} else {
// 找到按 address 升序的插入位置
val insertPos = items.indexOfFirst { it.address > item.address }
.let { if (it < 0) items.size else it }
add(insertPos, item)
}
}
/**
* 同步更新指定地址的调料名称
* @param address 秤地址
* @param name 新名称
*/
fun updateName(address: Int, name: String) {
val idx = items.indexOfFirst { it.address == address }
if (idx >= 0) set(idx, items[idx].copy(name = name))
}
/**
* 同步移除指定地址的条目
* @param address 秤地址
*/
fun removeByAddress(address: Int) {
val idx = items.indexOfFirst { it.address == address }
if (idx >= 0) removeAt(idx)
}
}
@@ -90,23 +90,34 @@ 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)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(sub)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
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)
}
position == highlightedPosition -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
}
else -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
}
@@ -163,11 +174,4 @@ class Seasoning18GridAdapter(
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -95,24 +95,34 @@ 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)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(sub)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
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)
}
position == highlightedPosition -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
}
else -> {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
}
@@ -169,11 +179,4 @@ class Seasoning22GridAdapter(
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -7,23 +7,22 @@ import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.databinding.ListItemSeasoningBinding
import com.shuwei.dish.match.model.GoodsItem
class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
BaseQuickAdapter<SeasoningEntity, SeasoningSearchAdapter.VH>(list) {
/**
* 调料搜索弹窗适配器
*/
class SeasoningSearchAdapter(private var list: MutableList<GoodsItem>) :
BaseQuickAdapter<GoodsItem, SeasoningSearchAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: SeasoningEntity?
item: GoodsItem?
) {
holder.binding.tvGoodsInfo.run {
text = item?.goodsName
setBackgroundResource(
if (item?.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
setTextColor(
ContextCompat.getColor(
context,
@@ -32,6 +31,10 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
)
)
}
holder.binding.root.setBackgroundResource(
if (item?.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners
)
}
override fun onCreateViewHolder(
@@ -40,10 +43,10 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
viewType: Int
): VH {
val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
ListItemSeasoningBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemSeasoningBinding) : QuickViewHolder(binding.root)
}
@@ -6,13 +6,25 @@ import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ItemSeasoningWeightBinding
import com.shuwei.dish.match.databinding.ItemSeasoningWeightListBinding
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
/**
* 调料使用重量列表 Adapter
* 用于 SubmitFoodActivity 展示各调料槽位的实时用量
* 同名调料已在外部聚合,adapter 按 goodsName 作为唯一标识
* 支持两种布局模式:
* - VIEW_TYPE_GRID(默认):4 列卡片,名称与重量垂直排列
* - VIEW_TYPE_LIST:2 列,名称与重量水平排列,名称占剩余空间
*/
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, SeasoningWeightAdapter.VH>() {
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, QuickViewHolder>() {
companion object {
/** 4 列卡片模式 */
const val VIEW_TYPE_GRID = 0
/** 2 列水平模式 */
const val VIEW_TYPE_LIST = 1
}
/** 调料展示数据项,key = goodsName(同名调料已聚合) */
data class Item(
@@ -22,6 +34,13 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
val useWeight: Double
)
/** 当前布局模式,由外部切换后调用 notifyDataSetChanged 生效 */
var isListMode: Boolean = false
override fun getItemViewType(position: Int, list: List<Item>): Int {
return if (isListMode) VIEW_TYPE_LIST else VIEW_TYPE_GRID
}
/**
* 更新单个 item,按 goodsName 匹配;不存在则追加
*/
@@ -42,17 +61,21 @@ class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, Sea
if (idx >= 0) removeAt(idx)
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
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)
QuickViewHolder(binding.root)
}
}
override fun onBindViewHolder(holder: VH, position: Int, item: Item?) {
override fun onBindViewHolder(holder: QuickViewHolder, position: Int, item: Item?) {
item ?: return
holder.binding.tvSeasoningName.text = item.goodsName
holder.binding.tvSeasoningWeight.text = "${item.useWeight}g"
val showWeight = "${item.useWeight.roundedDecimalPlace(1)}g"
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningName, item.goodsName)
holder.setText(com.shuwei.dish.match.R.id.tvSeasoningWeight, showWeight)
}
inner class VH(var binding: ItemSeasoningWeightBinding) : QuickViewHolder(binding.root)
}
@@ -0,0 +1,39 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemSettingBinding
import com.shuwei.dish.match.model.SettingItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
/**
* 设置页面列表适配器
* 点击事件直接调用 item.onClick(),无需在 Adapter 中分发业务逻辑
*/
class SettingAdapter : BaseQuickAdapter<SettingItem, SettingAdapter.VH>() {
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSettingBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SettingItem?) {
item ?: return
holder.binding.tvTitle.text = item.title
// 有副标题则显示,否则隐藏
val subtitle = item.subtitle
if (subtitle != null) {
holder.binding.tvSubtitle.visible()
holder.binding.tvSubtitle.text = subtitle
} else {
holder.binding.tvSubtitle.gone()
}
}
inner class VH(val binding: ListItemSettingBinding) : QuickViewHolder(binding.root)
}
@@ -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)
}
}
@@ -1,136 +1,136 @@
package com.shuwei.dish.match.adapter
import android.graphics.Typeface
import android.icu.text.DecimalFormat
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemCellBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class TextCellAdapter(var list: MutableList<SeasoningEntity>) :
RecyclerView.Adapter<TextCellAdapter.VH>() {
inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(parent.context)
val binding = ListItemCellBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int) {
val item = list[position]
holder.binding.run {
if (position == 10 || position == 11 || position == 13 || position == 14) {
root.invisible()
} else {
root.visible()
}
loadCell(tvCell, item)
//root.setBackgroundResource(R.color.dish_green)
(root.layoutParams as RecyclerView.LayoutParams).let {
// it.bottomMargin = 15.dp
it.width = 226.dp
it.height = 120.dp
// it.rightMargin = 0
// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
it.leftMargin = 7.dp
it.rightMargin = 8.dp
it.topMargin = 7.dp
it.bottomMargin = 8.dp
}
if (item.pageType == 1) {
root.clickWithDebounce { onCellClick?.invoke(position) }
}
}
}
override fun getItemCount() = list.size
var onCellClick: ((positon: Int) -> Unit)? = null
companion object {
fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
val child: TextView = frameLayout.getChildAt(0) as TextView
child.text = getTextSpan(item)
}
fun loadCell(tvCell: TextView, item: SeasoningEntity) {
tvCell.text = getTextSpan(item)
}
// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
// if (item.goodsName.isNullOrBlank()) {
// tvCell.run {
// text = buildSpannableString {
// appendText(
// "未配置",
// ForegroundColorSpan("#999999".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(28, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )}
// //setTextColor(ContextCompat.getColor(context, R.color.black999))
// }
//package com.shuwei.dish.match.adapter
//
//import android.graphics.Typeface
//import android.icu.text.DecimalFormat
//import android.text.SpannableStringBuilder
//import android.text.style.AbsoluteSizeSpan
//import android.text.style.ForegroundColorSpan
//import android.text.style.LineHeightSpan
//import android.text.style.StyleSpan
//import android.view.LayoutInflater
//import android.view.ViewGroup
//import android.widget.FrameLayout
//import android.widget.TextView
//import androidx.core.content.ContextCompat
//import androidx.core.graphics.toColorInt
//import androidx.recyclerview.widget.RecyclerView
//import com.chad.library.adapter4.viewholder.QuickViewHolder
//import com.shuwei.dish.match.R
//import com.shuwei.dish.match.databinding.ListItemCellBinding
//import com.shuwei.dish.match.entity.SeasoningEntity
//import com.shuwei.dish.match.utils.ext.appendText
//import com.shuwei.dish.match.utils.ext.buildSpannableString
//import com.shuwei.dish.match.utils.ext.clickWithDebounce
//import com.shuwei.dish.match.utils.ext.dp
//import com.shuwei.dish.match.utils.ext.invisible
//import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
//import com.shuwei.dish.match.utils.ext.visible
//
//class TextCellAdapter(var list: MutableList<SeasoningEntity>) :
// RecyclerView.Adapter<TextCellAdapter.VH>() {
//
// inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root)
//
// override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
// val inflater = LayoutInflater.from(parent.context)
// val binding = ListItemCellBinding.inflate(inflater, parent, false)
// return VH(binding)
// }
//
// override fun onBindViewHolder(holder: VH, position: Int) {
// val item = list[position]
// holder.binding.run {
// if (position == 10 || position == 11 || position == 13 || position == 14) {
// root.invisible()
// } else {
//// if (item.pageType == 1) {
//// tvCell.run {
//// text = item.goodsName
//// setTextColor(ContextCompat.getColor(context, R.color.black))
//// }
//// } else {
// tvCell.text = getTextSpan(item)
//// }
// root.visible()
// }
// loadCell(tvCell, item)
// //root.setBackgroundResource(R.color.dish_green)
// (root.layoutParams as RecyclerView.LayoutParams).let {
//// it.bottomMargin = 15.dp
// it.width = 226.dp
// it.height = 120.dp
//// it.rightMargin = 0
//// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
//
// it.leftMargin = 7.dp
// it.rightMargin = 8.dp
// it.topMargin = 7.dp
// it.bottomMargin = 8.dp
// }
// if (item.pageType == 1) {
// root.clickWithDebounce { onCellClick?.invoke(position) }
// }
// }
fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder {
val endIndex = if (item.sort == 9) 10 else 6
var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!!
if (name.length > endIndex) {
name = "${name.substring(0, endIndex)}"
}
return buildSpannableString {
appendText(
name,
ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(28, true),
LineHeightSpan { text, start, end, spanstartv, v, fm ->
fm.descent += 10.dp // 增加行间距
}
)
append("\n")
val weight = item.useWeight ?: 0.toDouble()
val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71"
appendText(
// "${DecimalFormat("#").format(weight)}g",
"${weight.roundedOneDecimalPlace()}g",
ForegroundColorSpan(weightColor.toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
}
// }
//
// override fun getItemCount() = list.size
//
// var onCellClick: ((positon: Int) -> Unit)? = null
//
// companion object {
// fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
// val child: TextView = frameLayout.getChildAt(0) as TextView
// child.text = getTextSpan(item)
// }
// fun loadCell(tvCell: TextView, item: SeasoningEntity) {
// tvCell.text = getTextSpan(item)
// }
//// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
//// if (item.goodsName.isNullOrBlank()) {
//// tvCell.run {
//// text = buildSpannableString {
//// appendText(
//// "未配置",
//// ForegroundColorSpan("#999999".toColorInt()),
//// StyleSpan(Typeface.BOLD),
//// AbsoluteSizeSpan(28, true),
//// LineHeightSpan { text, start, end, spanstartv, v, fm ->
//// fm.descent += 10.dp // 增加行间距
//// }
//// )}
//// //setTextColor(ContextCompat.getColor(context, R.color.black999))
//// }
//// } else {
////// if (item.pageType == 1) {
////// tvCell.run {
////// text = item.goodsName
////// setTextColor(ContextCompat.getColor(context, R.color.black))
////// }
////// } else {
//// tvCell.text = getTextSpan(item)
////// }
//// }
//// }
//
// fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder {
// val endIndex = if (item.sort == 9) 10 else 6
// var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!!
// if (name.length > endIndex) {
// name = "${name.substring(0, endIndex)}…"
// }
// return buildSpannableString {
// appendText(
// name,
// ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(28, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )
// append("\n")
// val weight = item.useWeight ?: 0.toDouble()
// val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71"
// appendText(
//// "${DecimalFormat("#").format(weight)}g",
// "${weight.roundedOneDecimalPlace()}g",
// ForegroundColorSpan(weightColor.toColorInt()),
// AbsoluteSizeSpan(30, true)
// )
// }
// }
// }
//
//}
@@ -8,18 +8,18 @@ import android.widget.ImageView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemFoodCollectionBinding
import com.shuwei.dish.match.objbox.FoodCollectionBean
import com.shuwei.dish.match.databinding.ListItemVectorCollectionBinding
import com.shuwei.dish.match.model.FoodCollectionBean
import com.shuwei.dish.match.utils.ext.load
class FoodCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
BaseQuickAdapter<FoodCollectionBean, FoodCollectionAdapter.VH>(list) {
class VectorCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
BaseQuickAdapter<FoodCollectionBean, VectorCollectionAdapter.VH>(list) {
inner class VH(var binding: ListItemFoodCollectionBinding) : QuickViewHolder(binding.root)
inner class VH(var binding: ListItemVectorCollectionBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemFoodCollectionBinding.inflate(inflater, parent, false)
val binding = ListItemVectorCollectionBinding.inflate(inflater, parent, false)
return VH(binding)
}
@@ -5,12 +5,11 @@ import android.content.pm.ActivityInfo
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
@@ -25,6 +24,7 @@ import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.db.DbViewModel
import com.shuwei.dish.match.ui.InitActivity
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.invisible
@@ -35,9 +35,15 @@ import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.getValue
@Suppress("DEPRECATION")
open class BaseActivity : AppCompatActivity() {
companion object {
const val TAG = "BaseActivity"
/** 日期时间格式化器,复用避免每秒重复创建 */
private val DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
}
private lateinit var binding: ActivityBaseBinding
override fun onCreate(savedInstanceState: Bundle?) {
@@ -45,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) {
@@ -56,42 +62,28 @@ open class BaseActivity : AppCompatActivity() {
}
setContentView(binding.root)
statusBarDarkFont(enable = false)
// window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge
// window.insetsController?.apply {
// hide(WindowInsets.Type.statusBars()) // 隐藏状态栏
// systemBarsBehavior =
// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示
// }
// 主设备垂直 padding 30dp,子设备 15dp
val verticalPadding = if (GlobalData.deviceRole == DeviceRole.MASTER) 30.dp else 10.dp
binding.tvLeftTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.tvRightTime.setPadding(0, verticalPadding, 0, verticalPadding)
binding.llTitleBar.gone()
binding.ivBack.setOnClickListener { finish() }
}
private var bgState = true
fun useBackground(enable: Boolean) {
bgState = enable
binding.root.setOnClickListener { hideKeyboard() }
}
override fun onDestroy() {
dismissLoading()
handler.removeCallbacksAndMessages(null)
// 只关闭自己弹出的 Loading,防止误关其他 Activity 的 Loading
Loading.dismissIfOwnedBy(this)
super.onDestroy()
ActivityManager.removeActivity(this)
}
fun hideStatusBar() {
// enableEdgeToEdge()
//// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
//// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
// window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
hideSystemBars()
}
fun statusBarDarkFont(enable: Boolean) {
//window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
val color = ContextCompat.getColor(
this, if (enable) R.color.black else R.color.white
)
@@ -103,12 +95,13 @@ open class BaseActivity : AppCompatActivity() {
titleBarAction: ((FrameLayout) -> Unit)? = null,
backAction: ((ImageView) -> Unit)? = null,
titleAction: ((TextView) -> Unit)? = null,
rightIconActon: ((ImageView) -> Unit)? = null
rightIconAction: ((ImageView) -> Unit)? = null
) {
binding.llTitleBar.visible()
titleBarAction?.invoke(binding.llTitleBar)
backAction?.invoke(binding.ivBack)
titleAction?.invoke(binding.tvTitle)
rightIconActon?.invoke(binding.ivRightIcon)
rightIconAction?.invoke(binding.ivRightIcon)
}
fun setHeaderBgVisible(show: Boolean) {
@@ -117,7 +110,7 @@ open class BaseActivity : AppCompatActivity() {
}
}
public fun setHeaderBackground(isHomePage: Boolean = false) {
fun setHeaderBackground(isHomePage: Boolean = false) {
binding.ivHeaderBg.setImageResource(
if (isHomePage) R.drawable.bg_home_page else R.drawable.bg_other_page
)
@@ -135,14 +128,13 @@ open class BaseActivity : AppCompatActivity() {
}
private fun updateDateTime() {
val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
val dateTime = formatter.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
val dateTime = DATE_FORMATTER.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
val arr = dateTime.split("***")
binding.tvRightTime.text = arr[0]
binding.tvLeftTime.text = arr[1]
}
val handler = Handler(Looper.getMainLooper())
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
@@ -161,44 +153,61 @@ open class BaseActivity : AppCompatActivity() {
handler.removeCallbacks(updateTask)
}
fun showLoading(msg: String = "加载中……") {
Loading.show(
context = this,
message = msg
)
fun showLoading(msg: String = "加载中……", timeoutMs: Long = Loading.DEFAULT_TIMEOUT_MS) {
handler.post {
Loading.show(
context = this,
message = msg,
timeoutMs = timeoutMs
)
}
}
fun dismissLoading() {
Loading.dismiss()
Log.d(TAG, "dismissLoading, ${this.javaClass.simpleName}")
handler.post {
Loading.dismiss()
}
}
fun delayDismissLoading() {
window.decorView.postDelayed({
Log.d(TAG, "delayDismissLoading, ${this.javaClass.simpleName}")
handler.postDelayed({
Loading.dismiss()
}, 500)
}
/**
* 在主线程延迟执行任务,供子类使用
* @param delayMs 延迟毫秒数
* @param block 待执行的任务
*/
protected fun postDelayed(delayMs: Long, block: () -> Unit) {
handler.postDelayed(block, delayMs)
}
val netViewModel: NetViewModel by viewModels()
val appViewModel: DbViewModel by viewModels()
val dbViewModel: DbViewModel by viewModels()
private var permissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var singlePermissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var multiplePermissionsCallback: ((isGranted: Boolean) -> Unit)? = null
private var activityCallback: ((intent: Intent?) -> Unit)? = null
fun requestMultiplePermissions(
permissions: Array<String>,
callback: (isGranted: Boolean) -> Unit
) {
this.permissionCallback = callback
this.multiplePermissionsCallback = callback
requestMultiplePermissionsLauncher.launch(permissions)
}
fun requestPermission(permission: String, callback: (isGranted: Boolean) -> Unit) {
this.permissionCallback = callback
this.singlePermissionCallback = callback
requestPermissionLauncher.launch(permission)
}
val requestMultiplePermissionsLauncher = registerForActivityResult(
private val requestMultiplePermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
var isGranted = true
@@ -207,14 +216,14 @@ open class BaseActivity : AppCompatActivity() {
isGranted = false
}
}
permissionCallback?.invoke(isGranted)
multiplePermissionsCallback?.invoke(isGranted)
}
// 权限请求回调
val requestPermissionLauncher = registerForActivityResult(
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
permissionCallback?.invoke(isGranted)
singlePermissionCallback?.invoke(isGranted)
}
fun startActivity(intent: Intent, callback: (Intent?) -> Unit) {
@@ -263,4 +272,8 @@ open class BaseActivity : AppCompatActivity() {
}
}
fun hideKeyboard() {
KeyboardUtil.hideKeyboard(binding.root)
}
}
@@ -1,21 +1,22 @@
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.ScaleServiceManager
import com.shuwei.dish.match.ui.InitActivity.Companion.TAG
import com.shuwei.dish.match.scale.ScaleDeviceConfig
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
class BaseApp : Application() {
@@ -24,50 +25,76 @@ class BaseApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
CrashHandler.init(this)
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
// 主设备是/dev/ttyS7,子设备是/dev/ttyS4
if (deviceId == "8fc2ab34-2137-3112-acca-f884ea8736d4") {
GlobalData.deviceRole = DeviceRole.MASTER
Weigher2.setDevicePort("/dev/ttyS7")
} else {
GlobalData.deviceRole = DeviceRole.SLAVE
Weigher2.setDevicePort("/dev/ttyS4")
// 读取本地保存的环境地址,覆盖默认值
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 {
// const val canteenId = "1678234139391512577"
private val TAG = "BaseApp"
var canteenId = "0"
// var configUrl = ""
// var token: String? = null
// var deviceId: String? = null
var appVersion: String = "1"
@Volatile
private var sharedPref: SharedPreferences? = null
public var instance: BaseApp? = null
fun getSharedPref(): SharedPreferences? {
if (sharedPref != null) {
return sharedPref
lateinit var instance: BaseApp
/** 双重检查锁,保证多线程下只初始化一次 */
fun getSharedPref(): SharedPreferences {
return sharedPref ?: synchronized(this) {
sharedPref ?: instance.getSharedPreferences(
instance.getString(R.string.sp_save_name),
Context.MODE_PRIVATE
).also { sharedPref = it }
}
val saveName = instance?.getString(R.string.sp_save_name)
sharedPref = instance?.getSharedPreferences(saveName, Context.MODE_PRIVATE)
return sharedPref
}
}
@@ -1,10 +0,0 @@
package com.shuwei.dish.match.base
class BaseReq<T> {
var code: Int? = null
var success: Boolean? = null
var data: T? = null
var message: String? = null
var result: T? = null
var msg: String? = null
}
@@ -8,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 = 8,
version = 13,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
@@ -214,6 +214,48 @@ val MIGRATION_6_7 = MigrationImpl(6, 7) {db ->
}
}
/**
* 版本8→9:为 dm_cook_food_goods 和 dm_seasoning 表新增 zjmCode、materId 字段
*/
val MIGRATION_8_9 = MigrationImpl(8, 9) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN materId TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN materId TEXT DEFAULT ''")
}
/**
* 版本9→10:删除 dm_cook_food 表 foodId 唯一索引
* 改为由应用层保证同一 foodId 且 isDel=0 只有一条记录,以支持逻辑删除
*/
val MIGRATION_9_10 = MigrationImpl(9, 10) { db ->
db.execSQL("DROP INDEX IF EXISTS dm_cook_food_foodId_IDX")
}
/**
* 版本10→11:为 dm_cook_food_goods 和 dm_seasoning 表新增 goodsCode(物料编码)字段
*/
val MIGRATION_10_11 = MigrationImpl(10, 11) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN goodsCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN goodsCode TEXT DEFAULT ''")
}
/**
* 版本11→12:为 dm_cook_food_goods 和 dm_seasoning 表新增 rawMaterialsType(净材种类)字段
*/
val MIGRATION_11_12 = MigrationImpl(11, 12) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
}
/**
* 版本12→13:为 dm_cook_food 表新增 submitState(接口提交状态)字段
* 0-未提交(本地草稿/手动删除),1-接口提交成功后删除
*/
val MIGRATION_12_13 = MigrationImpl(12, 13) { db ->
db.execSQL("ALTER TABLE dm_cook_food ADD COLUMN submitState INTEGER NOT NULL DEFAULT 0")
}
class DatabaseProvider(private val context: Context) {
// 单例模式确保全局唯一实例‌
val instance: AppDatabase by lazy {
@@ -233,6 +275,11 @@ class DatabaseProvider(private val context: Context) {
// .addMigrations(MIGRATION_5_6)
// .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.build()
}
}
@@ -1,10 +1,11 @@
package com.shuwei.dish.match.db
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -21,8 +22,8 @@ class DbRepository {
db.cookFoodDao().updateCookFood(item)
}
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode)
suspend fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
@@ -33,13 +34,23 @@ class DbRepository {
db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
}
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode)
suspend fun deleteCookFood(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 */
suspend fun markSubmittedAndDelete(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().markSubmittedAndDelete(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
}
/** 仅逻辑删除调料行(materialType = 3),主辅材不动 */
suspend fun deleteSeasoningOnly(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteSeasoningOnly(pid, foodId)
}
//--------------------------------------------------------------------------------------------
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
@@ -67,7 +78,7 @@ class DbRepository {
db.seasoningDao().getSeasoningBySort(sort)
}
suspend fun getSeasoningByGoodsId(goodsId: Int) = withContext(Dispatchers.IO) {
suspend fun getSeasoningByGoodsId(goodsId: String) = withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningByGoodsId(goodsId)
}
@@ -100,6 +111,20 @@ class DbRepository {
db.seasoningDao().insertSeasoning(item)
}
/**
* 按 goodsId 查重后 upsert:存在则更新,不存在则插入
* 确保同一个 goodsId 在 dm_seasoning 中只有一条记录
*/
suspend fun upsertSeasoningByGoodsId(item: SeasoningEntity) = withContext(Dispatchers.IO) {
val existing = db.seasoningDao().getSeasoningByGoodsId(item.goodsId)
if (existing != null) {
db.seasoningDao().updateSeasoning(item.also { it.id = existing.id })
} else {
item.id = 0
db.seasoningDao().insertSeasoning(item)
}
}
/**
* 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID
@@ -154,6 +179,105 @@ class DbRepository {
suspend fun deleteAllSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().deleteAllByDeviceId(deviceId)
}
// ---- 调试用:查询各表全量数据(含已删除记录)----
/** 查询 dm_cook_food 全部记录 */
suspend fun getAllCookFood() = withContext(Dispatchers.IO) {
db.cookFoodDao().getAllCookFood()
}
/** 查询 dm_cook_food_goods 全部记录 */
suspend fun getAllCookFoodGoods() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getAllCookFoodGoods()
}
/** 查询 dm_seasoning 全部记录 */
suspend fun getAllSeasoning() = withContext(Dispatchers.IO) {
db.seasoningDao().getAllSeasoning()
}
// ---- 调试用:分页查询(含 isDel 过滤)----
suspend fun getCookFoodPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodDao().countCookFoodAll(if (showDel) 1 else 0)
}
suspend fun getCookFoodGoodsPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getCookFoodGoodsPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodGoodsAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countCookFoodGoodsAll(if (showDel) 1 else 0)
}
suspend fun getSeasoningPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countSeasoningAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.seasoningDao().countSeasoningAll(if (showDel) 1 else 0)
}
suspend fun getSlotPaged(limit: Int, offset: Int) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().getSlotPaged(limit, offset)
}
suspend fun countSlotAll() = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countSlotAll()
}
// ---- 调试用:动态条件过滤分页查询 ----
suspend fun getCookFoodPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().queryPagedFiltered(query)
}
suspend fun countCookFoodFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().countFiltered(query)
}
suspend fun getCookFoodGoodsPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().queryPagedFiltered(query)
}
suspend fun countCookFoodGoodsFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countFiltered(query)
}
suspend fun getSeasoningPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().queryPagedFiltered(query)
}
suspend fun countSeasoningFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().countFiltered(query)
}
suspend fun getSlotPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryPagedFiltered(query)
}
suspend fun countSlotFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countFiltered(query)
}
/**
* 物理清空全部 4 张表的测试数据
* 清除顺序:先删子表(食材),再删主表(菜品),最后删配置表(调料、槽位)
*/
suspend fun clearAllData() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteAll()
db.cookFoodDao().deleteAll()
db.seasoningDao().deleteAll()
db.seasoningSlotDao().deleteAll()
}
}
//
//class SeasoningRepository(val seasoningDao: SeasoningDao) {
@@ -2,12 +2,13 @@ package com.shuwei.dish.match.db
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.entity.CollectedFoodItem
import com.shuwei.dish.match.entity.CookFoodEntity
import androidx.sqlite.db.SimpleSQLiteQuery
import com.shuwei.dish.match.model.CollectedFoodItem
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,9 +29,9 @@ class DbViewModel : ViewModel() {
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) {
fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) {
viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode)
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode, dinnerType)
}
}
@@ -48,6 +49,30 @@ class DbViewModel : ViewModel() {
}
}
/**
* 查询本地烹饪中数据(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
fun getCookFoodListWithCallback(
cookMode: Int,
dinnerType: String = "0",
onResult: (MutableList<CookFoodEntity>?) -> Unit
) {
viewModelScope.launch {
onResult(rep.getCookFoodList(cookMode, dinnerType))
}
}
/**
* 直接返回本地烹饪中数据,供需要串行等待结果的场景使用(如先查库再发网络请求)
*/
suspend fun getCookFoodListDirect(
cookMode: Int,
dinnerType: String = "0"
): MutableList<CookFoodEntity>? {
return rep.getCookFoodList(cookMode, dinnerType)
}
suspend fun countCookFood(cookMode: Int): Int {
return rep.countCookFood(cookMode)
}
@@ -68,28 +93,62 @@ class DbViewModel : ViewModel() {
entity: CookFoodEntity,
list: MutableList<CookFoodGoodsEntity>?
) {
val data = rep.getCookFoodById(entity.foodId, cookMode)
if (data != null) {
// 存在数据先删除
rep.deleteCookFood(entity.foodId, cookMode)
rep.deleteCookFoodGoodsList(data.id, entity.foodId)
}
// 重新保存数据
rep.insertCookFood(entity)
val newData = rep.getCookFoodById(entity.foodId, cookMode)
list?.let { it ->
it.forEach { goods ->
goods.id = 0
goods.pid = newData?.id ?: 0
val data = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
if (data == null) {
// 首次保存:主表 + 主辅材 + 调料 全量 insert
rep.insertCookFood(entity)
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)
}
rep.insertGoodsList(it)
}
}
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,15 +18,17 @@ 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
var foodConstituteList: MutableList<CookFoodGoodsEntity>? = null
constructor() : this(foodId = "") // 必需的空构造
}
}
@@ -1,9 +1,9 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.DateTimeUtil
import java.io.Serializable
import java.time.LocalDateTime
@@ -63,30 +63,36 @@ class CookFoodGoodsEntity(
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity, Serializable {
constructor() : this(goodsId = "") // 必需的空构造,
constructor() : this(goodsId = "") // 必需的空构造
@Ignore
var isSetFinished: Boolean = false
@Ignore
var isItemClicked: Boolean = false
@Ignore
var isSamplingPage: Boolean = false
@Ignore
var isNewDishType: Boolean = false
@Ignore
var isClicked: Boolean = false
//true-原始数据,false-非原始数据
@Ignore
var isOriginalData: Boolean = true
@Ignore
var foodScore: Int = -1
}
/**
* 转换为 GoodsItem用于 UI DishPartAdapterPrepareFoodActivity
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -1,7 +1,6 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.utils.DateTimeUtil
@@ -55,6 +54,16 @@ data class SeasoningEntity(
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var sort: Int = 9999,
/**
* isDel表示数据是否已删除1-已删除为无效数据0-正常使用有效数据
@@ -62,11 +71,5 @@ data class SeasoningEntity(
var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity {
@Ignore
var pageType: Int = 0
@Ignore
var isClicked: Boolean = false
constructor() : this(goodsId = "") // 必需的空构造
}
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.db.entity
import androidx.room.Entity
@@ -1,6 +1,5 @@
package com.shuwei.dish.match.dialog
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.ColorDrawable
@@ -8,13 +7,13 @@ import android.os.Bundle
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import androidx.annotation.StyleRes
import android.widget.TextView
import androidx.core.view.updateLayoutParams
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.DialogCommonBinding
import com.shuwei.dish.match.utils.ext.dp
import java.lang.ref.WeakReference
import com.shuwei.dish.match.R
open class CommonDialog(
context: Context
@@ -27,10 +26,17 @@ open class CommonDialog(
private var negativeText = "取消"
private var positiveText = "确认"
private var neutralText: String? = null
private var singleText: String? = null
private var negativeClick: (() -> Unit)? = null
private var positiveClick: (() -> Unit)? = null
// 返回 true 时关闭弹窗,返回 false 时保持弹窗显示(用于校验不通过的场景)
private var positiveClick: (() -> Boolean)? = null
private var neutralClick: (() -> Unit)? = null
private var singleClick: (() -> Unit)? = null
private var dismissCallback: (() -> Unit)? = null
// 待添加到 llContent 的自定义 View,在 onCreate 后挂载
private var pendingContentView: View? = null
// pendingContentView 非空时对 tvContent 的灵活控制回调(可调整可见性、间距等)
private var tvContentConfigurator: ((TextView) -> Unit)? = null
/** 设置标题,为空时隐藏 */
fun setTitle(text: String): CommonDialog = apply { titleText = text }
@@ -44,8 +50,17 @@ open class CommonDialog(
negativeClick = onClick
}
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮) */
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮),点击后自动关闭弹窗 */
fun setPositiveButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
positiveText = text
positiveClick = onClick?.let { cb -> { cb(); true } }
}
/**
* 设置右侧确认按钮文字及可拦截的点击回调
* 回调返回 true 时关闭弹窗,返回 false 时保持弹窗显示(用于校验不通过的场景)
*/
fun setPositiveButtonInterceptable(text: String, onClick: () -> Boolean): CommonDialog = apply {
positiveText = text
positiveClick = onClick
}
@@ -56,26 +71,47 @@ 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)
binding = DialogCommonBinding.inflate(layoutInflater)
setContentView(binding.root)
// 弹窗不允许点击外部关闭
setCancelable(false)
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
window?.run {
val screenWidth = context.resources.displayMetrics.widthPixels
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
attributes = attributes.apply {
width = if (isMaster) (screenWidth * 0.72f).toInt() else (screenWidth * 0.60f).toInt()
}
// 透明背景,让 shape_dialog_bg 的阴影层正常显示
setBackgroundDrawable(ColorDrawable())
setCancelable(false)
// 应用缩放+淡入淡出动画
setWindowAnimations(R.style.CommonDialogAnimation)
}
val verticalMargin = if (GlobalData.deviceRole == DeviceRole.MASTER) 35.dp else 20.dp
// 主/从设备边距微调:主设备内容区域更大,适当增加上下留白
val verticalMargin = if (isMaster) 48.dp else 36.dp
binding.tvTitle.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = verticalMargin }
binding.tvContent.updateLayoutParams<LinearLayout.LayoutParams> { bottomMargin = verticalMargin }
@@ -85,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按钮模式共用)
@@ -99,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() {
@@ -114,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
@@ -129,14 +183,3 @@ open class CommonDialog(
}
}
}
open class SafeDialog(activity: Activity, @StyleRes themeResId: Int) :
Dialog(activity, themeResId) {
private val weakContext = WeakReference(activity)
override fun show() {
weakContext.get()?.let {
if (!it.isFinishing && !it.isDestroyed) super.show()
}
}
}
@@ -0,0 +1,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()
}
@@ -1,62 +1,62 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogFoodRecognizeBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
/**
* 菜品识别结果弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param onDismiss 弹窗关闭时的额外回调(可选)
* @param onItemSelected 用户点击某一菜品时的回调
*/
class FoodRecognizeDialog(
private val activity: BaseActivity,
private val onDismiss: (() -> Unit)? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogFoodRecognizeBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>()
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar(); onDismiss?.invoke() }
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodRecognizeDialog.adapter
}
}
/**
* 加载识别结果数据并显示弹窗
* @param items 识别到的菜品列表
*/
@SuppressLint("NotifyDataSetChanged")
fun loadData(items: List<CookFoodGoodsEntity>) {
list.clear()
list.addAll(items)
adapter.notifyDataSetChanged()
if (!isShowing) show()
}
}
//package com.shuwei.dish.match.dialog
//
//import android.annotation.SuppressLint
//import android.os.Handler
//import android.os.Looper
//import android.view.LayoutInflater
//import androidx.recyclerview.widget.GridLayoutManager
//import com.google.android.material.bottomsheet.BottomSheetDialog
//import com.shuwei.dish.match.R
//import com.shuwei.dish.match.adapter.FoodAdapter
//import com.shuwei.dish.match.base.BaseActivity
//import com.shuwei.dish.match.databinding.DialogFoodRecognizeBinding
//import com.shuwei.dish.match.entity.GoodsItem
//
///**
// * 菜品识别结果弹窗,继承 BottomSheetDialog 确保只初始化一次
// * @param activity 宿主 Activity
// * @param onDismiss 弹窗关闭时的额外回调(可选)
// * @param onItemSelected 用户点击某一菜品时的回调
// */
//class FoodRecognizeDialog(
// private val activity: BaseActivity,
// private val onDismiss: (() -> Unit)? = null,
// private val onItemSelected: (item: GoodsItem) -> Unit
//) : BottomSheetDialog(activity, R.style.BottomSheet) {
//
// private val binding = DialogFoodRecognizeBinding.inflate(LayoutInflater.from(activity))
// private val list = mutableListOf<GoodsItem>()
// private val adapter = FoodAdapter(list).apply {
// setOnItemClickListener { _, _, position ->
// list[position].isClicked = true
// notifyItemChanged(position)
// Handler(Looper.getMainLooper()).postDelayed({
// onItemSelected(list[position])
// dismiss()
// }, 300)
// }
// }
//
// init {
// setContentView(binding.root)
// setCancelable(true)
// behavior.skipCollapsed = false
// setOnDismissListener { activity.hideStatusBar(); onDismiss?.invoke() }
// binding.recyclerView.run {
// layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
// adapter = this@FoodRecognizeDialog.adapter
// }
// }
//
// /**
// * 加载识别结果数据并显示弹窗
// * @param items 识别到的菜品列表
// */
// @SuppressLint("NotifyDataSetChanged")
// fun loadData(items: List<GoodsItem>) {
// list.clear()
// list.addAll(items)
// adapter.notifyDataSetChanged()
// if (!isShowing) show()
// }
//}
@@ -1,30 +1,34 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.graphics.Rect
import android.text.Editable
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import androidx.core.view.isEmpty
import android.view.Window
import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.LinearLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.adapter.FoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
@@ -36,25 +40,39 @@ import kotlinx.coroutines.launch
class FoodSearchDialog(
private val activity: BaseActivity,
private val defGoodsName: String? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
private val onItemSelected: (item: GoodsItem) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val PAGE_SIZE = 50
}
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>()
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
private val list = mutableListOf<GoodsItem>()
private val adapter = FoodAdapter(list).apply {
isStateViewEnable = 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(item)
dismiss()
}, 300)
}
}
}
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
@@ -79,22 +97,22 @@ class FoodSearchDialog(
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化及滑动冲突处理
// RecyclerView 初始化
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodSearchDialog.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
// addOnScrollListener(object : RecyclerView.OnScrollListener() {
// override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
// super.onScrolled(rv, dx, dy)
// // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
// }
// })
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -106,19 +124,60 @@ class FoodSearchDialog(
initObserver()
// 若有默认食材名称,自动填充并触发搜索
if (!defGoodsName.isNullOrBlank()) {
binding.etSheetInput.setText(defGoodsName)
searchGoods(binding.etSheetInput)
//if (!defGoodsName.isNullOrBlank()) {
//binding.etSheetInput.setText(defGoodsName)
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(binding.root)
//}
}
/** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */
override fun show() {
super.show()
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
// 是最早能感知触摸的时机。在此处判断触摸点位置来控制 behavior.isDraggable
// 从而确保 BottomSheetBehavior 在 CoordinatorLayout.onInterceptTouchEvent 时
// 已经是禁用状态,不会拦截列表区域的滑动手势。
val originalCallback = window?.callback ?: return
window?.callback = object : Window.Callback by originalCallback {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val rect = Rect()
binding.refreshLayout.getGlobalVisibleRect(rect)
// 触摸点落在列表区域内则禁止 BottomSheet 拖拽
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
behavior.isDraggable = true
}
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
/**
* 收集 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 -> {
@@ -146,35 +205,46 @@ class FoodSearchDialog(
}
/**
* 请求食材列表
* 请求食材列表,使用弹窗专用接口,结果写入 foodSearchGoodsListState
*/
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "0",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "0",
pageNum = pageNo,
pageSize = PAGE_SIZE,
goodsName = goodsName.takeUnless { it.isNullOrBlank() }
)
if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!!
}
activity.netViewModel.queryGoodsList(param)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<CookFoodGoodsEntity>?) {
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到食材信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
// 仅当当前不是 GridLayoutManager 时才重新赋值(空态切回网格时)
binding.recyclerView.run {
if (layoutManager !is GridLayoutManager) {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
}
}
if (pageNo == 1) list.clear()
// 将接口返回的 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 >= pageSize
val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
@@ -183,4 +253,39 @@ class FoodSearchDialog(
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
list.clear()
adapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(activity),
binding.recyclerView,
false
)
}
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到食材信息,请联系管理员添加"
// it.root.setOnClickListener { loadGoodsList(buildMockGoodsList().toMutableList()) }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,80 @@
package com.shuwei.dish.match.dialog
import android.graphics.Rect
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsPreviewAdapter
import com.shuwei.dish.match.databinding.DialogGoodsPreviewBinding
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
/**
* 用料明细预览弹窗,以 BottomSheetDialog 形式展示当前 goodsList
* @param context 宿主 ContextActivity
* @param goodsList 需要展示的物品列表
*/
class GoodsPreviewDialog(
context: android.content.Context,
private val goodsList: List<CookFoodGoodsEntity>
) : BottomSheetDialog(context, R.style.BottomSheet) {
private val binding = DialogGoodsPreviewBinding.inflate(LayoutInflater.from(context))
init {
setContentView(binding.root)
setCancelable(true)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
binding.rvGoodsList.run {
layoutManager = LinearLayoutManager(context)
adapter = GoodsPreviewAdapter(goodsList)
}
// foodId 对同一菜品所有条目相同,统一显示在列表上方
val foodId = goodsList.firstOrNull()?.foodId.orEmpty()
binding.tvFoodId.text = "菜品ID${foodId.ifEmpty { "-" }}"
if (goodsList.isEmpty()) {
binding.rvGoodsList.gone()
binding.tvEmpty.visible()
}
}
/** 弹出时注入 Window.Callback,解决 RecyclerView 与 BottomSheet 拖拽的滑动冲突 */
override fun show() {
super.show()
val originalCallback = window?.callback ?: return
window?.callback = object : android.view.Window.Callback by originalCallback {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val rect = Rect()
binding.rvGoodsList.getGlobalVisibleRect(rect)
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
behavior.isDraggable = true
}
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
}
@@ -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()
}
if (dialog == null) {
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss)
dialog?.updateContent(message)
owner = context // 同一弹窗最后一次 show 决定 owner
resetTimeout(timeoutMs)
return
}
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss)
owner = context
dialog?.show()
resetTimeout(timeoutMs)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun dismiss() {
timeoutHandler.removeCallbacks(timeoutRunnable)
try {
dialog?.let {
if (it.isShowing) {
it.dismiss()
}
}
dialog = null
} catch (e: Exception) {
e.printStackTrace()
} finally {
dialog = null
owner = null
}
}
/**
* Activity 销毁专用:只关闭自己持有的 Loading,防止跨 Activity 误关
* 单例 Loading 在多 Activity 共享时,若每个 Activity 的 onDestroy 都无脑 dismiss
* 会误关后启动 Activity 弹出的 Loading;由本方法做 owner 校验后才允许关闭
*/
fun dismissIfOwnedBy(context: Context) {
if (owner === context) dismiss()
}
/**
* 重置超时兜底定时器
* @param timeoutMs <=0 表示不安排定时器(关闭兜底)
*/
private fun resetTimeout(timeoutMs: Long) {
timeoutHandler.removeCallbacks(timeoutRunnable)
if (timeoutMs > 0) {
timeoutHandler.postDelayed(timeoutRunnable, timeoutMs)
}
}
@@ -1,259 +1,259 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan
import android.text.style.StyleSpan
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
/**
* 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param weighIndex 秤的索引
* @param weighAddress 秤的地址,用于监听重量和去皮
* @param clickName 默认填充的调料名称(可选)
* @param onItemSelected 用户点击某一调料时的回调,携带当前重量
*/
class SeasoningSearchDialog(
private val activity: BaseActivity,
private val weighIndex: Int,
private val weighAddress: Int,
private val clickName: String? = null,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "BottomDialog2"
}
private val binding = DialogSeasoningSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position].also { it.useWeight = currentWeight })
dismiss()
}, 300)
}
}
private var goodsName: String? = null
private var currentWeight = 0.0
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener {
// 弹窗关闭时移除重量监听,避免内存泄漏
WeightUtil.removeWeightListener(TAG)
activity.hideStatusBar()
}
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入调料名称"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
afterTextChanged = {
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化及滑动冲突处理
binding.recyclerView.let {
it.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
it.adapter = adapter
it.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
// 去皮按钮
binding.tvClear.setOnClickListener {
Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
WeightUtil.tareTwo(weighAddress)
}
// 监听对应秤的重量变化
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, _, weight ->
if (weighAddress == address) {
currentWeight = weight.toDouble()
binding.tvWeight.text = getTextSpan(weight)
}
}
)
binding.tvWeight.text = getTextSpan(0.0)
initObserver()
// 若有默认调料名称,自动填充并触发搜索
if (!clickName.isNullOrBlank()) {
binding.etSheetInput.setText(clickName.trim())
pageNo = 1
getGoodsList()
}
}
/**
* 收集 seasoningListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.seasoningListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v)
}
/**
* 请求调料列表
*/
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "1",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!!
}
activity.netViewModel.querySeasoningList(param)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
/**
* 生成重量显示的富文本:数值大字加粗,单位小字灰色
*/
fun getTextSpan(weight: Double): SpannableStringBuilder {
val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
val bottomUnit = if (weight >= 1000) "千克" else ""
return buildSpannableString {
appendText(
topWeight,
ForegroundColorSpan("#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(72, true),
LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
)
append("\n")
appendText(
bottomUnit,
ForegroundColorSpan("#999999".toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
//package com.shuwei.dish.match.dialog
//
//import android.annotation.SuppressLint
//import android.graphics.Typeface
//import android.os.Handler
//import android.os.Looper
//import android.text.SpannableStringBuilder
//import android.text.style.AbsoluteSizeSpan
//import android.text.style.ForegroundColorSpan
//import android.text.style.LineHeightSpan
//import android.text.style.StyleSpan
//import android.util.Log
//import android.view.Gravity
//import android.view.LayoutInflater
//import android.view.View
//import android.view.ViewGroup
//import android.view.WindowManager
//import androidx.core.graphics.toColorInt
//import androidx.core.view.isEmpty
//import androidx.core.widget.addTextChangedListener
//import androidx.lifecycle.Lifecycle
//import androidx.lifecycle.lifecycleScope
//import androidx.lifecycle.repeatOnLifecycle
//import androidx.recyclerview.widget.GridLayoutManager
//import androidx.recyclerview.widget.RecyclerView
//import com.google.android.material.bottomsheet.BottomSheetDialog
//import com.shuwei.dish.match.R
//import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
//import com.shuwei.dish.match.base.BaseActivity
//import com.shuwei.dish.match.base.BaseApp
//import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
//import com.shuwei.dish.match.entity.SeasoningEntity
//import com.shuwei.dish.match.net.UiState
//import com.shuwei.dish.match.utils.KeyboardUtil
//import com.shuwei.dish.match.utils.WeightUtil
//import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
//import com.shuwei.dish.match.utils.ext.appendText
//import com.shuwei.dish.match.utils.ext.buildSpannableString
//import com.shuwei.dish.match.utils.ext.dp
//import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
//import com.shuwei.dish.match.utils.ext.toast
//import kotlinx.coroutines.launch
//
///**
// * 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
// * @param activity 宿主 Activity
// * @param weighIndex 秤的索引
// * @param weighAddress 秤的地址,用于监听重量和去皮
// * @param clickName 默认填充的调料名称(可选)
// * @param onItemSelected 用户点击某一调料时的回调,携带当前重量
// */
//class SeasoningSearchDialog(
// private val activity: BaseActivity,
// private val weighIndex: Int,
// private val weighAddress: Int,
// private val clickName: String? = null,
// private val onItemSelected: (item: SeasoningEntity) -> Unit
//) : BottomSheetDialog(activity, R.style.BottomSheet) {
//
// companion object {
// const val TAG = "BottomDialog2"
// }
//
// private val binding = DialogSeasoningSearchBinding.inflate(LayoutInflater.from(activity))
// private val list = mutableListOf<SeasoningEntity>()
// private val adapter = SeasoningSearchAdapter(list).apply {
// setOnItemClickListener { _, _, position ->
// list[position].isClicked = true
// notifyItemChanged(position)
// Handler(Looper.getMainLooper()).postDelayed({
// onItemSelected(list[position].also { it.useWeight = currentWeight })
// dismiss()
// }, 300)
// }
// }
//
// private var goodsName: String? = null
// private var currentWeight = 0.0
// private var pageNo = 1
// private val pageSize = 50
//
// init {
// setContentView(binding.root)
// setCancelable(true)
// behavior.skipCollapsed = false
// window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
// window?.setGravity(Gravity.BOTTOM)
// window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
// window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
//
// setOnDismissListener {
// // 弹窗关闭时移除重量监听,避免内存泄漏
// WeightUtil.removeWeightListener(TAG)
// activity.hideStatusBar()
// }
//
// // 输入框:清空时重置列表
// binding.etSheetInput.run {
// hint = "输入调料名称"
// addTextChangedListener(
// onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
// afterTextChanged = {
// if (it.isNullOrBlank()) {
// list.clear()
// adapter.notifyDataSetChanged()
// }
// }
// )
// addOnActionSearchListener { searchGoods(this) }
// }
//
// // RecyclerView 初始化及滑动冲突处理
// binding.recyclerView.let {
// it.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
// it.adapter = adapter
// it.addOnScrollListener(object : RecyclerView.OnScrollListener() {
// override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
// super.onScrolled(rv, dx, dy)
// // 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
// val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
// binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
// }
// })
// }
//
// binding.refreshLayout.run {
// setEnableRefresh(false)
// setEnableLoadMore(false)
// setOnRefreshListener { pageNo = 1; getGoodsList() }
// setOnLoadMoreListener { getGoodsList() }
// }
//
// binding.ivSearch.setOnClickListener { searchGoods(it) }
// binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
//
// // 去皮按钮
// binding.tvClear.setOnClickListener {
// Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
// WeightUtil.tareTwo(weighAddress)
// }
//
// // 监听对应秤的重量变化
// WeightUtil.addWeightListener(
// weightKey = TAG,
// getWeight = { address, _, weight ->
// if (weighAddress == address) {
// currentWeight = weight.toDouble()
// binding.tvWeight.text = getTextSpan(weight)
// }
// }
// )
// binding.tvWeight.text = getTextSpan(0.0)
//
// initObserver()
//
// // 若有默认调料名称,自动填充并触发搜索
// if (!clickName.isNullOrBlank()) {
// binding.etSheetInput.setText(clickName.trim())
// pageNo = 1
// getGoodsList()
// }
// }
//
// /**
// * 收集 seasoningListState,统一处理 Loading / Success / Error 状态
// */
// private fun initObserver() {
// activity.lifecycleScope.launch {
// activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
// activity.netViewModel.seasoningListState.collect { state ->
// when (state) {
// is UiState.Success -> loadGoodsList(state.data)
// is UiState.Error -> {
// activity.toast(state.msg)
// finishRefresh()
// }
// else -> {}
// }
// }
// }
// }
// }
//
// /**
// * 触发搜索:校验输入、隐藏键盘、发起请求
// */
// private fun searchGoods(v: View) {
// if (goodsName.isNullOrBlank()) {
// activity.toast("请${binding.etSheetInput.hint}")
// return
// }
// pageNo = 1
// getGoodsList()
// KeyboardUtil.hideKeyboard(v)
// }
//
// /**
// * 请求调料列表
// */
// private fun getGoodsList() {
// val param = mutableMapOf<String, Any>(
// "goodsType" to "1",
// "placeId" to BaseApp.canteenId,
// "pageNum" to pageNo,
// "pageSize" to pageSize
// )
// if (!goodsName.isNullOrBlank()) {
// param["goodsName"] = goodsName!!
// }
// activity.netViewModel.querySeasoningList(param)
// }
//
// /**
// * 将返回数据填充到列表
// */
// @SuppressLint("NotifyDataSetChanged")
// private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
// finishRefresh()
// if (records.isNullOrEmpty()) {
// activity.toast("暂未搜索到调料信息")
// return
// }
// if (pageNo == 1) list.clear()
// list.addAll(records)
// adapter.notifyDataSetChanged()
// val isLoadMoreEnable = records.size >= pageSize
// binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
// if (isLoadMoreEnable) pageNo++
// }
//
// private fun finishRefresh() {
// if (pageNo == 1) binding.refreshLayout.finishRefresh()
// else binding.refreshLayout.finishLoadMore()
// }
//
// /**
// * 生成重量显示的富文本:数值大字加粗,单位小字灰色
// */
// fun getTextSpan(weight: Double): SpannableStringBuilder {
// val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
// val bottomUnit = if (weight >= 1000) "千克" else "克"
// return buildSpannableString {
// appendText(
// topWeight,
// ForegroundColorSpan("#000000".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(72, true),
// LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
// )
// append("\n")
// appendText(
// bottomUnit,
// ForegroundColorSpan("#999999".toColorInt()),
// AbsoluteSizeSpan(30, true)
// )
// }
// }
//}
@@ -5,47 +5,58 @@ import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.KeyboardShortcutGroup
import android.view.LayoutInflater
import android.view.Menu
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
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
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
* 调料选择弹窗,不含重量显示,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param clickIndex 点击的调料槽位索引
* @param onItemSelected 用户点击某一调料时的回调
*/
class SeasoningSelectDialog(
private val activity: BaseActivity,
private val onItemSelected: (item: SeasoningEntity) -> Unit
private val onItemSelected: (item: GoodsItem) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "SeasoningSelectDialog"
const val PAGE_SIZE = 50
}
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val list = mutableListOf<GoodsItem>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
isStateViewEnable = true
setOnItemClickListener { _, v, position ->
val item = list.getOrNull(position) ?: return@setOnItemClickListener
item.isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
v.postDelayed({
onItemSelected(item)
dismiss()
}, 300)
}
@@ -53,7 +64,7 @@ class SeasoningSelectDialog(
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
private var collectJob: Job? = null
init {
setContentView(binding.root)
@@ -66,6 +77,7 @@ class SeasoningSelectDialog(
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener {
collectJob?.cancel()
activity.hideStatusBar()
}
@@ -91,7 +103,7 @@ class SeasoningSelectDialog(
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
@@ -104,6 +116,23 @@ class SeasoningSelectDialog(
/** 弹窗显示时自动加载默认调料列表,并在 Window.Callback 层提前拦截触摸以解决滑动冲突 */
override fun show() {
super.show()
// 启动 StateFlow 收集,弹窗关闭时由 setOnDismissListener 取消
collectJob = activity.lifecycleScope.launch {
activity.netViewModel.foodSearchGoodsListState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
if (pageNo == 1) {
loadEmptyView()
}
}
is UiState.Idle -> {}
}
}
}
pageNo = 1
getGoodsList()
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
@@ -126,6 +155,18 @@ class SeasoningSelectDialog(
}
return originalCallback.dispatchTouchEvent(event)
}
override fun onPointerCaptureChanged(hasCapture: Boolean) {
originalCallback.onPointerCaptureChanged(hasCapture)
}
override fun onProvideKeyboardShortcuts(
data: List<KeyboardShortcutGroup?>?,
menu: Menu?,
deviceId: Int
) {
originalCallback.onProvideKeyboardShortcuts(data, menu, deviceId)
}
}
}
@@ -143,52 +184,57 @@ class SeasoningSelectDialog(
}
/**
* 请求调料列表(临时使用本地模拟数据,正式联调时替换为真实接口)
* 请求调料列表
*/
private fun getGoodsList() {
// TODO: 联调时注释掉下方模拟数据,取消注释真实接口调用
val nameList = listOf(
"", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
activity.netViewModel.queryFoodSearchGoodsList(
goodsType = "1",
pageNum = pageNo,
pageSize = PAGE_SIZE,
goodsName = goodsName.takeUnless { it.isNullOrBlank() }
)
val mockData = mutableListOf<SeasoningEntity>()
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
mockData.add(SeasoningEntity(goodsId = "1000$padString", goodsName = name))
}
loadGoodsList(mockData)
// val param = mutableMapOf<String, Any>(
// "goodsType" to "1",
// "placeId" to BaseApp.canteenId,
// "pageNum" to pageNo,
// "pageSize" to pageSize
// )
// if (!goodsName.isNullOrBlank()) {
// param["goodsName"] = goodsName!!
// }
// activity.querySeasoningList(param = param, onSuccess = {
// loadGoodsList(it)
// }, onFailure = { _, msg ->
// activity.toast(msg)
// finishRefresh()
// })
}
/**
* 将返回数据填充到列表
* 加载本地模拟数据,仅用于联调时点击空布局快速验证 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)
}
/**
* 将返回数据转换并填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
private fun loadGoodsList(records: MutableList<GoodsItem>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
//activity.toast("暂未搜索到调料信息")
if (pageNo == 1) {
loadEmptyView()
}
return
}
binding.recyclerView.layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
if (pageNo == 1) list.clear()
list.addAll(records)
list.forEach {
it.oldGoodsId = it.goodsId
//materId才是提交数据需要的goodsId数据
it.goodsId = it.materId ?:""
}
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
val isLoadMoreEnable = records.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
@@ -197,4 +243,41 @@ class SeasoningSelectDialog(
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
try {
binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false)
list.clear()
adapter.notifyDataSetChanged()
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(activity),
binding.recyclerView,
false
)
}
emptyViewBinding?.let {
it.tvContent.text = "暂无数据"
it.tvSubContent.text = "未查询到调料信息,请联系管理员添加"
// 点击空布局加载本地模拟数据,方便联调时验证 UI
//it.root.setOnClickListener { loadMockData() }
it.root.layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
binding.recyclerView.layoutManager = LinearLayoutManager(activity)
binding.recyclerView.post {
it.root.minimumHeight = binding.recyclerView.height
adapter.stateView = it.root
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -1,62 +0,0 @@
package com.shuwei.dish.match.entity
data class HomeModeBean(
var modeName: String = "",
var modeDesc: String = "",
var modeIconId: Int = 0,
var isLocked: Boolean = false,
var onClick: (() -> Unit)? = null
)
data class FoodRecordBean(
var records: MutableList<FoodRecord>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class GoodsRecordBean(
var records: MutableList<CookFoodGoodsEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class SeasoningRecordBean(
var records: MutableList<SeasoningEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class ResetReasoningRecord(
val list: MutableList<SeasoningEntity>
)
//data class DishDetailEntity(
// var foodId: String? = null,
// var foodWeight: Double? = null,
// var foodRealWeight: Double? = null,
// var voList: List<DishPartEntity>? = null
//)
//data class DishPartEntity(
// var foodId: String? = null,
// var foodName: String? = null,
// var foodWeight: Int? = null,
// var useWeight: Int? = null,
// var useRealWeight: Int? = null,
// var goodsId: Int? = null,
// var goodsName: String? = null,
// var materialType: Int? = null,
// var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
// var isSamplingPage: Boolean = false
//)
@@ -1,25 +0,0 @@
package com.shuwei.dish.match.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity
//@Entity(tableName = "dm_dish")
//data class DishEntity (
// /**
// * 菜品id
// */
// @PrimaryKey var id: Long,
// /**
// * 菜品名称
// */
// var name: String? = "",
// /**
// * 菜品重量
// */
// var weight: Int? = 0,
//
// var sort: Int = 0,
// @ColumnInfo(name = "create_time") val createTime: Long = System.currentTimeMillis()
//): BaseEntity
@@ -1,38 +0,0 @@
//package com.shuwei.dish.match.entity
//
//import androidx.room.Ignore
//import androidx.room.PrimaryKey
//import com.shuwei.dish.match.db.BaseEntity
//
//data class GoodsInfoEntity(
// @PrimaryKey(autoGenerate = true) var id: Long = 0,
// var goodsId: Int,
// var goodsName: String? = "",
// var popularName: String? = "",
// var canteenId: String? = "",
// var goodsOrRelationCode: String? = "",
// var relateionType: Int? = -1,
// var relateionType_dictText: String? = "",
//
// var sort: Int = 0,
//
// var swMaterBaseMaterial: String? = "",
// var preMaterClass: String? = "",
// var preUseWeight: Int? = 0,
// var preWeight: Int? = 0,
// var oil: String? = "",
// var sugar: String? = "",
// /**
// * isDel表示数据是否已删除,1-已删除,为无效数据,0-正常使用,有效数据
// */
// var isDel: Int = 0,
// val createTime: Long = System.currentTimeMillis()
//) : BaseEntity {
// @Ignore
// var pageType: Int = 0
//
// @Ignore
// var isClicked: Boolean = false
//
// constructor() : this(id = -1, goodsId = -1) // 必需的空构造
//}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity
package com.shuwei.dish.match.model
/**
* 已采集食材列表项数据模型
@@ -0,0 +1,43 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品详情接口传输对象(DTO),不依赖 Room
* 对应接口:getConstituteByFoodId / saveConstitute
*/
data class CookFoodDTO(
var foodId: String = "",
var foodName: String? = null,
var canteenId: String? = null,
var foodWeight: Double = 0.0,
// 0-制作模式,1-采样模式,2-品控模式
var cookMode: Int = 0,
var dinnerType: String? = "0",
// 接口返回的食材构成列表
var matchingConstituteInfoList: MutableList<CookFoodGoodsDTO>? = null,
var foodConstituteList: MutableList<CookFoodGoodsDTO>? = null
) : Serializable {
/**
* 转换为 Room 实体,用于写入本地数据库
* id/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodEntity = CookFoodEntity(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType
).also { entity ->
entity.matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toEntity() }
?.toMutableList()
entity.foodConstituteList = foodConstituteList
?.map { it.toEntity() }
?.toMutableList()
}
}
@@ -0,0 +1,119 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品构成(主辅料/调料)接口传输对象(DTO),不依赖 Room
* 对应 CookFoodDTO 中 foodConstituteList、matchingConstituteInfoList 的元素
*/
data class CookFoodGoodsDTO(
var goodsId: String = "",
var goodsName: String? = null,
var foodId: String? = "",
var goodsOrRelationCode: String? = "",
// 调料数据顺序
var sort: Int = 0,
// 食材原材料:1,预制品:2
var relateionType: Int = 0,
// 物品类型:1主料 2辅料 3调料
var materialType: Int = 0,
// 物品 是否全部可食:true-全部可食,false-部分可食
var allEdible: Boolean = true,
// 物品 用料重量
var useWeight: Double? = 0.0,
var popularName: String? = "",
var canteenId: String? = "",
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
// 物料编码
var goodsCode: String? = "",
// 净材种类
var rawMaterialsType: String? = null
) : Serializable {
/**
* 转换为 GoodsItem,用于 UI 层
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 Room 实体,用于写入本地数据库
* id/pid/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
}
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/pid/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodGoodsEntity.toDTO(): CookFoodGoodsDTO = CookFoodGoodsDTO(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodEntity.toDTO(): CookFoodDTO = CookFoodDTO(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType,
matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toDTO() }
?.toMutableList(),
foodConstituteList = foodConstituteList
?.map { it.toDTO() }
?.toMutableList()
)
@@ -0,0 +1,52 @@
package com.shuwei.dish.match.model
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class HomeModeBean(
var modeName: String = "",
var modeDesc: String = "",
var modeIconId: Int = 0,
var isLocked: Boolean = false,
var onClick: (() -> Unit)? = null
)
data class FoodCollectionBean(
var imageUri: Uri? = null,
var bitmap: Bitmap? = null,
var imageFile: File? = null,
var imageVector: FloatArray? = null,
var isShowCamera: Boolean = false,
var isFinish:Boolean = false,
var uploadSuccess:Boolean = false
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FoodCollectionBean
if (isShowCamera != other.isShowCamera) return false
if (isFinish != other.isFinish) return false
if (uploadSuccess != other.uploadSuccess) return false
if (imageUri != other.imageUri) return false
if (bitmap != other.bitmap) return false
if (imageFile != other.imageFile) return false
if (!imageVector.contentEquals(other.imageVector)) return false
return true
}
override fun hashCode(): Int {
var result = isShowCamera.hashCode()
result = 31 * result + isFinish.hashCode()
result = 31 * result + uploadSuccess.hashCode()
result = 31 * result + (imageUri?.hashCode() ?: 0)
result = 31 * result + (bitmap?.hashCode() ?: 0)
result = 31 * result + (imageFile?.hashCode() ?: 0)
result = 31 * result + (imageVector?.contentHashCode() ?: 0)
return result
}
}
@@ -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
)
@@ -0,0 +1,83 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import java.io.Serializable
/**
* 物品信息接口返回的数据类(DTO),不依赖 Room,同时承载 UI 状态字段
* 对应接口:queryGoodsInfoList,食材(goodsType=0)和调料(goodsType=1)共用此结构
*/
data class GoodsItem(
// 接口返回字段
var goodsId: String = "",
var goodsName: String? = null,
val popularName: String? = null,
val zjmCode: String? = null,
var materId: String? = null,
var materCode: String? = null,
// 从 CookFoodGoodsEntity 迁移的数据字段(DishPartAdapter 显示及转换用)
var materialType: Int = 0,
var useWeight: Double? = 0.0,
var relateionType: Int = 0,
var allEdible: Boolean = true,
var goodsOrRelationCode: String? = "",
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
// true-原始数据,false-非原始数据(识图新增)
var isOriginalData: Boolean = true,
// 识图匹配分数,-1 表示不显示
var foodScore: Int = -1,
// 净材种类(当前选中值)
var rawMaterialsType: String? = null,
// 净材种类候选列表(临时字段,逗号分隔,不持久化)
var rawMaterialsTypes: String? = null,
var oldGoodsId: String? = null
) : Serializable {
/**
* 物料编码,与查询接口返回的 materCode 同值,统一对外暴露为 goodsCode 供下游使用
*/
val goodsCode: String? get() = materCode
/**
* 转换为 CookFoodGoodsEntity,用于写入数据库或传给 SubmitFoodActivity
*/
fun toCookFoodGoodsEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 SeasoningEntity,用于保存调料完整信息到 dm_seasoning
*/
fun toSeasoningEntity(): SeasoningEntity = SeasoningEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.model
/**
* 物品名称批量查询入参 DTO
* 用于 queryGoodsInfoList 接口 goodsNames 字段的元素类型
* @param goodsName 物品名称
* @param rawMaterialsType 净材种类
*/
data class GoodsNameQueryDTO(
val goodsName: String,
val rawMaterialsType: String
)
@@ -0,0 +1,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,13 +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.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.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
@@ -20,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?>
/**
@@ -29,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?>
/**
@@ -37,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>?>
@@ -46,68 +50,27 @@ 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>?>
/**
* 物品信息
* 物品信息(食材 goodsType=0,调料 goodsType=1 共用此接口)
*/
@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<CookFoodGoodsEntity>?>
): ApiResponse<MutableList<GoodsItem>?>
/**
* 调料信息
* 提交采集图片数据
*/
@Multipart
@POST
suspend fun querySeasoningList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any>
): ApiResponse<MutableList<SeasoningEntity>?>
// /**
// *获取配置信息
// */
// @GET("/equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = App.appVersion
// ): ApiResponse<DeviceConfigInfo>
//
// @GET
// suspend fun getAccessToken(
// @Url url: String = UrlConfig.GET_ACCESS_TOKEN,
// @Query("qrcodeId") qrcodeId: String
// ): ApiResponse<String?>
//
// @GET
// suspend fun getShelfList(
// @Url url: String = UrlConfig.GET_SHELF_LIST,
// @Query("deviceId") deviceId: String
// ): ApiResponse<ShelfResult>
//
// @GET
// suspend fun getGoodsList(
// @Url url: String = UrlConfig.GET_GOODS_LIST,
// @Query("canteenId") canteenId: String = "0",
// @Query("goodsName") goodsName: String? = null,
// @Query("pageNo") pageNo: Int = 1,
// @Query("pageSize") pageSize: Int = 50
// ): ApiResponse<MutableList<GoodsModel>?>
//
// @POST
// suspend fun saveShelfGoodsList(
// @Url url: String = UrlConfig.SAVE_SHELF_GOODS_LIST,
// @Body body: ShelfBody
// ): ApiResponse<Any?>
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>
}
@@ -2,14 +2,23 @@ package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.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()
@@ -87,15 +123,61 @@ class NetViewModel(
}
/**
* 查询物品信息列表的 UI 状态流,UI 层通过 collect 监听
* 查询采样数据列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
private val _goodsListState = MutableStateFlow<UiState<MutableList<CookFoodGoodsEntity>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<CookFoodGoodsEntity>?>> = _goodsListState.asStateFlow()
fun getSamplingListWithCallback(
param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getSamplingList(param))
}
}
/**
* 查询物品信息列表
* 查询物品信息列表的 UI 状态流(食材/调料共用),UI 层通过 collect 监听
*/
fun queryGoodsList(param: MutableMap<String, Any>) {
private val _goodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _goodsListState.asStateFlow()
/**
* 食材搜索弹窗专用的 UI 状态流,与 goodsListState 隔离,避免弹窗请求影响其他页面
*/
private val _foodSearchGoodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val foodSearchGoodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _foodSearchGoodsListState.asStateFlow()
fun resetGoodsListState() {
_goodsListState.value = UiState.Idle
}
/**
* 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用)
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryGoodsList(
goodsType: String,
placeId: String = BaseApp.canteenId,
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch {
_goodsListState.value = UiState.Loading
_goodsListState.value = repository.queryGoodsList(param)
@@ -103,18 +185,74 @@ class NetViewModel(
}
/**
* 查询调料信息列表的 UI 状态流,UI 层通过 collect 监听
* 食材搜索弹窗专用的查询方法,结果写入 foodSearchGoodsListState,不影响 goodsListState
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
private val _seasoningListState = MutableStateFlow<UiState<MutableList<SeasoningEntity>?>>(UiState.Idle)
val seasoningListState: StateFlow<UiState<MutableList<SeasoningEntity>?>> = _seasoningListState.asStateFlow()
/**
* 查询调料信息列表
*/
fun querySeasoningList(param: MutableMap<String, Any>) {
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 {
_seasoningListState.value = UiState.Loading
_seasoningListState.value = repository.querySeasoningList(param)
_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,9 +1,10 @@
package com.shuwei.dish.match.net
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.model.GoodsItem
import okhttp3.MultipartBody
import okhttp3.RequestBody
/**
* 网络数据仓库,封装所有 ApiService 调用
@@ -11,101 +12,42 @@ import com.shuwei.dish.match.entity.SeasoningEntity
*/
class RemoteRepository {
/**
* 提交制作菜品
* @param entity 菜品实体
* @return UiState 包装的结果,Success 表示提交成功,Error 携带错误信息
*/
suspend fun submitCookFood(entity: CookFoodEntity): UiState<Any?> {
/** 统一封装 API 调用:处理响应映射和异常转换,返回 UiState */
private suspend fun <T> safeApiCall(call: suspend () -> ApiResponse<T>): UiState<T?> {
return try {
val resp = apiService.submitCookFood(param = entity)
val resp = call()
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
UiState.Error("-1", getApiException(e).errorMsg)
}
}
/**
* 查询菜品详情
* @param foodId 菜品 ID
* @return UiState 包装的结果,Success 携带详情数据,Error 携带错误信息
*/
suspend fun getFoodDetail(foodId: String): UiState<CookFoodEntity?> {
return try {
val resp = apiService.getFoodDetail(foodId = foodId)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 提交制作菜品 */
suspend fun submitCookFood(entity: CookFoodDTO): UiState<Any?> =
safeApiCall { apiService.submitCookFood(param = entity) }
/**
* 搜索菜品列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.searchFoodList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 查询菜品详情 */
suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> =
safeApiCall { apiService.getFoodDetail(foodId = foodId) }
/**
* 查询采样数据列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun getSamplingList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.getSamplingList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 搜索菜品列表 */
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
safeApiCall { apiService.searchFoodList(param = param) }
/**
* 查询物品信息列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<CookFoodGoodsEntity>?> {
return try {
val resp = apiService.queryGoodsList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 查询采样数据列表 */
suspend fun 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) }
/**
* 查询调料信息列表
* @param param 查询参数
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun querySeasoningList(param: MutableMap<String, Any>): UiState<MutableList<SeasoningEntity>?> {
return try {
val resp = apiService.querySeasoningList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 上传采集数据 */
suspend fun 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) }
}
@@ -1,68 +0,0 @@
package com.shuwei.dish.match.objbox
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class FoodClassInfo(
var class_names: List<String>,
var class_to_idx: Map<String, Int>,
var idx_to_class: Map<String, String>
)
data class FoodCollectionBean(
var imageUri: Uri? = null,
var bitmap: Bitmap? = null,
var imageFile: File? = null,
var imageVector: FloatArray? = null,
var isShowCamera: Boolean = false,
var isFinish:Boolean = false,
var uploadSuccess:Boolean = false
)
data class ReceiptGoodsInfo(
var goodsId: String? = "",
var goodsName: String? = "",
var procurementUnit: String? = "",
var procurementCount: Int? = 0,
var procurementPrice: Double? = 0.0,
var procurementAmount: Double? = 0.0,
var receiptCount: Int? = 0,
var goodsWeight: Int? = 0,
var goodsState: String? = "",
var isSelected: Boolean = false,
var isLocalGoods: Boolean = false
)
//data class DropdownInfo(
// var id: String,
// var name: String
//)
//data class RecognizeResult(
// var name: String? = null,
// var image: String? = null,
// var isSelected: Boolean = false
//)
//data class GoodsSearchInfo(
// var id: String? = null,
// var name: String? = null,
// var isSelected: Boolean = false
//)
data class OperateBean(
var obj: Any? = null,
var typeList: MutableList<OperateType> = mutableListOf(),
var isSelected: Boolean = false,
var isLocalGoods: Boolean = false,
var isReceiptPage: Boolean = true
)
data class OperateType(
var value: String = "",
var width: Int = 0,
var textColor: String = "#FF999999",
var isBoldFont: Boolean = false,
var isShowIcon: Boolean = false
)
@@ -11,7 +11,8 @@ data class Food(
var name: String? = null,
var foodIdx: Int = 0,
@HnswIndex(dimensions = 512, distanceType = VectorDistanceType.DOT_PRODUCT)
var foodVector: FloatArray? = null
var foodVector: FloatArray? = null,
var version: String? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -23,6 +24,7 @@ data class Food(
if (foodIdx != other.foodIdx) return false
if (name != other.name) return false
if (!foodVector.contentEquals(other.foodVector)) return false
if (version != other.version) return false
return true
}
@@ -32,6 +34,8 @@ data class Food(
result = 31 * result + foodIdx
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (foodVector?.contentHashCode() ?: 0)
result = 31 * result + (version?.hashCode() ?: 0)
return result
}
}
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.objbox
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class FoodClassInfo(
var class_names: List<String>,
var class_to_idx: Map<String, Int>,
var idx_to_class: Map<String, String>
)
@@ -22,7 +22,7 @@ import java.io.InputStream
object FoodModule {
private lateinit var module_mobile: Module
private lateinit var module: Module
private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
@@ -37,8 +37,8 @@ object FoodModule {
suspend fun init(context: Context) {
withContext(Dispatchers.IO) {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
initDefFoodData(context)
module = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
// initDefFoodData(context)
}
}
@@ -51,6 +51,11 @@ object FoodModule {
// }
fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
// 模型未初始化时直接返回 null,避免 UninitializedPropertyAccessException
if (!::module.isInitialized) {
logInfo("bitmap2FloatArray: module 尚未初始化,跳过推理")
return null
}
var rgb565Bitmap: Bitmap? = null
try {
val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
@@ -64,7 +69,7 @@ object FoodModule {
NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB
NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB
)
val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor()
val outputTensor = module.forward(IValue.from(inputTensor)).toTensor()
return outputTensor.dataAsFloatArray
} catch (e: OutOfMemoryError) {
e.printStackTrace()
@@ -124,7 +129,12 @@ object FoodModule {
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
val arr = name.split("&&")
var rawMaterialsType = ""
if (arr.size > 1) {
rawMaterialsType = arr[1]
}
nameScoreList.add(IdNameScore(id = it.id, name = arr[0], score = it.score, rawMaterialsType = rawMaterialsType))
}
logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList
@@ -176,7 +186,8 @@ object FoodModule {
data class IdNameScore(
val id: Long,
var name: String,
val score: Double
val score: Double,
val rawMaterialsType: String
)
suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) {
@@ -213,6 +213,11 @@ object ObjectBox {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
}
// 清除所有向量采集数据
suspend fun removeAll() = safeDbOp {
getBox<Food>()?.removeAll()
}
/**
* 按名称分组统计采集数量
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
@@ -28,7 +28,6 @@ class MdnsDiscoveryManager(private val context: Context) {
/** 待解析队列,串行处理避免并发解析失败 */
private val resolveQueue = LinkedList<NsdServiceInfo>()
@Volatile
private var isResolving = false
/**
@@ -17,6 +17,9 @@ object ScaleDeviceConfig {
/** 18个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_18 = listOf(6, 5, 4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 18, 17, 16, 15, 14, 13)
/** 1个秤的地址列表 */
val SCALE_ORDER_1 = listOf(1)
/** 设备在列表中的显示顺序 */
val DEVICE_ORDER = listOf(DEVICE_ID_2, DEVICE_ID_22, DEVICE_ID_18, DEVICE_ID_1)
}
@@ -34,5 +34,7 @@ data class ScaleEvent(
const val TYPE_SEASONING_ADDED = "seasoning_added"
/** 调料槽位配置同步事件:主设备配置变更后广播给子设备 */
const val TYPE_SEASONING_CONFIG = "seasoning_config"
/** 清除数据事件:主设备通知所有子设备清除本机测试数据 */
const val TYPE_CLEAR_DATA = "clear_data"
}
}
@@ -9,6 +9,8 @@ import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -34,6 +36,9 @@ object ScaleServiceManager {
private const val TAG = "ScaleServiceManager"
@Volatile private var isStarted = false
private var serviceScope: CoroutineScope? = null
private var mdnsRegister: MdnsRegisterManager? = null
private var wsServer: ScaleWebSocketServer? = null
private var udpSender: UdpBroadcastSender? = null
@@ -71,6 +76,14 @@ object ScaleServiceManager {
get() = wsServer?.onSeasoningConfig
set(value) { wsServer?.onSeasoningConfig = value }
/**
* 收到主设备下发的清除数据事件时的回调,仅子设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
var onClearData: ((ScaleEvent) -> Unit)?
get() = wsServer?.onClearData
set(value) { wsServer?.onClearData = value }
/**
* 向所有已连接子设备广播调料配置(主设备调用)
* @param slots 全量槽位配置列表
@@ -79,6 +92,16 @@ object ScaleServiceManager {
wsClient?.sendToAllSlaves(buildConfigEvent(slots))
}
/**
* 通知所有已连接子设备清除本机数据(主设备调用)
* 子设备收到后执行与主设备相同的清除逻辑
*/
fun sendClearData() {
wsClient?.sendToAllSlaves(
ScaleEvent(type = ScaleEvent.TYPE_CLEAR_DATA, deviceId = GlobalData.deviceId)
)
}
/**
* 向单台刚连接的子设备推送调料配置(连接时按需调用)
* @param deviceId 目标子设备 ID
@@ -117,6 +140,9 @@ object ScaleServiceManager {
* 启动所有秤服务,在 Application.onCreate() 中调用
*/
fun start(context: Context) {
if (isStarted) return
isStarted = true
serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val deviceId = GlobalData.deviceId
val role = GlobalData.deviceRole
Log.d(TAG, "启动秤服务, deviceId=$deviceId, role=$role")
@@ -128,9 +154,8 @@ object ScaleServiceManager {
if (role == DeviceRole.MASTER) {
startMasterServices(context, deviceId)
} else {
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制
val localIp = getLocalIp(context)
udpSender = UdpBroadcastSender(deviceId, localIp).also { it.start() }
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制;每次广播时动态获取 IP,避免启动时网络未就绪导致 IP 为空
udpSender = UdpBroadcastSender(deviceId = deviceId, ipProvider = { getLocalIp(context) }).also { it.start() }
}
}
@@ -156,17 +181,21 @@ object ScaleServiceManager {
val client = ScaleWebSocketClient().also {
it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) }
it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) }
it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) }
it.onDeviceDisconnected = { remoteId ->
aggregator?.removeDevice(remoteId)
// 清除 UDP 缓存,确保子设备下次广播时能重新触发 onDeviceFound,立即重连而非等待指数退避
udpReceiver?.removeDevice(remoteId)
}
it.onDeviceConnected = { remoteId ->
// 子设备连接成功时,从 Room 读取全量配置并单独推送给该设备
CoroutineScope(Dispatchers.IO).launch {
val slots = BaseApp.instance!!.database.seasoningSlotDao()
.queryAll()
.map { slot ->
serviceScope?.launch {
val slots = BaseApp.instance?.database?.seasoningSlotDao()
?.queryAll()
?.map { slot ->
ScaleEvent.SlotConfig(
slot.deviceId, slot.address, slot.goodsId, slot.goodsName
)
}
} ?: return@launch
if (slots.isNotEmpty()) sendSeasoningConfigTo(remoteId, slots)
}
}
@@ -225,6 +254,9 @@ object ScaleServiceManager {
* 停止所有秤服务,在 Application.onTerminate() 或退出时调用
*/
fun stop() {
serviceScope?.cancel()
serviceScope = null
isStarted = false
mdnsDiscovery?.stopDiscovery()
udpReceiver?.stop()
udpSender?.stop()
@@ -43,6 +43,11 @@ class ScaleWebSocketClient {
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
/**
* 各设备的连接版本号,每次调用 connect() 时递增
* doConnect 执行前校验版本,版本不匹配说明已有更新的连接请求,直接放弃本次连接
*/
private val connectVersions = ConcurrentHashMap<String, Int>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
@@ -66,14 +71,24 @@ class ScaleWebSocketClient {
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
// 取消旧 IP 的重连任务,防止 IP 变化时新旧任务并发竞争
// 取消旧重连任务,防止 IP 变化时新旧任务并发竞争
reconnectTasks.remove(deviceId)?.cancel(false)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
// 递增版本号:doConnect 执行前会校验版本,旧任务版本不匹配时直接放弃
val version = (connectVersions[deviceId] ?: 0) + 1
connectVersions[deviceId] = version
doConnect(deviceId, host, port, version)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
val url = "ws://$host:$port"
private fun doConnect(deviceId: String, host: String, port: Int, version: Int = connectVersions[deviceId] ?: 0) {
// 版本校验:若当前版本已被更新的 connect() 调用覆盖,放弃本次连接
if (connectVersions[deviceId] != version) {
Log.d(TAG, "放弃过期连接任务: $deviceId, version=$version")
return
}
// IPv6 地址在 URL 中需用方括号包裹,否则 OkHttp 会将冒号后内容误解析为端口
val formattedHost = if (host.contains(':')) "[$host]" else host
val url = "ws://$formattedHost:$port"
val request = Request.Builder().url(url).build()
val ws = httpClient.newWebSocket(request, object : WebSocketListener() {
@@ -103,34 +118,46 @@ class ScaleWebSocketClient {
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
// 使用 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)
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
// 同上:只有移除的是自己,才触发断线回调和重连
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
broadcast(gson.toJson(data))
val lastPush = lastPushTime[address] ?: 0L
if (now - lastPush >= THROTTLE_MS) {
// 窗口已过:立即推送,并取消可能存在的延迟任务
lastPushTime[address] = now
pendingPushTasks.remove(address)?.cancel(false)
broadcast(gson.toJson(data))
} else if (pendingPushTasks[address] == null) {
// 窗口内首次触发:安排 trailing 推送,保证窗口末尾推最新值
val delay = THROTTLE_MS - (now - lastPush)
if (!scheduler.isShutdown) {
pendingPushTasks[address] = scheduler.schedule({
val latest = latestData[address] ?: return@schedule
lastPushTime[address] = System.currentTimeMillis()
pendingPushTasks.remove(address)
broadcast(gson.toJson(latest))
}, delay, TimeUnit.MILLISECONDS)
}
}
// 窗口内且已安排过 trailing 任务:仅更新 latestData,等 trailing 触发时推送
}
}
@@ -77,6 +113,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
WeightUtil.removeWeightListener(TAG)
onConnectionChanged = null
onSeasoningConfig = null
onClearData = null
scheduler.shutdownNow()
try {
server?.stop(1000)
} catch (e: Exception) {
@@ -85,6 +123,8 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
server = null
latestData.clear()
lastPushTime.clear()
pendingPushTasks.values.forEach { it.cancel(false) }
pendingPushTasks.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
@@ -117,23 +157,47 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
private inner class InternalServer(port: Int) :
WebSocketServer(InetSocketAddress(port)) {
/** 当前活跃连接计数,用于替代 connections.isNullOrEmpty() 避免竞态 */
private val connectionCount = AtomicInteger(0)
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
connectionCount.incrementAndGet()
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
} catch (e: Exception) {
Log.w(TAG, "初始推送失败: ${e.message}")
if (latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
} catch (e: Exception) {
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
} else {
// latestData 为空说明刚重启,WeightUtil 还未产生读数
// 延迟 3 秒后补推一次,确保主设备能看到本机秤
Log.d(TAG, "latestData 为空,${RETRY_PUSH_DELAY_MS}ms 后补推")
scheduler.schedule({
if (conn.isOpen && latestData.isNotEmpty()) {
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
Log.d(TAG, "补推数据: address=${data.address}, weight=${data.weight}")
} catch (e: Exception) {
Log.w(TAG, "补推失败: ${e.message}")
}
}
}
}, RETRY_PUSH_DELAY_MS, TimeUnit.MILLISECONDS)
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
Log.d(TAG, "主设备已断开: ${conn.remoteSocketAddress}, reason=$reason")
// 若已无任何连接,通知子设备 UI 更新为未连接状
if (server?.connections.isNullOrEmpty()) {
// 用计数器判断是否还有活跃连接,避免依赖 connections 集合的移除时机导致竞
if (connectionCount.decrementAndGet() <= 0) {
connectionCount.set(0)
onConnectionChanged?.invoke(false)
}
}
@@ -146,6 +210,7 @@ class ScaleWebSocketServer(private val deviceId: String, private val context: Co
val event = gson.fromJson(message, ScaleEvent::class.java)
when (event.type) {
ScaleEvent.TYPE_SEASONING_CONFIG -> onSeasoningConfig?.invoke(event)
ScaleEvent.TYPE_CLEAR_DATA -> onClearData?.invoke(event)
}
return
}
@@ -13,10 +13,12 @@ import java.util.concurrent.TimeUnit
* UDP 广播发送器(子设备运行)
* 每隔固定间隔向局域网广播本机的 deviceId、IP、WebSocket 端口
* 作为 mDNS 的兜底发现机制,确保主设备能可靠发现子设备
*
* @param ipProvider 动态获取本机 IP 的函数,每次广播时调用;返回空字符串时跳过本次广播
*/
class UdpBroadcastSender(
private val deviceId: String,
private val localIp: String,
private val ipProvider: () -> String,
private val wsPort: Int = MdnsRegisterManager.WS_PORT
) {
@@ -43,12 +45,18 @@ class UdpBroadcastSender(
task = scheduler.scheduleWithFixedDelay({
sendBroadcast()
}, 0, INTERVAL_SEC, TimeUnit.SECONDS)
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, ip=$localIp, port=$wsPort")
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, port=$wsPort")
}
private fun sendBroadcast() {
try {
val payload = gson.toJson(Payload(deviceId, localIp, wsPort))
val ip = ipProvider()
// 网络未就绪时跳过本次广播,等待下一个周期重试
if (ip.isEmpty()) {
Log.d(TAG, "IP 未就绪,跳过本次广播")
return
}
val payload = gson.toJson(Payload(deviceId, ip, wsPort))
val data = payload.toByteArray(Charsets.UTF_8)
DatagramSocket().use { socket ->
socket.broadcast = true
@@ -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()
}
/**
* 删除弹窗
*/
@@ -65,11 +84,11 @@ class CollectedFoodActivity : BaseActivity() {
appendText(" 删除后无法恢复,${separator}确认要删除吗?")
}
CommonDialog(this)
.setTitle("温馨提示")
.setTitle("删除提示")
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
appViewModel.removeCollectedFood(
dbViewModel.removeCollectedFood(
foodName = foodName,
nameFilter = binding.editSearch.text.toString()
)
@@ -84,17 +103,22 @@ class CollectedFoodActivity : BaseActivity() {
setContentView(binding.root)
setHeaderBackground()
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
}, titleAction = {
it.text = "已采集食材"
}, rightIconActon = {
it.gone()
}, rightIconAction = {
it.visible()
it.setPaddingRelative(25.dp ,0 ,25.dp ,0)
it.setImageResource(R.drawable.ic_trash_white)
it.setOnClickListener {
showClearAllDialog()
}
}, backAction = {
it.visible()
it.setOnClickListener {
@@ -108,7 +132,7 @@ class CollectedFoodActivity : BaseActivity() {
}
private fun loadCollectedFoodList(name: String = "") {
appViewModel.loadCollectedFoodList(nameFilter = name)
dbViewModel.loadCollectedFoodList(nameFilter = name)
}
/**
@@ -118,7 +142,7 @@ class CollectedFoodActivity : BaseActivity() {
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.collectedFoodListState.collect { items ->
dbViewModel.collectedFoodListState.collect { items ->
foodList.clear()
foodList.addAll(items)
adapter.notifyDataSetChanged()
@@ -156,7 +180,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
}
binding.editSearch.run {
addTextChangedListener {
@@ -169,7 +193,7 @@ class CollectedFoodActivity : BaseActivity() {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
hideKeyboard()
true
} else {
false
@@ -0,0 +1,210 @@
package com.shuwei.dish.match.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.R
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityCookingModeBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.ui.fragment.FoodListFragment
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
/**
* 制作模式
*/
class CookingModeActivity : BaseActivity() {
companion object {
const val IS_COOKING = "isCooking"
const val DINNER_TYPE = "dinnerType"
const val IS_CONFIG_PAGE = "isConfigPage"
}
private val binding by lazy { ActivityCookingModeBinding.inflate(layoutInflater) }
private var dinnerType: String = "1"
private val fragmentList = mutableListOf<FoodListFragment>().apply {
add(FoodListFragment.instance("1"))
add(FoodListFragment.instance("2"))
add(FoodListFragment.instance("3"))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setHeaderBackground()
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "选择菜品"
}, rightIconAction = {
it.visible()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
startActivity<SettingActivity>()
}
}, backAction = {
it.gone()
})
addViewListener()
loadFragment()
onBackPressedDispatcher.addCallback(this) {}
}
fun getCookFoodList() {
dbViewModel.getCookFoodList(cookMode = 0, dinnerType = dinnerType)
}
private var configFinished = false
fun judgeDeviceConfig(block: () -> Unit) {
// if (configFinished) {
// block()
// return
// }
// dbViewModel.loadSeasoning {
// if (it.isEmpty()) {
// showDeviceConfigDialog()
// return@loadSeasoning
// }
// configFinished = true
// block()
// }
block()
}
private fun showDeviceConfigDialog() {
CommonDialog(this)
.setTitle("配置提示")
.setContent("请先在设备配置页面进行调料设置")
.setNegativeButton("取消")
.setPositiveButton("去设置") { startActivity<SettingActivity>() }
.show()
}
/** 切换餐次时清空输入框,用此标志跳过 afterTextChanged 的查询触发 */
private var isClearingByTabSwitch = false
private fun addViewListener() {
binding.tvBreakfast.setOnClickListener { selectTab("1") }
binding.tvLunch.setOnClickListener { selectTab("2") }
binding.tvDinner.setOnClickListener { selectTab("3") }
binding.etInputDish.addOnActionSearchListener {
jumpSearch()
}
binding.ivDishSearch.setOnClickListener {
jumpSearch()
}
// 输入框清空时自动查询全部数据(切换餐次触发的清空除外,由 loadFragment 负责刷新)
binding.etInputDish.addTextChangedListener(afterTextChanged = { text ->
if (!isClearingByTabSwitch && text.isNullOrBlank()) {
currentFragment()?.getDishList(pageNo = 1, dinnerType = dinnerType)
}
})
}
/**
* 切换餐次 Tab:更新选中态、清空搜索框、切换 Fragment
* 由点击事件调用,不触发额外的 getDishList(),由 onHiddenChanged 负责刷新
*/
private fun selectTab(type: String) {
if (dinnerType == type) return
dinnerType = type
binding.tvBreakfast.isChecked = type == "1"
binding.tvLunch.isChecked = type == "2"
binding.tvDinner.isChecked = type == "3"
clearSearchText()
loadFragment()
}
/** 获取当前显示的 FoodListFragment */
private fun currentFragment(): FoodListFragment? {
return runCatching { fragmentList[dinnerType.toInt() - 1] }.getOrNull()
}
private fun jumpSearch() {
val searchText = binding.etInputDish.text.toString().trim()
if (searchText.isBlank()) {
toast(binding.etInputDish.hint.toString())
return
}
currentFragment()?.getDishList(pageNo = 1, dinnerType = dinnerType, foodName = searchText)
hideKeyboard()
}
private fun loadFragment() {
runCatching {
val target = fragmentList[dinnerType.toInt() - 1]
supportFragmentManager.beginTransaction().apply {
fragmentList.forEach { fragment ->
if (fragment.isAdded) {
if (fragment == target) show(fragment) else hide(fragment)
} else {
if (fragment == target) add(R.id.flSubPage, fragment)
}
}
commit()
}
}.onFailure {
it.printStackTrace()
}
}
fun deleteCookFoodAndGoods(foodId: String, dinnerType: String, action: () -> Unit) {
lifecycleScope.launch {
dbViewModel.deleteCookFoodAndGoods(cookMode = 0, foodId = foodId, dinnerType = dinnerType)
action()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
runCatching {
val isConfigPage = intent.getBooleanExtra(IS_CONFIG_PAGE, false)
if (isConfigPage) {
return
}
val lastDinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0"
if (lastDinnerType !in listOf("1", "2", "3")) return
if (lastDinnerType == dinnerType) {
// 同一 TabonHiddenChanged 不会触发,直接刷新
clearSearchText()
currentFragment()?.getDishList(1, lastDinnerType)
} else {
// 不同 TabselectTab → loadFragment → show → onHiddenChanged 负责刷新,无需直接调用
selectTab(lastDinnerType)
}
}.onFailure {
it.printStackTrace()
}
}
private var isVisible = false
override fun onResume() {
super.onResume()
isVisible = true
}
override fun onPause() {
super.onPause()
isVisible = false
}
fun clearSearchText() {
isClearingByTabSwitch = true
binding.etInputDish.text?.clear()
isClearingByTabSwitch = false
}
}
@@ -77,7 +77,7 @@
// it.visible()
// }, titleAction = {
// it.text = "设备配置"
// }, rightIconActon = {
// }, rightIconAction = {
// it.gone()
//// it.alpha = 0.0F
//// it.setImageResource(R.drawable.ic_setting)
@@ -1,291 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityDishSamplingBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.SwipeCallback
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable
class DishSamplingActivity : BaseActivity() {
private lateinit var binding: ActivityDishSamplingBinding
var foodName: String? = null
private var foodId: String? = null
companion object {
private const val TAG = "DishSamplingActivity"
public const val FOOD_NAME = "foodName"
public const val PAGE_FROM = "pageFrom"
public const val HOME = "home"
}
private var pageFrom: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDishSamplingBinding.inflate(layoutInflater)
setContentView(binding.root)
pageFrom = intent.getStringExtra(PAGE_FROM)
setTitleBar()
setHeaderBackground()
addViewClickListener()
foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName)
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
//Log.d(TAG, "addViewClickListener: address=$address,state=$state,weight=$weight")
if (address == AddressUtil.ONE) {
tempDishEntity?.useWeight = weight.toDouble()
binding.tvDishPartWeight.text = "${weight}"
}
})
addBackKeyListener()
}
private fun setTitleBar() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "菜品采集"
}, rightIconActon = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
//startActivity<DeviceSettingActivity>()
}
}, backAction = {
it.visible()
it.setOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
})
}
fun getCookFoodList() {
appViewModel.getCookFoodList(cookMode = 1)
}
/**
* 监听返回键
*/
fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) {
if (list.isEmpty().not()) {
//说明有新增的食材
saveDataRemindDialog()
return@addCallback
}
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
onBackPressedDispatcher.onBackPressed()
}
}
private fun saveDataRemindDialog() {
CommonDialog(this)
.setTitle("温馨提示")
.setContent("您好,当前页面存在未保存的数据,确认返回吗?")
.setNegativeButton("取消")
.setPositiveButton("确认") {
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
finish()
}
.setOnDismissCallback { hideStatusBar() }
.show()
}
val list: MutableList<CookFoodGoodsEntity> = mutableListOf()
private var tempDishEntity: CookFoodGoodsEntity? = null
private var dishMaterialType = 1
private val dishAdapter by lazy {
DishPartAdapter(list).apply {
addOnItemChildClickListener(R.id.ivOperateIcon) { _, _, position ->
//删除操作
list.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, list.size - position)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun addViewClickListener() {
binding.rvDishPartList.run {
layoutManager =
LinearLayoutManager(this@DishSamplingActivity, LinearLayoutManager.VERTICAL, false)
adapter = dishAdapter
val itemTouchHelper = ItemTouchHelper(SwipeCallback(dishAdapter) { position ->
list.removeAt(position)
dishAdapter.notifyItemRemoved(position)
dishAdapter.notifyItemRangeChanged(position, list.size - position)
toast("已删除")
})
itemTouchHelper.attachToRecyclerView(this)
}
binding.etInputDishType.addOnActionSearchListener {
searchDishType()
}
binding.ivDishTypeSearch.setOnClickListener {
searchDishType()
}
binding.ivWeightClear.setOnClickListener {
WeightUtil.tareTwo(AddressUtil.ONE)
}
binding.ivWeightAdd.setOnClickListener {
if (tempDishEntity == null) {
toast("请输入食材名称")
return@setOnClickListener
}
if (binding.rgSampling.checkedRadioButtonId == -1) {
toast("请选择主辅材类型")
return@setOnClickListener
}
val realWeight = tempDishEntity!!.useWeight ?: 0.0
if (realWeight <= 0.toDouble()) {
toast("食材用量需要大于0")
return@setOnClickListener
}
if (tempDishEntity?.goodsName != binding.etInputDishType.text.toString().trim()) {
toast("所选择的食材名称与输入框内容不一致")
return@setOnClickListener
}
tempDishEntity!!.apply {
isSamplingPage = true
materialType = dishMaterialType
}
val result = list.firstOrNull { it.goodsId == tempDishEntity!!.goodsId }
if (result != null) {
toast("${result.goodsName}食材已添加,请勿重复操作")
return@setOnClickListener
}
list.add(tempDishEntity!!)
dishAdapter.notifyDataSetChanged()
tempDishEntity = null
binding.etInputDishType.setText("")
binding.rgSampling.clearCheck()
}
binding.rgSampling.setOnCheckedChangeListener { group, checkedId ->
dishMaterialType = if (checkedId == R.id.rbDishTypeFirst) 1 else 2
}
binding.ivDishSearch.setOnClickListener { v ->
jumpSearch(v)
}
binding.btnCook.clickWithDebounce {
val showFoodName = binding.etInputDish.text.toString().trim()
if (showFoodName.isBlank()) {
toast("菜品名称为空")
return@clickWithDebounce
}
if (list.isEmpty()) {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val food = FoodRecord()
binding.etInputDish.let {
if (it.tag != null && it.tag.toString() == it.text.toString()) {
food.foodId = foodId
}
}
food.foodName = showFoodName
food.cookMode = 1
Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
// startActivity<SubmitDishActivity> {
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, list as java.io.Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
}
}
binding.etInputDish.let { v ->
v.addOnActionSearchListener {
jumpSearch(v)
}
}
}
private fun jumpSearch(v: View) {
val intent = Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, binding.etInputDish.text.toString().trim())
}
startActivity(intent) {
foodId = it?.getStringExtra(FoodSearchActivity.FOOD_ID)
foodName = it?.getStringExtra(FoodSearchActivity.FOOD_NAME)
binding.etInputDish.run {
setText(foodName)
tag = foodName
setSelection(text.length)
}
}
KeyboardUtil.hideKeyboard(v)
}
private fun searchDishType() {
val inputText = binding.etInputDishType.text.toString().trim()
FoodSearchDialog(
activity = this@DishSamplingActivity,
defGoodsName = inputText
) { item ->
val filterResult = list.firstOrNull { it.goodsName == item.goodsName }
if (filterResult != null) {
toast("不允许重复添加同一食材")
return@FoodSearchDialog
}
binding.etInputDishType.run {
setText(item.goodsName)
setSelection(length())
}
tempDishEntity = CookFoodGoodsEntity().apply {
goodsId = item.goodsId
goodsName = item.goodsName
relateionType = item.relateionType ?: 0
goodsOrRelationCode = item.goodsOrRelationCode
isNewDishType = true
// TODO: 待定
//allEdible = item.allEdible
}
}.show()
}
}
@@ -6,17 +6,29 @@ import android.os.Bundle
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.recyclerview.widget.GridLayoutManager
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
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.CookFoodGoodsEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.net.toUri
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.objbox.FoodModule
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.showRawMaterialsDialog
import kotlinx.coroutines.launch
/**
* 食材识别结果页面
@@ -28,30 +40,32 @@ class FoodRecognizeActivity : BaseActivity() {
private const val TAG = "FoodRecognizeActivity"
private const val EXTRA_IMAGE_URI = "extra_image_uri"
private const val EXTRA_CURRENT_WEIGHT = "extra_current_weight"
private const val EXTRA_INITIAL_WEIGHT = "extra_initial_weight"
const val EXTRA_SELECTED_ITEM = "extra_selected_item"
const val IS_MANUAL_CANCEL = "isManualCancel"
/** 内存缓存食材列表,避免通过 Intent 序列化传递大数据 */
private var pendingGoodsList: ArrayList<CookFoodGoodsEntity>? = null
private var recognizeNameList: ArrayList<FoodModule.IdNameScore>? = null
/**
* 启动本页面,通过 onResult 回调返回用户选中的食材
* @param activity 宿主 Activity
* @param imageUri 食材照片路径(可为空)
* @param goodsList 识别到的食材列表
* @param nameScoreList 识别到的食材列表
* @param finishCallback 页面关闭后回调,携带用户选中的食材(未选中时为 null)
*/
fun start(
activity: BaseActivity,
imageUri: String? = null,
currentWeight: Double = 0.0,
goodsList: ArrayList<CookFoodGoodsEntity> = arrayListOf(),
finishCallback: (Boolean, CookFoodGoodsEntity?) -> Unit = { _, _ -> }
initialWeight: Double = 0.0,
nameScoreList: ArrayList<FoodModule.IdNameScore> = arrayListOf(),
finishCallback: (Boolean, GoodsItem?) -> Unit = { _, _ -> }
) {
pendingGoodsList = goodsList
recognizeNameList = nameScoreList
val launchIntent = Intent(activity, FoodRecognizeActivity::class.java).apply {
putExtra(EXTRA_IMAGE_URI, imageUri)
putExtra(EXTRA_CURRENT_WEIGHT, currentWeight)
putExtra(EXTRA_INITIAL_WEIGHT, initialWeight)
}
activity.startActivity(launchIntent) { resultIntent ->
val isManualCancel = resultIntent?.getBooleanExtra(IS_MANUAL_CANCEL, false) ?: false
@@ -59,7 +73,8 @@ class FoodRecognizeActivity : BaseActivity() {
IntentCompat.getSerializableExtra(
it,
EXTRA_SELECTED_ITEM,
CookFoodGoodsEntity::class.java)
GoodsItem::class.java
)
}
finishCallback(isManualCancel, entity)
}
@@ -79,28 +94,72 @@ class FoodRecognizeActivity : BaseActivity() {
private lateinit var binding: ActivityFoodRecognizeBinding
private val list = mutableListOf<CookFoodGoodsEntity>()
private val list = mutableListOf<GoodsItem>()
/** 当前选中的列表位置,-1 表示无选中 */
private var selectedPosition = -1
/** 秤1当前实时重量 */
/** 秤1当前实时净重(已减去初始读数) */
private var currentWeight = 0.0
/** 重量减少时延迟更新的任务,页面关闭前取消可防止用错误重量 */
private var pendingWeightRunnable: Runnable? = null
/** 初始秤读数,净重 = 秤读数 - initialWeight */
private var initialWeight = 0.0
/** 当前待延迟更新的目标重量,避免相同重量重复重置计时器 */
private var pendingWeight = 0.0
/**
* 滑动窗口:保存最近 10 条稳定帧净重,用于关闭页面时判断数据是否稳定
* 超过 10 条时移除队头,保持最新数据
*/
private val weightWindow = ArrayDeque<Double>(10)
private val adapter = GoodsInfoSearchAdapter(list).apply {
/** 右侧自定义滚动指示条最小高度 */
private val scrollIndicatorMinHeightPx by lazy { (24 * resources.displayMetrics.density).toInt() }
/** 食材搜索弹窗,持有引用防止重复打开 */
private var foodSearchDialog: FoodSearchDialog? = null
/** 数据不稳定提示弹窗,持有引用通过 isShowing 防止重复弹出 */
private var unstableDialog: CommonDialog? = null
/** 通过name查询IdNameScore */
private val nameScoreMap = mutableMapOf<String, FoodModule.IdNameScore>()
/** 识别物品名称列表(名称 + 净材种类,用于批量查询接口) */
private val goodsNameList = mutableListOf<GoodsNameQueryDTO>()
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateScrollIndicator()
}
}
private val adapter = 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 ?: "-"
}
}
@@ -121,14 +180,15 @@ class FoodRecognizeActivity : BaseActivity() {
}
}
)
initData()
initRecyclerView()
updateRvParentLayoutHeight()
initClickListeners()
onBackPressedDispatcher.addCallback(this) {
manualBack()
}
initObserver()
initData()
}
/**
@@ -140,62 +200,202 @@ 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"
// 取出内存缓存的食材列表,取用后立即清空
val goodsList = pendingGoodsList?.also { pendingGoodsList = null }
if (!goodsList.isNullOrEmpty()) {
list.addAll(goodsList)
// 默认选中第一项
selectedPosition = 0
list[0].isClicked = true
list[0].useWeight = currentWeight
binding.tvSelectedFood.text = list[0].goodsName ?: "-"
recognizeNameList?.apply {
forEach {
val goodsName = it.name.split("WP").first()
val rawMaterialsType = it.rawMaterialsType
goodsNameList.add(GoodsNameQueryDTO(goodsName, rawMaterialsType))
nameScoreMap.put(goodsName, it)
}
recognizeNameList = null
}
if (goodsNameList.isNotEmpty()) {
netViewModel.queryGoodsList(goodsType = "0", goodsNames = goodsNameList)
} else {
loadRecognizeList(emptyList())
}
}
private fun buildTestData(item: FoodModule.IdNameScore): GoodsItem {
var foodName = ""
var foodId = ""
item.name.split("WP").let {
foodName = it[0]
foodId = if (it.size > 1) {
"WP${it[1]}"
} else {
item.name
}
}
return GoodsItem(
goodsId = foodId,
goodsName = foodName
).also {
it.foodScore = ((1 - item.score) * 10000).toInt()
it.isOriginalData = false
}
}
private fun initObserver() {
lifecycleScope.launch {
netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Loading -> {
showLoading()
}
is UiState.Success -> {
delayDismissLoading()
loadRecognizeList(state.data)
}
is UiState.Error -> {
delayDismissLoading()
toast(state.msg)
}
is UiState.Idle -> {}
}
}
}
}
private fun loadRecognizeList(goodsList: List<GoodsItem>?) {
if (goodsList.isNullOrEmpty()) {
binding.flListContainer.updateLayoutParams {
height = 260.dp
}
binding.rvRecognizeList.gone()
binding.tvNoData.visible()
return
}
binding.rvRecognizeList.visible()
binding.tvNoData.gone()
list.clear()
goodsList.forEach { goods ->
var foodScore = -1
nameScoreMap[goods.goodsName]?.let { food ->
foodScore = ((1 - food.score) * 10000).toInt()
}
//保存原goodsId
goods.oldGoodsId = goods.goodsId
//PrepareFoodActivity中的goodsId就是materId
goods.goodsId = goods.materId ?: ""
list.add(goods.also { it.foodScore = foodScore })
}
adapter.notifyDataSetChanged()
updateRvParentLayoutHeight()
// 默认选中第一项
selectedPosition = 0
list[0].isClicked = true
list[0].useWeight = currentWeight
binding.tvSelectedFood.text = list[0].goodsName ?: "-"
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.recyclerView.run {
layoutManager = GridLayoutManager(this@FoodRecognizeActivity, 2)
adapter = this@FoodRecognizeActivity.adapter
binding.rvRecognizeList.let {
it.layoutManager = GridLayoutManager(this@FoodRecognizeActivity, 2)
it.adapter = adapter
it.isVerticalScrollBarEnabled = false
it.removeOnScrollListener(scrollListener)
it.addOnScrollListener(scrollListener)
it.post { updateScrollIndicator() }
}
updateListVisibility()
}
/**
* 更新RecyclerView父布局高度
*/
private fun updateRvParentLayoutHeight() {
binding.flListContainer.updateLayoutParams {
height = if (adapter.items.size > 2) 260.dp else 130.dp
}
}
/**
* 根据列表是否有数据控制 RecyclerView 显隐
*/
private fun updateListVisibility() {
if (list.isEmpty()) {
binding.recyclerView.gone()
} else {
binding.recyclerView.visible()
if (list.isNotEmpty()) {
binding.rvRecognizeList.post { updateScrollIndicator() }
}
}
/**
* 根据 RecyclerView 滚动状态刷新右侧自定义指示条
*/
private fun updateScrollIndicator() {
val recyclerView = binding.rvRecognizeList
val track = binding.vScrollIndicatorTrack
val thumb = binding.vScrollIndicatorThumb
if (!recyclerView.canScrollVertically(1) && !recyclerView.canScrollVertically(-1)) {
track.gone()
thumb.gone()
return
}
val range = recyclerView.computeVerticalScrollRange()
val extent = recyclerView.computeVerticalScrollExtent()
val offset = recyclerView.computeVerticalScrollOffset()
if (range <= 0 || extent <= 0 || range <= extent) {
track.gone()
thumb.gone()
return
}
val trackHeight = track.height.takeIf { it > 0 } ?: recyclerView.height
if (trackHeight <= 0) return
val thumbHeight = ((extent.toFloat() / range) * trackHeight)
.toInt()
.coerceAtLeast(scrollIndicatorMinHeightPx)
.coerceAtMost(trackHeight)
val maxTop = (trackHeight - thumbHeight).coerceAtLeast(0)
val scrollable = (range - extent).coerceAtLeast(1)
val thumbTop = ((offset.toFloat() / scrollable) * maxTop).toInt().coerceIn(0, maxTop)
if (thumb.layoutParams.height != thumbHeight) {
thumb.layoutParams = thumb.layoutParams.apply { height = thumbHeight }
}
thumb.translationY = thumbTop.toFloat()
track.visible()
thumb.visible()
}
/**
* 初始化按钮点击事件
*/
private fun initClickListeners() {
// 更换食材:打开食材搜索弹窗
// 更换食材:打开食材搜索弹窗,已打开时跳过
binding.btnChangeFood.clickWithDebounce {
val currentName = list.getOrNull(selectedPosition)?.goodsName
FoodSearchDialog(
// val currentName = list.getOrNull(selectedPosition)?.goodsName
if (foodSearchDialog?.isShowing == true) return@clickWithDebounce
foodSearchDialog = FoodSearchDialog(
activity = this,
defGoodsName = currentName,
// defGoodsName = currentName,
onItemSelected = { item -> updateSelectedFood(item) }
).show()
).also { it.show() }
}
// 取消:关闭页面
@@ -206,7 +406,7 @@ class FoodRecognizeActivity : BaseActivity() {
* 用新食材替换当前选中项(或追加到列表首位)
*/
@SuppressLint("NotifyDataSetChanged")
private fun updateSelectedFood(item: CookFoodGoodsEntity) {
private fun updateSelectedFood(item: GoodsItem) {
val existIndex = list.indexOfFirst { it.goodsId == item.goodsId }
if (existIndex >= 0) {
list.forEachIndexed { i, e -> e.isClicked = (i == existIndex) }
@@ -217,8 +417,14 @@ class FoodRecognizeActivity : BaseActivity() {
selectedPosition = 0
}
adapter.notifyDataSetChanged()
if (list.isNotEmpty()) {
binding.rvRecognizeList.visible()
binding.tvNoData.gone()
}
updateRvParentLayoutHeight()
binding.tvSelectedFood.text = item.goodsName ?: "-"
updateListVisibility()
binding.rvRecognizeList.post { updateScrollIndicator() }
}
/**
@@ -227,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)
@@ -287,7 +522,6 @@ class FoodRecognizeActivity : BaseActivity() {
override fun onDestroy() {
super.onDestroy()
cancelPendingWeightUpdate()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -9,14 +9,15 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodRecordAdapter
import com.shuwei.dish.match.adapter.Food2Adapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.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,12 +25,16 @@ 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
/**
* 菜品搜索页面
*/
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"
@@ -38,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 {
FoodRecordAdapter(list).apply {
Food2Adapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { adapter, view, position ->
if (pageType == 1) {
val jumpItem = list[position].also { it.dinnerType = dinnerType }
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.FOOD_ITEM, jumpItem as Serializable)
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()
}
@@ -66,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)
@@ -75,7 +78,7 @@ class FoodSearchActivity : BaseActivity() {
it.visible()
}, titleAction = {
it.text = "菜品搜索"
}, rightIconActon = {
}, rightIconAction = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
@@ -119,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)
@@ -136,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,12 +10,16 @@ import com.shuwei.dish.match.adapter.HomeModeAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityHomeBinding
import com.shuwei.dish.match.entity.HomeModeBean
import com.shuwei.dish.match.model.HomeModeBean
import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
import kotlin.collections.getOrNull
/**
* 主页,包括三种模式:制作模式、采样模式、品控模式
*/
class HomeActivity : BaseActivity() {
companion object {
@@ -39,12 +43,14 @@ class HomeActivity : BaseActivity() {
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground(isHomePage = true)
// 根据当前 canteenId 恢复 RadioButton 选中状态
binding.radioGroup.check(
if (BaseApp.canteenId == "1678234139391512577") R.id.radioSw else R.id.radioZk
)
binding.radioGroup.setOnCheckedChangeListener { group, checkedId ->
if (checkedId == R.id.radioSw) {
BaseApp.canteenId = "1678234139391512577"
} else {
BaseApp.canteenId = "0"
}
val id = if (checkedId == R.id.radioSw) "1678234139391512577" else "0"
SpTool.canteenId = id
BaseApp.canteenId = id
}
onBackPressedDispatcher.addCallback(this) {}
// binding.radioGroup.check(R.id.radioZk)
@@ -60,18 +66,18 @@ class HomeActivity : BaseActivity() {
// startActivity<SamplingListActivity>()
// }
// appViewModel.deleteSeasoningBySort(12)
// appViewModel.deleteSeasoningBySort(11)
// appViewModel.deleteSeasoningBySort(13)
// appViewModel.deleteSeasoningBySort(14)
// appViewModel.deleteCookFoodAndGoods(0,"") {}
// appViewModel.getCookFoodGoodsList("") {goodsList->
// dbViewModel.deleteSeasoningBySort(12)
// dbViewModel.deleteSeasoningBySort(11)
// dbViewModel.deleteSeasoningBySort(13)
// dbViewModel.deleteSeasoningBySort(14)
// dbViewModel.deleteCookFoodAndGoods(0,"") {}
// dbViewModel.getCookFoodGoodsList("") {goodsList->
// goodsList.forEach {
// it.foodId = "1594990392084779010"
// appViewModel.updateGoods(it)
// dbViewModel.updateGoods(it)
// }
// }
// appViewModel.clearAllSeasoning(){}
// dbViewModel.clearAllSeasoning(){}
// binding.btnDeviceConfig.setOnClickListener {
@@ -92,7 +98,7 @@ class HomeActivity : BaseActivity() {
private val modeAdapter by lazy {
HomeModeAdapter(modeList).apply {
addOnItemChildClickListener(R.id.layoutCard) { adapter, view, position ->
modeList[position].onClick?.invoke()
modeList.getOrNull(position)?.onClick?.invoke()
}
}
}
@@ -105,7 +111,7 @@ class HomeActivity : BaseActivity() {
modeIconId = R.drawable.ic_mode_cook,
onClick = {
SpTool.cookMode = 0
startActivity<SelectDishActivity>()
startActivity<CookingModeActivity>()
finish()
})
)
@@ -135,23 +141,23 @@ class HomeActivity : BaseActivity() {
private fun goSampling() {
lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot()
val slots = dbViewModel.loadSeasoningSlot()
if (slots.isEmpty()) {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>()
startActivity<SamplingModeActivity>()
finish()
return@launch
}
val count = appViewModel.countCookFood(cookMode = 1)
val count = dbViewModel.countCookFood(cookMode = 1)
if (count > 0) {
// 有烹饪中数据,打开采样历史列表页面
startActivity<SamplingListActivity>()
startActivity<SamplingModeActivity>()
finish()
return@launch
}
// 无烹饪数据,打开新增采样页面
startActivity<PrepareCookActivity> {
putExtra(PrepareCookActivity.PAGE_FROM, PrepareCookActivity.HOME)
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
}
finish()
}
@@ -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> {}
}
}
}

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