91 Commits
Author SHA1 Message Date
mazengfei 8e8ab6936c fix(net): 优化 HttpException 错误消息解析逻辑
- 添加从 HttpException 错误响应体解析后台业务 msg 的扩展方法
- 优先使用后台返回的业务 msg,失败时回退到 HTTP 状态短语
- 处理解析过程中可能出现的异常,确保安全降级
- 添加相关注释说明业务错误消息提取的设计意图
2026-07-28 19:45:00 +08:00
mazengfei 23d211bfdb fix(shelf): 实现负漂移抑制以提高重量显示准确性
- 在ShelfV3Activity中针对-5克以内的负读数做归零处理,避免误导显示
- 修改获取重量逻辑,保证显示重量与真实重量区分
- 在ShelfV3Adapter中对空秤零点附近的小负重量做归零处理
- 优化重量单位显示逻辑,根据负漂移抑制后的值显示克或千克
- 更新local.properties中SDK路径配置
2026-07-13 18:26:16 +08:00
mazengfei 7ab8e09a46 refactor(scale): 优化串口数据处理逻辑和自动应答机制
- 在串口数据读取中增加帧拼装与解析,统一自动应答发送
- 实现心跳日志降频,减少重复日志,避免主线程无效计算
- 将数据回调分派至主线程,确保线程安全
- 缓存时间格式化器,避免重复创建对象
- 增加秤重数据变更判断,避免无变化时刷新UI和日志
- 异步优化文件日志写入,使用协程通道和单线程写入,避免多线程SimpleDateFormat竞争
- 修复文件路径和上下文使用,防止Activity内存泄漏
- 精简部分注释和代码未使用段落,提高代码清晰度
2026-07-03 14:57:25 +08:00
mazengfei 1e44cd2765 fix(scale): 优化秤数据同步及格口显示顺序
- 增加秤号到列表索引的映射,提升心跳回调数据定位效率
- 调整重量数据处理逻辑,减少索引查找开销
- 按设备物理布局重排格口列表,解决多列设备显示错乱问题
- 定义同步间隔和首次同步延迟常量,统一定时任务配置
- 调整重量上报心跳间隔为1秒,提升秤数据实时性
- 清空列表时同步清理秤号映射,防止脏数据影响展示
- 优化空格口清理逻辑,改用映射位置快速更新对应项
2026-07-02 17:58:46 +08:00
lvmeng 3757b851be refactor(home): 移除旧版首页和货架相关界面及代码
- 删除 activity_home.xml 布局文件
- 删除 activity_shelf.xml 布局文件
- 删除 activity_shelf_v2.xml 布局文件
- 从 AndroidManifest.xml 中移除 HomeActivity 和 ShelfActivity 等旧页面声明
- 删除 ApiService.kt 网络接口文件
- 删除 ApiServiceV2.kt 网络接口文件
- 删除 CabinetInitResult.kt 数据模型文件
- 删除 CleanPackageAdapter.kt 适配器文件
- 删除 CleanPackageOption.kt 数据模型文件
- 删除 Constant.kt 常量定义文件
- 删除 DeviceConfigInfo.kt 数据模型文件
- 删除 DrawableExt.kt 扩展工具文件
- 删除 F2C.kt 温度转换工具文件
- 删除 GoodsModel.kt 数据模型文件
- 删除 GoodsRecord.kt 数据模型文件
- 删除 GridLayoutTool.kt 布局工具文件
- 删除 HexUtils.java 十六进制工具类
2026-06-30 11:27:29 +08:00
lvmeng 947afd06e8 fix(dialog): 修复Chip选择对话框的标题显示和布局问题
- 修改对话框确认按钮提示文本,使用动态标题变量替代固定文本
- 移除手动设置Chip的LayoutParams,改为直接使用addView方法
- 设置ChipGroup为多行显示以支持更多选项
- 调整布局文件中ChipGroup的宽度为wrap_content并居中对齐
- 添加水平和垂直间距属性以改善Chip排列效果
- 修正ShelfV3Activity中传递给对话框的标题文本格式
2026-06-26 16:43:45 +08:00
lvmeng 5412f0b6b9 feat(shelf): 更新货架界面和菜品分类功能
- 重命名tvFoodType为tvVegTypeName以统一命名规范
- 新增bg_pressed.xml选择器用于按钮按下效果
- 在ChipSelectDialog中添加必选验证和改进布局参数
- 扩展HomeV3Activity传递菜品名称和蔬菜类型信息
- 调整list_item_shelf_v3.xml布局尺寸和权重配置
- 新增tvFoodType显示菜品分类并调整字体间距
- 更新ShelfV3Activity处理菜品分类数据传递和显示
- 修改SlotModel数据结构支持蔬菜类型ID和名称字段
- 优化适配器中菜品分类文本显示逻辑和颜色设置
2026-06-26 16:24:53 +08:00
lvmeng 0e0c2fe47b feat(shelf): 添加净菜毛菜搜索和分类选择功能
- 新增 tvFoodType TextView 显示食品分类
- 将搜索框可见性从 gone 改为 visible
- 添加 FoodItem 和 FoodType 数据模型
- 添加 SearchFoodRequest 请求模型
- 在 ApiServiceV3 中新增 searchFood 接口
- 在 NetViewModelV3 中新增 searchFood 方法
- 修改 PutSlotRequest 模型字段为可空类型并添加 vegTypeId
- 更新 HomeV3Activity 中行列计算逻辑
- 缩短商品数据同步间隔时间
- 修改 InitActivity 中的基础URL配置
- 将 PendingInboundAdapter 适配器改为使用 FoodItem 类型
- 在 ShelfV3Activity 中实现净菜分类选择对话框功能
- 添加 ChipSelectDialog 自定义对话框组件
- 添加 chip 相关的颜色资源文件
- 实现搜索功能替换原有的待入库查询逻辑
2026-06-26 14:57:07 +08:00
lvmengandClaude Opus 4.7 84634f843d refactor(home): 用FlexboxLayoutManager替换GridLayoutManager,支持动态行列布局
FlexboxLayoutManager(COLUMN+WRAP)天然列优先排列,数据无需重排。
item宽高在拿到接口行列数后直接计算,适配任意rows×cols配置。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 09:17:06 +08:00
lvmeng 419e08c0e0 fix(shelf): 解决货架模型重量数据同步问题
- 在HomeV3Activity中添加重量值赋值逻辑确保模型数据完整
- 临时注释PendingInboundItem中的重量赋值避免数据冲突
- 修复货架适配器数据更新时重量信息丢失的问题
2026-06-25 14:48:04 +08:00
lvmeng 70f4146509 fix(init): 移除测试设备号硬编码
- 注释掉测试用的固定设备号赋值代码
- 确保使用实际的设备唯一标识符获取逻辑
- 保留设备号获取的日志记录功能
2026-06-25 14:10:58 +08:00
lvmeng 45bd586e7a refactor(model): 更新PendingInboundItem数据模型字段类型
- 将vegTypeId从Int类型改为Long?可空类型,默认值为null
- 将vegTypeName从String类型改为String?可空类型,默认值为null
- 将spec从String类型改为String?可空类型,默认值为null
- 所有修改的字段都设置了默认值以保持向后兼容性
2026-06-25 11:31:14 +08:00
lvmeng a78eaf7233 feat(app): 集成V3货柜管理功能
- 将应用包名从com.shuwei.intelligent.shelves迁移至com.sw.scalefusion.shelf
- 更新AndroidManifest.xml中的Activity声明路径以匹配新包名
- 在InitActivity中同步更新包名引用及依赖导入
- 修改GridLayoutTool工具类适配V3货柜布局排序逻辑
- 新增ApiResponse和BaseRepository统一网络响应处理
- 添加V3 API服务定义支持毛菜柜/净菜柜功能
- 新增HomeV3Activity和ShelfV3Activity实现新版货柜界面
- 集成API文档定义的货柜初始化、入柜、同步重量等核心功能
- 添加SlotModel等数据模型支持V3货柜操作
- 更新ProtocolConstants增加设备类型映射配置
- 重构网络管理层实现V2/V3接口实例分离管理
2026-06-24 17:39:44 +08:00
mazengfei 8aaba07cb6 perf(home): 调整货架商品同步到服务器的时间间隔
- 将定时任务的执行间隔从5秒调整为2分钟
- 保持初始延迟时间不变(10秒)
- 优化服务器同步频率以减少资源消耗
2026-06-04 18:10:17 +08:00
mazengfei 4f1a9dd0a1 refactor(home): 重构货架重量数据模型和显示逻辑
- 将实际重量计算统一到weight字段,移除actualQty和totalWeightG字段
- 修改HomeV2Activity中的重量数据处理逻辑,统一转换为千克单位
- 更新ShelfV2Activity中重量显示格式,优化单位转换逻辑
- 调整ShelfV2Adapter中重量显示逻辑,统一使用weight字段进行展示
- 在日志记录中增加温度信息输出
- 移除废弃的条件判断逻辑,简化代码结构
2026-06-04 17:28:23 +08:00
lvmeng 694e20023a refactor(layout): 优化网格布局工具和货架界面排序逻辑
- 新增 getLayoutManagerV2 方法用于创建垂直方向的网格布局管理器
- 修改 initListSortV2 方法支持动态行列参数控制排序逻辑
- 添加 submitListSortV2 方法用于提交时按槽位号排序
- 重构 HomeV2Activity 中的 UI 更新逻辑,支持动态行列配置
- 修复 RecyclerView 初始化时机问题,避免重复设置适配器
- 优化 ShelfV2Activity 中的清空操作,添加加载提示和自动关闭功能
- 完善槽位状态管理,支持空置和存放中状态标识
2026-06-04 14:32:08 +08:00
mazengfei ac17fca325 fix(home): 解决格口数据更新和权重计算问题
- 将超时时间从5分钟调整为1分钟
- 优化格口数据匹配逻辑,提高查找效率
- 根据物品类型分别处理重量数据存储
- 添加JSON序列化支持用于调试日志
- 修复itemType字段类型不一致问题
- 优化重量显示计算逻辑并简化条件判断
- 修复代码格式化问题
2026-06-04 11:19:25 +08:00
lvmeng 4b8e5f84e6 refactor(net): 重构网络请求配置以支持动态域名切换
- 在 ApiServiceV2 中使用 @Url 注解实现动态 URL 配置
- 添加 DynamicBaseUrlInterceptor 拦截器用于实时替换基础 URL
- 更新环境切换对话框中的环境名称和对应的 URL 映射关系
- 修改 UrlConfig 中的常量为变量并包含完整的基础 URL 路径
- 在 HttpManager 中为两个 API 服务实例都添加动态域名拦截器
- 调整环境配置常量定义顺序以匹配新的环境切换逻辑
2026-06-03 16:30:05 +08:00
lvmeng d6ba6cf527 feat(model): 更新货柜初始化结果和净菜包选项数据结构
- 在 CabinetInitResult 中添加 horizontalRows 和 verticalCount 字段
- 为 CleanPackageOption 和 MealPackageOption 添加 orderNo、foodId、needCount、needQtyKg 等字段
- 修改 CleanPackageOption 的 itemType 为 1,MealPackageOption 的 itemType 为 2
- 在 ShelfModelV2 中添加 cleanOrderNo 字段用于关联净菜订单号
- 更新 SyncItem 数据结构,添加 cleanOrderNo 字段
- 移除 HomeV2Activity 中的测试代码和硬编码逻辑
- 修改格口编号显示格式,补零显示两位数
- 调整定时任务执行频率,缩短同步间隔时间
2026-05-28 17:39:04 +08:00
lvmeng 4408acfca4 refactor(net): 注释掉设备令牌和配置相关功能
- 注释掉 getDeviceToken 方法及其相关逻辑
- 注释掉 getDeviceConfig 方法及其相关逻辑
- 注释掉 getAccessToken 方法及其相关逻辑
- 保留了 UiState 相关的状态流定义
- 维持了其他网络请求方法的正常功能
2026-05-27 19:55:02 +08:00
lvmengandClaude Sonnet 4.6 b0739605b9 feat(v2): 新增 V2 货柜接口层、页面及数据同步功能
- 新增 ApiServiceV2 / NetViewModelV2 承载 v1.1 接口(初始化、净菜包/餐品净菜包选项、数据同步)
- 新增 CabinetRequestInterceptor 独立 Retrofit 实例
- 新增 HomeV2Activity + ShelfV2Activity 及对应布局
- 新增 ShelfV2Adapter / CleanPackageAdapter / MealPackageAdapter
- 新增 ShelfModelV2 / CabinetInitResult / SyncBody / SyncItem / CleanPackageOption / MealPackageOption 数据模型
- HomeV2Activity 实现 syncShelfGoodsToServer 定时同步逻辑
- GridLayoutTool 新增 initListSortV2 支持 ShelfModelV2 排序

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:54:06 +08:00
lvmeng d524da1af3 refactor(net): 注释掉设备token和配置信息相关API接口
- 将getDeviceToken接口注释掉
- 将getDeviceInfo接口注释掉
- 将getAccessToken接口注释掉
- 保留了原有的文档注释
- 维持了文件结构完整性
2026-05-27 13:51:23 +08:00
lvmengandClaude Sonnet 4.6 967d06ce1e docs(serial): 更新 ScaleManager 类注释
- 同步典型用法为当前链式 start() 写法
- 职责说明补充 Channel 发送队列描述
- release() 说明补充"清空队列"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:47:56 +08:00
lvmengandClaude Sonnet 4.6 d5c6ec92d4 refactor(serial): sendCmd 改用 Channel 保证串口写入顺序
- 新增 sendChannel(无界 Channel)作为发送队列
- openPort() 启动单一消费者协程顺序消费队列,避免并发写串口
- sendCmd() 改为 trySend() 入队,不再每次创建新协程
- release() 关闭 Channel,确保消费者协程正常退出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:02:15 +08:00
lvmengandClaude Sonnet 4.6 7b28eb03d6 refactor(serial): 清理废弃类并复用现有工具函数
- 删除 SerialProtocolHandler(旧版协议解析器,已无任何引用)
- 删除 SerialFrameParser(仅被 SerialProtocolHandler 使用,随其废弃)
- ScaleFrameParser.parseFrame 改用项目已有的 String.hexToBytes(),移除重复的私有 hexToByteArray()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:00:08 +08:00
lvmengandClaude Sonnet 4.6 4d545ceb46 refactor(serial): 将 SerialPortManager 合并到 ScaleManager
- 删除 SerialPortManager,串口生命周期(open/startReceive/release)内嵌到 ScaleManager
- ScaleManager 改为 object 单例,去除 .instance 调用
- init() 简化为只接收 activateCode,去除 deviceId + activateMap 查表逻辑
- 新增 start(preHook) 方法合并 open + startReceive,支持链式调用到末尾
- BaseActivity.sendCmd 改为直接调用 ScaleManager.sendCmd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:59:19 +08:00
mazengfeiandClaude Sonnet 4.6 0eede1059c feat(serial): 封装 ScaleManager 替换现有秤功能逻辑
新增 SDK 核心类:
- ScaleProtocolConstants:协议常量(命令字节、帧偏移)
- ScaleFrameParser:分片拼装、帧解析、BCD重量解析(修复旧代码 hex 误用问题)
- ScaleFrame / ScaleWeight / HeartbeatData / StatusData:数据模型
- ScaleManager:单例入口,支持链式回调注册、自动应答、指令构建
- ScaleFrameParserTest:基于实际抓包数据的单元测试

业务层替换:
- HomeActivity:移除 SerialProtocolHandler/SerialFrameParser,改用 ScaleManager 回调
- BaseActivity:sendCmd() 保持调用 SerialPortManager,与 ScaleManager.sendAction 独立
- ShelfActivity:秤置零、获取状态指令改用 ScaleManager 构建方法

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 13:54:02 +08:00
lvmeng 8145eb588b refactor(serial): 将串口配置常量重命名为大写格式
- 将 portName 常量重命名为 PORT_NAME
- 将 baudRate 常量重命名为 BAUD_RATE
- 更新代码中对这些常量的引用以使用新的命名约定
- 保持串口通信功能完全不变
2026-05-21 09:01:27 +08:00
mazengfei c91d5656aa 修改旧版设备秤数量导致数据显示不准的问题 2026-05-20 16:50:53 +08:00
mazengfei 532b22a5c6 设备支持3列显示,统一处理显示和提交数据顺序 2026-05-20 15:36:28 +08:00
mazengfei e9d04bfb70 优化启动页面样式 2026-04-28 16:52:54 +08:00
mazengfei afba5220b3 首页设置禁止返回按钮 2026-04-28 16:41:21 +08:00
lvmeng 4ace12c6c7 style(ui): 调整加载动画尺寸并替换为矢量资源
- 将加载动画ImageView尺寸从160dp调整为120dp
- 替换loading_spinner为新的loading矢量资源
- 添加loading.xml矢量动画资源文件
- 注释掉HomeActivity中的临时报告对话框逻辑
- 简化HomeActivity中的RespData类型转换代码
2026-04-28 15:37:47 +08:00
mazengfei ee36869003 优化 2026-04-07 17:19:12 +08:00
lvmengandClaude Sonnet 4.6 c23c215941 fix: 修复 noDataWatchdogTask 潜在的内存泄漏问题
在 Runnable 中添加生命周期检查,防止 Activity 销毁后任务继续执行。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:58:22 +08:00
lvmengandClaude Sonnet 4.6 3d67dd2c2d refactor: 优化货架列表转换逻辑并支持奇数长度处理
- 移除 subList 创建,直接通过索引访问元素
- 修复奇数长度列表的处理逻辑
- 保持单次循环,提升性能

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:53:00 +08:00
mazengfeiandClaude Sonnet 4.6 133f309d4b refactor: 统一时钟管理并优化状态栏显示逻辑
- 在 BaseActivity 中实现统一的时钟管理机制,避免各子类重复实现
- LogActivity 和 ShelfActivity 覆写 onClockTick() 自定义状态栏显示
- HomeActivity 移除重复的时间更新逻辑,启用右侧状态栏菜单
- 提取协议常量到 ProtocolConstants,提高代码可维护性
- 优化开锁逻辑判断,改为基于 deviceId 的动态判断
- 新增右侧状态栏弹出菜单布局文件

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:07:41 +08:00
mazengfeiandClaude Sonnet 4.6 3e5c7c6caa refactor(HomeActivity): 修复内存泄漏并优化错误处理
1. 修复内存泄漏问题
   - 在 onDestroy 中清理所有 Handler 回调
   - 在 onDestroy 中释放 Dialog 引用

2. 增强类型安全
   - updateUI 中增加类型检查和日志
   - updateOverdueState 中增加完整的类型检查

3. 优化串口异常处理
   - 串口数据处理异常时向用户提示

4. 消除代码重复
   - 删除冗余的 saveShelfGoodsList 方法
   - 定时任务直接调用 syncShelfGoodsToServer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:19:45 +08:00
mazengfeiandClaude Sonnet 4.6 04fe3ec2c1 refactor: 全面重构 HomeActivity 架构并修复多项 Bug
- 新增 SerialProtocolHandler 类,将串口协议解析逻辑从 HomeActivity 剥离
- ProtocolConstants 补充命令码常量(CMD_*)及帧解析偏移常量
- 修复 NetViewModel.getDeviceConfig 更新错误 StateFlow 的 Bug
- 修复 getWeightInfo 中 toInt() 整数溢出崩溃风险,改用 toLongOrNull(16)
- 升级废弃的 getSerializableExtra,使用 Build.VERSION.SDK_INT 版本分支
- BaseActivity 提取 onUserInactive() 虚方法,消除对子类 HomeActivity 的直接引用
- 修复 BaseActivity 中错误的 LIGHT_OPEN/CLOSE 导入(改从 ProtocolConstants 导入)
- repeatOnLifecycle 由 CREATED 改为 STARTED,避免 UI 不可见时无效更新
- updateOverdueState 从回调风格改为 StateFlow,与项目其他接口保持一致
- 提取 syncShelfGoodsToServer() 消除重复的 ShelfBody 构建逻辑
- 清理 NetViewModel 和 BaseActivity 中大段注释死代码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:26:05 +08:00
lvmengandClaude Sonnet 4.6 9a795b9710 feat(HomeActivity): 新增串口无数据超时提示弹窗
超过5分钟未收到串口数据时,自动弹窗提示用户考虑断电重启设备;
收到新数据后自动关闭弹窗并重置计时。
同步提交串口协议常量(ProtocolConstants)与帧解析器(SerialFrameParser)重构代码。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:18:00 +08:00
mazengfei fff6a2a3b3 配置生产域名 2026-03-31 19:16:56 +08:00
lvmeng 3b79f6d6aa feat(config): 实现应用基础URL配置管理与环境切换功能
- 引入GlobalData对象统一管理应用基础URL配置
- 添加TEST/UAT/PROD三套环境切换弹窗功能
- 在BaseActivity中集成双击右上角状态栏显示环境切换对话框
- 将网络请求配置从App.configUrl迁移至GlobalData.appBaseUrl
- 扩展SpTool工具类支持baseUrl本地存储
- 增加HTTP连接和写入超时时间至30秒
- 更新构建配置启用BuildConfig支持
- 添加环境切换相关UI资源文件和样式定义
2026-03-31 14:06:04 +08:00
lvmeng 76b67b545d Merge remote-tracking branch 'origin/dev_优化_基于分支代码备份' 2026-03-31 10:58:32 +08:00
lvmeng 3bd60990b9 Revert "重构 HomeActivity:修复多项稳定性与架构问题"
This reverts commit 85903eb646.
2026-03-31 10:57:46 +08:00
lvmeng a116c4fc6c Revert "重构:拆分 HomeActivity 职责,分层解耦串口与业务逻辑"
This reverts commit e6cfc4dff2.
2026-03-31 10:57:40 +08:00
mazengfei 40cd27fa7b 增加左上角点击关闭页面,增加启动网络检测和连接功能,删除手机端测试布局; 2026-03-30 11:18:11 +08:00
lvmengandClaude Sonnet 4.6 e6cfc4dff2 重构:拆分 HomeActivity 职责,分层解耦串口与业务逻辑
- 新建 SerialEventListener:定义串口协议解析回调接口契约
- 新建 SerialProtocolParser:迁移全部协议解析逻辑(拼包、12种命令分发、重量解析),无 Android 依赖,可单元测试
- 新建 SerialViewModel:管理串口生命周期(open/close)、Channel 数据传递,暴露 weightFlow / tempCtrlEventFlow / deviceStatusRefreshFlow
- 扩展 HomeViewModel:迁入定时任务(保存货架数据、刷新过期状态),改用 viewModelScope 管理,新增 deviceName 属性与 overdueUpdateFlow
- 精简 HomeActivity:从 835 行削减至 255 行,仅保留 UI 协调职责,所有串口与业务逻辑由 ViewModel 层处理

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 13:41:01 +08:00
lvmengandClaude Sonnet 4.6 85903eb646 重构 HomeActivity:修复多项稳定性与架构问题
架构优化:
- 新增 HomeViewModel,将 companion object 中的共享状态(shelfItems、
  weightMap、selectedShelfIndex、temperatureC、humidity)迁移至 ViewModel,
  消除 Activity 重建时的状态残留与全局状态污染问题

协程修复:
- repeatOnLifecycle(CREATED) 改为 STARTED,确保 Flow 收集与 UI 可见性
  生命周期对齐,进入后台时自动停止订阅
- 串口数据接收改用 Channel<String> + 单一消费协程,替代多 launch 并发模式,
  彻底消除 StringBuilder(serialReader)的并发竞争问题

稳定性修复:
- 修复 tipDialog 从未赋值导致 Dialog 功能失效及 onDestroy 窗口泄漏问题
- 修复 weightHex.toInt() 十进制解析错误,改为 toIntOrNull(16) 并添加兜底
- 修复 updateOverdueState 按索引访问接口数据导致的越界风险,
  改为按 deviceNo 精确匹配,同时修正交叉排列导致的状态串位问题
- 废弃 API getSerializableExtra 替换为版本分支兼容写法(minSdk 25+)

依赖升级:
- androidx.core-ktx 1.10.1 → 1.12.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:45:44 +08:00
mazengfei 11e6123cc9 隐藏温湿度 2026-03-10 18:06:23 +08:00
lvmeng 14d8064b13 优化 2026-02-05 14:50:33 +08:00
mazengfei 8006b0d077 优化 2026-01-29 18:53:16 +08:00
mazengfei dbb7ccf8c7 优化 2026-01-15 11:37:37 +08:00
lvmeng 3ff58641aa 优化 2026-01-13 09:29:30 +08:00
lvmeng 5d58cdc4cf 增加定时任务更新货柜数据过期状态 2026-01-13 09:27:41 +08:00
马增飞 9a222037ea 优化 2026-01-12 18:03:41 +08:00
lvmeng 247a332ff3 优化 2026-01-12 14:42:46 +08:00
mazengfei 2fe0381987 jks 2025-12-30 17:23:56 +08:00
lvmeng 7e32268a4c 优化 2025-12-30 16:59:56 +08:00
马增飞 5b0b98c8b8 优化 2025-12-30 16:28:35 +08:00
马增飞 aff3d5cc69 优化 2025-12-03 09:44:37 +08:00
马增飞 6d1b997145 优化 2025-11-26 16:00:19 +08:00
马增飞 ad669e421b 优化 2025-11-20 10:11:28 +08:00
lvmeng 119a69bbbc 优化 2025-11-19 08:31:40 +08:00
马增飞 096499cd65 优化并增加日志 2025-11-18 18:49:46 +08:00
lvmeng 79d14ca300 增加终端反馈 2025-11-18 16:29:38 +08:00
马增飞 f8eeb83c6f 优化 2025-11-17 16:27:00 +08:00
马增飞 99001fff2f 优化 2025-11-13 18:10:34 +08:00
马增飞 10059bf4f6 联调优化 2025-11-12 20:14:37 +08:00
马增飞 32ea2f1379 设备联调优化 2025-11-11 18:26:07 +08:00
lvmeng c20ce471cb 优化 2025-11-07 08:58:21 +08:00
马增飞 3366fc02e5 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/src/main/java/com/shuwei/intelligent/shelves/InitActivity.kt
#	app/src/main/java/com/shuwei/intelligent/shelves/net/ApiService.kt
#	app/src/main/java/com/shuwei/intelligent/shelves/net/NetViewModel.kt
2025-11-06 19:32:30 +08:00
马增飞 647ec0dcb7 设备联调优化 2025-11-06 19:29:18 +08:00
lvmeng 61c9452295 开机启动页面修改 2025-09-26 15:58:40 +08:00
lvmeng a0737ebbc3 接口header配置修改 2025-09-18 19:22:20 +08:00
lvmeng a811d296d9 高度修改 2025-09-17 15:18:55 +08:00
lvmeng 9c2c5f073e 初始化功能调试,启动页面优化 2025-09-17 15:16:11 +08:00
lvmeng a355828bab 增加初始化功能 2025-09-11 16:09:07 +08:00
lvmeng e153f3e3b9 增加开机启动 2025-08-22 17:34:56 +08:00
lvmeng ca2041fe76 参数优化 2025-08-18 14:17:58 +08:00
lvmeng f8453c7d76 增加过期逻辑 2025-08-15 17:59:40 +08:00
lvmeng 2162ba0c9e 页面优化 2025-08-11 17:48:34 +08:00
马增飞 5b26e7e711 设备联调优化 2025-08-11 17:15:02 +08:00
lvmeng 0d99c4f22a 调试通过获取设备信息实时更新二级页面重量,首页和二级页面列表页增加空数据展示 2025-08-11 14:22:34 +08:00
马增飞 9e5ede4a8e 设备联调,接口参数优化 2025-08-08 17:48:07 +08:00
lvmeng 0b52dad0fe 串口数据长度优化 2025-08-08 15:08:26 +08:00
lvmeng 383c354f29 接口联调2 2025-08-08 14:54:11 +08:00
lvmeng 65199f4c70 接口联调 2025-08-08 13:34:18 +08:00
lvmeng eb66c2d2ff 优化数据读取异常问题 2025-08-07 19:32:04 +08:00
马增飞 e7e062ebcb 功能联调 2025-08-07 18:58:17 +08:00
lvmeng 4d91b9d338 适配优化 2025-08-07 13:01:26 +08:00
lvmeng c409621e28 功能优化,增加心跳数据处理 2025-08-07 11:07:04 +08:00
114 changed files with 7069 additions and 1031 deletions
+40 -13
View File
@@ -4,14 +4,22 @@ plugins {
}
android {
signingConfigs {
getByName("debug") {
storeFile = file("../app/swkey.jks")
storePassword = "123456"
keyPassword = "123456"
keyAlias = "android"
}
}
namespace = "com.shuwei.intelligent.shelves"
compileSdk = 34
defaultConfig {
applicationId = "com.shuwei.intelligent.shelves"
applicationId = "com.sw.scalefusion.shelf"
minSdk = 25
//noinspection ExpiredTargetSdkVersion
targetSdk = 25
//noinspection EditedTargetSdkVersion,ExpiredTargetSdkVersion
targetSdk = 30
versionCode = 1
versionName = "1.0"
@@ -36,33 +44,52 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
}
ndkVersion = "29.0.13846066"
}
//noinspection UseTomlInstead
dependencies {
//implementation(fileTree("libs") { include("*.jar", "*.aar") })
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.android.core)
implementation(libs.core)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation("androidx.activity:activity-ktx:1.8.0")
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper4:4.1.2")
implementation("com.squareup.okhttp3:okhttp:4.9.1")
implementation("com.google.code.gson:gson:2.10.1")
// implementation("io.github.jeremyliao:live-event-bus-x:1.8.0")
// implementation("com.hoho:android-usb-serial:1.3.0")
implementation("io.github.jeadyx:kmp-serialport:1.0.0")
// implementation("io.github.jeadyx:jserialport:1.5")
// // 基础依赖包,必须要依赖
// implementation("com.geyifeng.immersionbar:immersionbar:3.2.2")
// // kotlin扩展(可选)
// implementation("com.geyifeng.immersionbar:immersionbar-ktx:3.2.2")
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("com.squareup.okhttp3:okhttp:4.9.1")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("io.github.scwang90:refresh-layout-kernel:3.0.0-alpha")
implementation("io.github.scwang90:refresh-header-classics:3.0.0-alpha")
implementation("io.github.scwang90:refresh-footer-classics:3.0.0-alpha")
implementation("org.greenrobot:eventbus:3.3.1")
implementation("com.google.android.flexbox:flexbox:3.0.0")
// implementation("io.github.jeremyliao:live-event-bus-x:1.8.0")
// implementation("com.hoho:android-usb-serial:1.3.0")
// implementation("io.github.jeadyx:jserialport:1.5")
}
+27 -8
View File
@@ -2,22 +2,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.USB_PERMISSION" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/ic_logo512"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_logo512"
android:supportsRtl="true"
android:theme="@style/Theme.IntelligentShelves">
android:theme="@style/Theme.IntelligentShelves"
android:usesCleartextTraffic="true">
<activity
android:name=".HomeActivity"
android:name="com.sw.scalefusion.shelf.InitActivity"
android:exported="true"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
@@ -27,10 +36,20 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ShelfActivity"
<activity android:name="com.shuwei.intelligent.shelves.activity.LogActivity" />
<activity android:name="com.sw.scalefusion.shelf.HomeV3Activity" />
<activity android:name="com.sw.scalefusion.shelf.ShelfV3Activity"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<receiver
android:name=".utils.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
</application>
</manifest>
@@ -1,23 +1,50 @@
package com.shuwei.intelligent.shelves
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import com.shuwei.intelligent.shelves.utils.BootReceiver
import com.shuwei.intelligent.shelves.utils.CrashHandler
/**
* @author: star
* @date: 2022-04-26
*/
class App : Application() {
//"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiIiLCJ0eXBlIjoiNiIsInVzZXJJZCI6IjE2NzgyMjY1NTI5MzE1NDkxODYifQ.CQmbELpqT6QzvVCgCeirwvSvPp8Mhft_h6b1Af574Co"
override fun onCreate() {
super.onCreate()
app = this
CrashHandler.init(this)
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter)
}
companion object {
private lateinit var app: App
var canteenId = "0"
var appVersion = "1"
var configUrl = ""
fun get(): App {
//从接口获取
var accessToken: String = ""
var deviceId: String = ""
@Volatile
private var sharedPref: SharedPreferences? = null
fun getInstance(): App {
return app
}
fun getSharedPref(): SharedPreferences? {
if (sharedPref != null) {
return sharedPref
}
sharedPref = app.getSharedPreferences("sp_intelligent_shelves", Context.MODE_PRIVATE)
return sharedPref
}
}
}
@@ -0,0 +1,39 @@
package com.shuwei.intelligent.shelves
object GlobalData {
// /**
// * 设备id
// */
// var deviceId: String = ""
// /**
// * app版本号
// */
// var appVersion: String = "1"
var appBaseUrl: String = ""
/**
* 具体业务 BaseUrl
*/
// 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 PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
}
/**
* 全局常量
*/
object Constants {
}
/**
* key
*/
object GlobalKey {
const val KEY_BASE_URL = "baseUrlKey"
}
@@ -1,229 +0,0 @@
package com.shuwei.intelligent.shelves
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.util.SparseIntArray
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.shuwei.intelligent.shelves.adapter.ShelfAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeBinding
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.serial.SerialPortManager
import com.shuwei.intelligent.shelves.task.TaskManager
import com.shuwei.intelligent.shelves.utils.UIUtils
import com.shuwei.intelligent.shelves.utils.hexToDec
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class HomeActivity : BaseActivity() {
companion object {
private const val TAG = "HomeActivity"
//协议头
const val HEADER = "D6DADEDB"
//协议尾
const val FOOTER = "FFFF"
const val ACTIVE_CMD =
"${HEADER}000281C5D70A7D428A4FA67B50F7DACA241938938B4B9CBD73673E9C8E7F2E15062D${FOOTER}"
const val LEFT_SHELF_OPEN_CMD =
"${HEADER}020101000000000000000000000000000000000000${FOOTER}"
const val RIGHT_SHELF_OPEN_CMD =
"${HEADER}020100010000000000000000000000000000000000${FOOTER}"
//const val TEMPERATURE_CMD = "${HEADER}1001${FOOTER}"
const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}"
val list: MutableList<ShelfModel> = mutableListOf()
}
private lateinit var binding: ActivityHomeBinding
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
updateLeftStatus("1-1冷藏柜 -2°C / 87%")
initList()
initRecyclerView()
lifecycleScope.launch {
val openState = SerialPortManager.open()
Log.d(TAG, "onCreate: openState=$openState")
if (openState) {
SerialPortManager.startReceive { data ->
runOnUiThread {
// 更新UI显示接收数据
runCatching {
receiveSerialPortData(data)
}.onFailure {
it.printStackTrace()
}
}
}
SerialPortManager.send(ACTIVE_CMD)
SerialPortManager.send(DEVICE_INFO_CMD)
}
}
TaskManager.startTask()
}
private fun receiveSerialPortData(data: String) {
Log.d(TAG, "receiveSerialPortData: $data")
if (data.startsWith(HEADER).not()) {
return
}
val cmd = data.substring(HEADER.length, HEADER.length + 2)
when (cmd) {
"03" -> {
//获取设备状态
val weightStartIndex = HEADER.length + 4 + 10
val temperatureStartIndex = weightStartIndex + 10 * 8
val temperatureHex = data.substring(temperatureStartIndex, 2)
val temperature = hexToDec(temperatureHex)
updateLeftStatus("1-1冷藏柜 $temperature°C / 87%")
var index = 1
var count = weightStartIndex
while (count < temperatureStartIndex) {
val weightInfo = data.substring(count, count + 8)
var weightHex = ""
weightInfo.run {
weightHex =
substring(6, 8) + substring(4, 6) + substring(2, 4) + substring(0, 2)
}
var realWeight = 0
if (weightHex.first() in '8'..'F') {
//负数
val weightHex2 = "0${weightHex.substring(1)}"
realWeight = hexToDec(weightHex2)
} else {
//整数
realWeight = hexToDec(weightHex)
}
weightArray.put(count, realWeight)
count += 8
index++
}
val intent = Intent(ShelfActivity.RECEIVER_DEVICE_INFO)
intent.putExtra(ShelfActivity.SHELF_WEIGHT, weightArray[clickIndex])
intent.putExtra(ShelfActivity.RECEIVER_DEVICE_INFO, data)
sendBroadcast(intent)
}
else -> {}
}
}
private val weightArray by lazy { SparseIntArray() }
private var clickIndex = 0
override fun onDestroy() {
lifecycleScope.launch {
SerialPortManager.close()
}
TaskManager.cancelTask()
super.onDestroy()
}
@Suppress("DEPRECATION")
private val shelfAdapter by lazy {
// val list = viewModel.getCurrentItems()
ShelfAdapter(list).apply {
setOnDebouncedItemClick { adapter, view, position ->
this@HomeActivity.clickIndex = position
lifecycleScope.launch {
val openCmd = if (list[position].shelfNo in 1..5)
LEFT_SHELF_OPEN_CMD
else
RIGHT_SHELF_OPEN_CMD
val openState = SerialPortManager.send(openCmd)
if (openState.not()) {
UIUtils.toast("${list[position].foodName}柜门开启失败")
return@launch
}
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position])
}) {
it?.run {
val model =
getSerializableExtra(ShelfActivity.SHELF_MODEL) as ShelfModel
list[position].apply {
foodName = model.foodName
foodWeight = model.foodWeight
storeDate = model.storeDate
}
notifyItemChanged(position)
}
}
}
}
}
}
private fun initRecyclerView() {
binding.rvShelf.run {
layoutManager =
GridLayoutManager(this@HomeActivity, 2, GridLayoutManager.VERTICAL, false)
adapter = shelfAdapter
}
}
private fun initList() {
// val list: MutableList<ShelfModel> = mutableListOf()
list.add(ShelfModel(shelfNo = 1))
list.add(ShelfModel(shelfNo = 6))
list.add(ShelfModel(shelfNo = 2))
list.add(ShelfModel(shelfNo = 7))
list.add(ShelfModel(shelfNo = 3))
list.add(ShelfModel(shelfNo = 8))
list.add(ShelfModel(shelfNo = 4))
list.add(ShelfModel(shelfNo = 9))
list.add(ShelfModel(shelfNo = 5))
list.add(ShelfModel(shelfNo = 10))
// viewModel.updateItems(list)
}
private val MM_DD_EEEE__HH_MM_SS = "MM月dd日 EEEE HH:mm:ss"
private fun updateDateTime() {
val sdf = SimpleDateFormat(MM_DD_EEEE__HH_MM_SS, Locale.CHINA)
val dateTime = sdf.format(Date())
updateRightStatus(dateTime)
}
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
handler.postDelayed(this, 1000)
}
}
override fun onResume() {
super.onResume()
hideStatusBar()
handler.post(updateTask)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(updateTask)
}
}
@@ -1,212 +0,0 @@
package com.shuwei.intelligent.shelves
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.shuwei.intelligent.shelves.HomeActivity.Companion.ACTIVE_CMD
import com.shuwei.intelligent.shelves.HomeActivity.Companion.DEVICE_INFO_CMD
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.adapter.SearchAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
import com.shuwei.intelligent.shelves.model.SearchModel
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.serial.SerialPortManager
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.decToHex
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.random.Random
@SuppressLint("NotifyDataSetChanged")
class ShelfActivity : BaseActivity() {
companion object {
private const val TAG = "ShelfActivity"
const val SHELF_MODEL = "shelfModel"
const val RECEIVER_DEVICE_INFO = "receiverDeviceInfo"
const val SHELF_WEIGHT = "shelfWeight"
}
private lateinit var binding: ActivityShelfBinding
private var shelfModel: ShelfModel? = null
private var clickIndex: Int = -1
private var realWeight: Int = 0
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfBinding.inflate(layoutInflater)
setContentView(binding.root)
shelfModel = intent.getSerializableExtra(SHELF_MODEL) as ShelfModel?
shelfModel?.let {
binding.tvShelfName.text = it.shelfName
binding.tvFoodName.text = if (it.foodName.isNullOrBlank()) "-" else it.foodName
}
initRecyclerView()
// initWeight()
binding.etInputFood.run {
addOnActionSearchListener {
if (text.isNullOrBlank()) {
toast(hint.toString())
return@addOnActionSearchListener
}
loadSearchData()
KeyboardUtil.hideKeyboard(context, this)
}
}
binding.btnConfirm.clickWithDebounce {
if (clickIndex < 0) {
toast("请选择食材")
return@clickWithDebounce
}
val data = ShelfModel(
shelfName = shelfModel?.shelfName,
foodName = list[clickIndex].foodName,
foodWeight = realWeight,
storeDate = DateTimeUtil.formatDateTime(dateTime = Date())
)
val intent = Intent().apply {
putExtra(SHELF_MODEL, data)
}
setResult(RESULT_OK, intent)
finish()
}
binding.tvFoodWeight.text = "${realWeight}"
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v)
}
binding.btnClearZero.setOnClickListener { v ->
lifecycleScope.launch {
val shelfNo = shelfModel?.shelfNo ?: 0
val zeroClearingCmd =
"${HomeActivity.HEADER}0401${decToHex(shelfNo)}${HomeActivity.FOOTER}"
SerialPortManager.send(zeroClearingCmd)
}
KeyboardUtil.hideKeyboard(v.context, v)
}
lifecycleScope.launch {
SerialPortManager.send(DEVICE_INFO_CMD)
}
registerReceiver(receiver, IntentFilter(RECEIVER_DEVICE_INFO))
}
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let {
val data = it.getStringExtra(RECEIVER_DEVICE_INFO)
Log.d(TAG, "onReceive: $data")
realWeight = it.getIntExtra(SHELF_WEIGHT, 0)
binding.tvFoodWeight.text = "${realWeight}"
}
}
}
override fun onDestroy() {
unregisterReceiver(receiver)
super.onDestroy()
}
// private fun initWeight() {
// realWeight = Random.nextInt(1, 5001)
// }
private fun loadSearchData() {
list.add(SearchModel(foodId = 1000, foodName = "菲律宾乳猪五花肉"))
list.add(SearchModel(foodId = 1001, foodName = "金针菇"))
list.add(SearchModel(foodId = 1002, foodName = "大菠菜"))
list.add(SearchModel(foodId = 1003, foodName = "清远麻鸡块"))
list.add(SearchModel(foodId = 1004, foodName = "普罗旺斯番茄"))
list.add(SearchModel(foodId = 1005, foodName = "长条茄子"))
list.add(SearchModel(foodId = 1006, foodName = "得莫利鱼"))
list.add(SearchModel(foodId = 1007, foodName = "土豆丝"))
list.add(SearchModel(foodId = 1008, foodName = "辣椒炒肉"))
list.add(SearchModel(foodId = 1009, foodName = "雪菜烧豆腐"))
list.add(SearchModel(foodId = 1010, foodName = "冬虫夏草炝拌芥兰苗"))
list.add(
SearchModel(
foodId = 1011,
foodName = "蘑菇玉米山药莲藕胡萝卜红枣莲子枸杞清炖排骨汤"
)
)
searchAdapter.notifyDataSetChanged()
}
private val list: MutableList<SearchModel> = mutableListOf()
private val searchAdapter by lazy {
SearchAdapter(list).apply {
setOnDebouncedItemClick { adapter, v, position ->
this@ShelfActivity.clickIndex = position
list.forEach { it.isClicked = false }
list[position].isClicked = true
notifyDataSetChanged()
binding.tvFoodName.text = list[position].foodName
KeyboardUtil.hideKeyboard(v.context, v)
}
}
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfActivity, 2)
adapter = searchAdapter
}
}
private val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
private fun updateDateTime() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val dateTime = sdf.format(Date())
val arr = dateTime.split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
private val handler = Handler(Looper.getMainLooper())
private val updateTask = object : Runnable {
override fun run() {
updateDateTime()
handler.postDelayed(this, 1000)
}
}
override fun onResume() {
super.onResume()
hideStatusBar()
handler.post(updateTask)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(updateTask)
}
}
@@ -0,0 +1,203 @@
package com.shuwei.intelligent.shelves.activity
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.shuwei.intelligent.shelves.adapter.LogAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityLogListBinding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.utils.ext.visible
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogActivity : BaseActivity() {
companion object {
const val IS_LOG_DIR = "isLogDir"
const val LOG_DATE = "logDate"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
}
/**
* 覆写时钟回调:左侧状态栏显示年月日星期,右侧状态栏显示时分秒
*/
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
private lateinit var binding:ActivityLogListBinding
private var isLogDir = false
private var logDate = ""
private val logList = mutableListOf<String>()
private val originalLogList = mutableListOf<String>() // 原始日志备份
private val logAdapter by lazy {
LogAdapter(logList).apply {
setOnItemClickListener { _, _, position ->
if (isLogDir.not()) {
showConfirmDialog(logList[position])
return@setOnItemClickListener
}
startActivity<LogActivity> {
putExtra(IS_LOG_DIR, false)
putExtra(LOG_DATE, logList[position])
}
}
}
}
private fun showConfirmDialog(content:String) {
CommonDialog(this).apply {
dialogTitle = "温馨提示"
dialogContent = content
confirmBlock = {
content?.copyTextToClipboard(this@LogActivity)
}
}.show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLogListBinding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
isLogDir = intent.getBooleanExtra(IS_LOG_DIR, false)
logDate = intent.getStringExtra(LOG_DATE) ?: ""
binding.ivBack.setOnClickListener { finish() }
binding.tvTitle.text = if (isLogDir) "全部日志" else logDate
binding.tvTitle.setOnClickListener {
logList.reverse()
logAdapter.notifyDataSetChanged()
}
binding.rvLogList.let {
it.layoutManager = LinearLayoutManager(this)
it.adapter = logAdapter
}
setupSearch()
setupJumpButton()
binding.btnFind.run {
if (isLogDir) gone() else visible()
}
binding.btnFind.setOnClickListener {
if (isLogDir) return@setOnClickListener
val list = logList.filter { it.contains("${ProtocolConstants.HEADER}${ProtocolConstants.CMD_TEMP_REPORT}") }
if (list.isEmpty()) {
toast("未找到到1001指令")
return@setOnClickListener
}
logList.clear()
logList.addAll(list)
logAdapter.notifyDataSetChanged()
}
// 后台加载日志数据
lifecycleScope.launch {
binding.progressBar.visible()
val data = withContext(Dispatchers.IO) {
if (isLogDir) fileLogger.getLogFiles()
else fileLogger.readLogsByName(logDate)
}
logList.addAll(data)
originalLogList.addAll(data)
logAdapter.notifyDataSetChanged()
binding.progressBar.gone()
setupTimeNavigation()
}
}
// 搜索功能
private fun setupSearch() {
binding.etSearch.addTextChangedListener(afterTextChanged = { s ->
val keyword = s?.toString() ?: ""
logList.clear()
logList.addAll(if (keyword.isEmpty()) originalLogList else originalLogList.filter { it.contains(keyword, ignoreCase = true) })
logAdapter.notifyDataSetChanged()
})
}
// 快速跳转功能
private fun setupJumpButton() {
var isAtTop = true
binding.fabJump.setOnClickListener {
if (isAtTop) {
binding.rvLogList.scrollToPosition(logList.size - 1)
} else {
binding.rvLogList.scrollToPosition(0)
}
isAtTop = !isAtTop
}
}
// 时间导航功能
private fun setupTimeNavigation() {
if (isLogDir) {
binding.hsvTimeNav.gone()
return
}
val timeMap = mutableMapOf<String, Int>()
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
originalLogList.forEachIndexed { index, log ->
val timeStr = log.substringAfter("[").substringBefore("]")
try {
val date = dateFormat.parse(timeStr)
date?.let {
val hourKey = SimpleDateFormat("HH:00", Locale.getDefault()).format(it)
if (!timeMap.containsKey(hourKey)) {
timeMap[hourKey] = index
}
}
} catch (e: Exception) {
// 忽略解析失败的日志
}
}
if (timeMap.isEmpty()) {
binding.hsvTimeNav.gone()
return
}
timeMap.toSortedMap().forEach { (hour, position) ->
val btn = TextView(this).apply {
text = hour
textSize = 14f
setTextColor(Color.BLACK)
setPadding(12.dp, 8.dp, 12.dp, 8.dp)
gravity = Gravity.CENTER
setBackgroundColor(Color.parseColor("#E0E0E0"))
setOnClickListener {
binding.rvLogList.scrollToPosition(position)
}
}
val params = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.MATCH_PARENT
).apply {
marginEnd = 8.dp
}
binding.llTimeNav.addView(btn, params)
}
}
}
@@ -0,0 +1,25 @@
package com.shuwei.intelligent.shelves.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.intelligent.shelves.databinding.ListItemLogBinding
class LogAdapter(list: MutableList<String>) :
BaseQuickAdapter<String, LogAdapter.VH>(list) {
inner class VH(var binding: ListItemLogBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemLogBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: String?) {
holder.binding.tvLogName.text = item
}
}
@@ -1,50 +0,0 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.shuwei.intelligent.shelves.model.SearchModel
class SearchAdapter(list: MutableList<SearchModel>) :
BaseQuickAdapter<SearchModel, SearchAdapter.VH>(list) {
inner class VH(var binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemSearchBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SearchModel?) {
val binding = holder.binding
binding.btnFoodName.run {
text = item!!.foodName
setTextColor(
getColor(if (item.isClicked) R.color.shelf_name_blue else R.color.black999)
)
val btnStrokeColor = if (item.isClicked) "#4969F5" else "#E6E6E6"
strokeColor = ColorStateList.valueOf(btnStrokeColor.toColorInt())
}
// val bgDrawable = createCustomDrawable(
// solidColor = "#FFFFFF",
// strokeWidth = 2.dp,
// strokeColor = if (item!!.isClicked) "#4969F5" else "#E6E6E6",
// radius = 12.dp
// )
// binding.root.background = bgDrawable
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -1,72 +0,0 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfBinding
import com.shuwei.intelligent.shelves.model.ShelfModel
class ShelfAdapter(list: MutableList<ShelfModel>) :
BaseQuickAdapter<ShelfModel, ShelfAdapter.VH>(list) {
inner class VH(var binding: ListItemShelfBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemShelfBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: ShelfModel?) {
val shelfNo = item!!.shelfNo
item.shelfName = "货架-${if (shelfNo < 10) "0${shelfNo}" else "$shelfNo" }"
val weight = item.foodWeight ?: 0
val binding = holder.binding
val isBlankShelf = weight <= 0
if (isBlankShelf) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.run {
setTextColor(getColor(R.color.shelf_name_white))
text = item.shelfName
}
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "0千克"
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.run {
setTextColor(getColor(R.color.shelf_name_blue))
text = item.shelfName
}
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.foodName
}
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
text = "${weight/1000F}千克"
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.storeDate
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -2,18 +2,40 @@ package com.shuwei.intelligent.shelves.base
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import android.widget.PopupWindow
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.shuwei.intelligent.shelves.activity.LogActivity
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ActivityBaseBinding
import com.shuwei.intelligent.shelves.databinding.PopupRightStatusMenuBinding
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_CLOSE
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.LIGHT_OPEN
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.FileLogger
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import kotlinx.coroutines.launch
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.shuwei.intelligent.shelves.utils.ext.setOnDoubleClickListener
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Suppress("DEPRECATION")
open class BaseActivity : AppCompatActivity() {
val TAG = this.javaClass.simpleName
private lateinit var binding: ActivityBaseBinding
private var launcher: ActivityResultLauncher<Intent>? = null
@@ -31,9 +53,10 @@ open class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
hideSystemBars()
binding = ActivityBaseBinding.inflate(layoutInflater)
setContentView(binding.root)
statusBarDarkFont(enable = false)
statusBarDarkFont()
launcher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
@@ -41,28 +64,37 @@ open class BaseActivity : AppCompatActivity() {
}
}
// window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge
// window.insetsController?.apply {
// hide(WindowInsets.Type.statusBars()) // 隐藏状态栏
// systemBarsBehavior =
// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示
// }
lightSwitchClick()
binding.tvLeftStatus.setOnDoubleClickListener {
//finish()
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
binding.tvRightStatus.setOnDoubleClickListener {
if (enableRightStatusMenu) showRightStatusPopup(binding.tvRightStatus)
}
}
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
}
/** 是否允许右侧状态栏双击弹出菜单,默认关闭,仅 HomeActivity 开启 */
protected open val enableRightStatusMenu: Boolean = false
fun statusBarDarkFont(enable: Boolean) {
//window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
fun hideStatusBar() {}
fun statusBarDarkFont() {
val color = ContextCompat.getColor(this, R.color.white)
binding.tvLeftStatus.setTextColor(color)
binding.tvRightStatus.setTextColor(color)
}
fun setBackground() {
binding.root.setBackgroundColor(ContextCompat.getColor(this, R.color.bg_page))
}
override fun setContentView(view: View?) {
if (view == binding.root) {
super.setContentView(view)
@@ -72,12 +104,154 @@ open class BaseActivity : AppCompatActivity() {
}
fun updateLeftStatus(data: String) {
binding.tvLeftStatus.text = data
binding.tvLeftStatus.text = data.ifBlank { " " }
}
fun updateRightStatus(data: String) {
binding.tvRightStatus.text = data
}
// 5分钟
private val TIME_OUT: Long = (1 * 60 * 1000).toLong()
private val mHandler by lazy { Handler(Looper.getMainLooper()) }
private val mTimeoutRunnable = Runnable {
// 用户长时间无操作时回调,子类可覆写实现具体逻辑(如关灯)
onUserInactive()
}
// ---- 右侧状态栏时钟 ----
// 默认时间格式:月日星期 时:分:秒
private val CLOCK_FORMAT = "MM月dd日 EEEE HH:mm:ss"
private val clockFormatter by lazy { SimpleDateFormat(CLOCK_FORMAT, Locale.CHINA) }
/**
* 每秒时钟回调,默认将当前时间显示在右侧状态栏。
* 子类可覆写以自定义格式或同时更新左侧状态栏。
*/
open fun onClockTick() {
updateRightStatus(clockFormatter.format(Date()))
}
private val clockTask = object : Runnable {
override fun run() {
onClockTick()
mHandler.postDelayed(this, 1000)
}
}
/**
* 用户超时无操作时回调,默认空实现,子类按需覆写
*/
open fun onUserInactive() {}
override fun onUserInteraction() {
super.onUserInteraction()
//开灯
if (System.currentTimeMillis() - lastTouchTime >= TIME_OUT) {
this.switchLight(true)
}
// 重置定时器
mHandler.removeCallbacks(mTimeoutRunnable)
mHandler.postDelayed(mTimeoutRunnable, TIME_OUT)
lastTouchTime = System.currentTimeMillis()
}
private var lastTouchTime = 0L
override fun onResume() {
super.onResume()
// 启动超时定时器
mHandler.postDelayed(mTimeoutRunnable, TIME_OUT)
// 启动右侧状态栏时钟
mHandler.post(clockTask)
}
override fun onPause() {
super.onPause()
// 取消超时定时器
mHandler.removeCallbacks(mTimeoutRunnable)
// 停止右侧状态栏时钟
mHandler.removeCallbacks(clockTask)
}
fun switchLight(isOpen: Boolean) {
val tag = binding.tvLeftStatus.tag?.toString()?.toBoolean() ?: true
if (tag == isOpen) {
return
}
sendCmd(if (isOpen) LIGHT_OPEN else LIGHT_CLOSE)
binding.tvLeftStatus.tag = isOpen
}
private fun lightSwitchClick() {
switchLight(true)
binding.tvLeftStatus.clickWithDebounce {
val tag = binding.tvLeftStatus.tag?.toString()?.toBoolean() ?: true
switchLight(tag.not())
}
}
private fun showRightStatusPopup(anchor: View) {
val menuBinding = PopupRightStatusMenuBinding.inflate(LayoutInflater.from(this))
val popup = PopupWindow(
menuBinding.root,
120.dp,
101.dp,
true
)
menuBinding.tvEnvSwitch.setOnClickListener {
popup.dismiss()
EnvSwitchDialog(this).show()
}
menuBinding.tvLog.setOnClickListener {
popup.dismiss()
startActivity<LogActivity> {
putExtra(LogActivity.IS_LOG_DIR, true)
}
}
popup.showAsDropDown(anchor)
}
fun sendCmd(cmd: String) {
ScaleManager.sendCmd(cmd)
log("发送命令: $cmd")
}
val fileLogger by lazy { FileLogger(this) }
fun log(message:String) {
Log.d(TAG, message)
fileLogger.log(message)
}
/**
* 窗口焦点变化时回调
* 场景:弹出 Dialog 后焦点丢失,Dialog 消失后焦点恢复,此时需重新隐藏系统栏,
* 避免系统栏在 Dialog 关闭后意外"复活"
*/
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemBars()
}
}
/**
* 隐藏状态栏和导航栏
* 使用 WindowInsetsControllerCompatAndroidx 推荐方式,向下兼容性好)
* - 状态栏:完全隐藏
* - 导航栏:完全隐藏,底部上滑时临时出现,松手后自动收回(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
*/
private fun hideSystemBars() {
// 允许布局延伸到系统栏区域(edge-to-edge),避免内容被系统栏遮挡
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
// 同时隐藏状态栏和导航栏
hide(WindowInsetsCompat.Type.systemBars())
// 底部上滑时临时显示导航栏,松手后自动隐藏(沉浸式 Sticky 模式)
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
@@ -0,0 +1,38 @@
package com.shuwei.intelligent.shelves.base
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.hideKeyboard
abstract class BaseDialog(
context: Context,
var defWidth: Int = 400.dp,
var defHeight: Int = 300.dp
) : Dialog(context, R.style.DialogTheme) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val rootView = getRootView()
setContentView(rootView)
setCancelable(false)
setCanceledOnTouchOutside(false)
window?.apply {
setLayout(defWidth, defHeight)
setBackgroundDrawable(ColorDrawable())
setGravity(Gravity.CENTER)
}
rootView.setOnClickListener { it.hideKeyboard() }
initView()
}
abstract fun getRootView(): View
abstract fun initView()
}
@@ -0,0 +1,69 @@
package com.shuwei.intelligent.shelves.base
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.databinding.DialogEnvSwitchBinding
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ToastUtil
import com.shuwei.intelligent.shelves.utils.ext.dp
/**
* 环境切换弹窗
*
* 提供 TEST / UAT / PROD 三套环境的切换功能,
* 初始化时自动根据 [GlobalData.appBaseUrl] 选中当前环境,
* 确认后更新 [GlobalData.appBaseUrl] 并通过 [onEnvChanged] 回调通知外部。
*
* @param context 上下文
* @param onEnvChanged 确认切换后的回调,参数为新的 baseUrl
*/
class EnvSwitchDialog(
context: Context,
private val onEnvChanged: (newBaseUrl: String) -> Unit = {}
) : BaseDialog(
context,
defWidth = 600.dp,
defHeight = WindowManager.LayoutParams.WRAP_CONTENT
) {
private lateinit var binding: DialogEnvSwitchBinding
override fun getRootView(): View {
binding = DialogEnvSwitchBinding.inflate(LayoutInflater.from(context))
return binding.root
}
override fun initView() {
// 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选
when (GlobalData.appBaseUrl) {
GlobalData.LOCAL_BASE_URL -> binding.rbTest.isChecked = true
GlobalData.TEST_BASE_URL -> binding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true
}
// 取消按钮
binding.btnCancel.setOnClickListener { dismiss() }
// 确认按钮
binding.btnConfirm.setOnClickListener {
// 未选中任何选项时提示用户
if (binding.rgEnv.checkedRadioButtonId == -1) {
ToastUtil.show(context,"请选择环境")
return@setOnClickListener
}
val newUrl = when (binding.rgEnv.checkedRadioButtonId) {
binding.rbTest.id -> GlobalData.LOCAL_BASE_URL
binding.rbUat.id -> GlobalData.TEST_BASE_URL
binding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setOnClickListener
}
GlobalData.appBaseUrl = newUrl
SpTool.baseUrl = newUrl
onEnvChanged(newUrl)
dismiss()
}
}
}
@@ -0,0 +1,108 @@
package com.shuwei.intelligent.shelves.dialog
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseDialog
import com.shuwei.intelligent.shelves.databinding.DialogChipSelectBinding
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.hideKeyboard
import com.shuwei.intelligent.shelves.utils.ext.toast
/**
* 带ChipGroup选择的弹窗,支持传入选项列表,回调返回选中索引
*/
class ChipSelectDialog(context: Context) : Dialog(context, R.style.DialogTheme) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DialogChipSelectBinding.inflate(LayoutInflater.from(context))
setContentView(binding.root)
setCancelable(false)
setCanceledOnTouchOutside(false)
window?.apply {
//setLayout(defWidth, defHeight)
setBackgroundDrawable(ColorDrawable())
setGravity(Gravity.CENTER)
}
binding.root.setOnClickListener { it.hideKeyboard() }
initView()
}
private lateinit var binding: DialogChipSelectBinding
/** 弹窗标题 */
var dialogTitle: String? = null
/** 选项列表 */
var items: List<String?> = emptyList()
/** 选中回调,返回选中的索引,取消时不触发 */
var onItemSelected: ((Int) -> Unit)? = null
/** 当前选中的索引,默认-1表示未选中 */
private var selectedIndex: Int = -1
fun initView() {
binding.btnCancel.setOnClickListener {
dismiss()
}
binding.btnConfirm.setOnClickListener {
if (binding.chipGroup.checkedChipId == -1) {
context.toast("请选择$dialogTitle")
return@setOnClickListener
}
onItemSelected?.invoke(selectedIndex)
dismiss()
}
}
override fun show() {
super.show()
binding.tvDialogTitle.text = dialogTitle
setupChips()
}
/**
* 动态创建Chip并添加到ChipGroup中
*/
private fun setupChips() {
binding.chipGroup.removeAllViews()
selectedIndex = -1
items.forEachIndexed { index, label ->
val chip = Chip(context).apply {
text = label
isCheckable = true
isCheckedIconVisible = false
textSize = 22f
chipStartPadding = 18.dp.toFloat()
chipEndPadding = 18.dp.toFloat()
textStartPadding = 8.dp.toFloat()
textEndPadding = 8.dp.toFloat()
chipMinHeight = 50.dp.toFloat()
setTextColor(resources.getColorStateList(R.color.chip_text_color, null))
chipBackgroundColor = resources.getColorStateList(R.color.chip_bg_color, null)
chipStrokeWidth = 1f
chipStrokeColor = resources.getColorStateList(R.color.chip_stroke_color, null)
setOnClickListener {
selectedIndex = index
}
}
binding.chipGroup.apply {
isSingleLine = false
addView(chip)
}
}
}
}
@@ -0,0 +1,33 @@
package com.shuwei.intelligent.shelves.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import com.shuwei.intelligent.shelves.base.BaseDialog
import com.shuwei.intelligent.shelves.databinding.DialogLayoutBinding
class CommonDialog(context: Context):BaseDialog(context) {
private lateinit var binding:DialogLayoutBinding
override fun getRootView(): View {
binding = DialogLayoutBinding.inflate(LayoutInflater.from(context))
return binding.root
}
var dialogTitle:String?=null
var dialogContent:String?=null
var confirmBlock:(()->Unit)?=null
override fun initView() {
binding.btnConfirm.setOnClickListener {
confirmBlock?.invoke()
dismiss()
}
}
override fun show() {
super.show()
binding.tvDialogTitle.text = dialogTitle
binding.tvDialogContent.text = dialogContent
}
}
@@ -1,9 +0,0 @@
package com.shuwei.intelligent.shelves.model
import java.io.Serializable
data class SearchModel(
var foodId: Int? = null,
var foodName: String? = null,
var isClicked: Boolean = false
) : Serializable
@@ -0,0 +1,10 @@
package com.shuwei.intelligent.shelves.model
data class SendWeightEvent(
var shelfNo: Int = 0,
var weight:Int=0
)
data class WeightEvent(var weight: Int)
data class ClearShelfEvent(var shelfNo: Int, var weight: Int)
@@ -1,11 +0,0 @@
package com.shuwei.intelligent.shelves.model
import java.io.Serializable
data class ShelfModel(
var shelfNo: Int = 0,
var shelfName: String? = null,
var foodName: String? = null,
var foodWeight: Int? = null,
var storeDate: String? = null
) : Serializable
@@ -0,0 +1,21 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import okhttp3.Interceptor
import okhttp3.Response
/**
* v1.1 接口专用拦截器
* /nutrition/neglect/ 路径无需鉴权,仅注入基础请求头
*/
class CabinetRequestInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-DEVICE-CODE", App.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
.build()
return chain.proceed(request)
}
}
@@ -0,0 +1,109 @@
package com.shuwei.intelligent.shelves.net
import android.annotation.SuppressLint
import android.util.Log
import com.shuwei.intelligent.shelves.BuildConfig
import com.shuwei.intelligent.shelves.GlobalData
import com.sw.scalefusion.shelf.net.ApiServiceV3
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.net.ssl.*
import java.security.SecureRandom
import java.security.cert.X509Certificate
import kotlin.apply
/** 动态域名拦截器:将 Retrofit 请求的 baseUrl 实时替换为 [GlobalData.appBaseUrl] */
class DynamicBaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newUrl = GlobalData.appBaseUrl.toHttpUrlOrNull() ?: return chain.proceed(request)
val url = request.url.newBuilder()
.scheme(newUrl.scheme)
.host(newUrl.host)
.port(newUrl.port)
.build()
return chain.proceed(request.newBuilder().url(url).build())
}
}
/** V3 接口专用实例 */
val apiServiceV3: ApiServiceV3 = Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl)
.client(HttpManager.instance.cabinetClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiServiceV3::class.java)
class HttpManager private constructor() {
/** 接口专用 OkHttpClient,注入 Content-Type、Accept、X-DEVICE-CODE、authorization */
val cabinetClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.apply {
connectTimeout(30, TimeUnit.SECONDS)
readTimeout(30, TimeUnit.SECONDS)
writeTimeout(30, TimeUnit.SECONDS)
sslSocketFactory(createSSLSocketFactory(), TrustAllCerts())
hostnameVerifier { _, _ -> true }
addInterceptor(DynamicBaseUrlInterceptor())
addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Log.d("HttpManager", "v2 okhttp ==>${it}")
}).apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
addInterceptor(CabinetRequestInterceptor())
}
.build()
}
companion object {
val instance by lazy { HttpManager() }
}
// fun newCall(request: Request): Call = client.newCall(request)
// inner class LoggingInterceptor : Interceptor {
// override fun intercept(chain: Interceptor.Chain): Response {
// val request = chain.request()
// // 打印请求日志
// Log.d("HttpManager", "--> ${request.method},${request.url},${request.body}")
//
// val response = chain.proceed(request)
// // 打印响应日志
// Log.d("HttpManager", "<-- ${response.body}")
// return response
// }
// }
// 信任所有证书的TrustManager实现
@SuppressLint("CustomX509TrustManager")
class TrustAllCerts : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
// 创建信任所有证书的SSLSocketFactory
fun createSSLSocketFactory(): SSLSocketFactory {
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(TrustAllCerts()), SecureRandom())
}
return sslContext.socketFactory
}
}
@@ -0,0 +1,74 @@
package com.shuwei.intelligent.shelves.net
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.WindowManager
import androidx.core.graphics.drawable.toDrawable
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.DialogLoadingBinding
import kotlin.let
import kotlin.run
object Loading {
private var dialog: LoadingDialog? = null
fun show(activity: Activity) {
try {
if (activity.isFinishing || activity.isDestroyed) {
return
}
if (dialog?.isShowing == true) {
dialog?.dismiss()
}
if (dialog == null) {
dialog = LoadingDialog(activity)
}
dialog?.show()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun dismiss() {
try {
dialog?.let {
if (it.isShowing) {
it.dismiss()
}
}
dialog = null
} catch (e: Exception) {
e.printStackTrace()
}
}
}
class LoadingDialog(
context: Context,
message: String = "加载中……",
private var onDismiss: () -> Unit = {}
) : Dialog(context, R.style.LoadingDialog) {
init {
val binding = DialogLoadingBinding.inflate(LayoutInflater.from(context))
binding.tvMessage.text = message
setContentView(binding.root)
setCancelable(true)
setCanceledOnTouchOutside(true)
window?.run {
setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
// setFlags(
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
// )
}
setOnDismissListener {
onDismiss()
}
}
}
@@ -0,0 +1,11 @@
package com.shuwei.intelligent.shelves.net
data class RespData<T>(
// val success: Boolean = false,
val msg: String? = null,
val code: String? = null,
val data: T? = null,
// val total: Int
) {
fun isSuccess() = code == "00000"
}
@@ -0,0 +1,8 @@
package com.shuwei.intelligent.shelves.net
sealed class UiState {
object Initial : UiState()
object Loading : UiState()
data class Success<T>(val data: RespData<T>) : UiState()
data class Error(val msg: String) : UiState()
}
@@ -0,0 +1,37 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
object UrlConfig {
// const val DEVICE_BASE_URL = "http://device.shuziweidao.com:8889"
// /**
// * device获取token
// */
// const val DEVICE_TOKEN = "${DEVICE_BASE_URL}/sys/getEquipmentToken"
// /**
// *获取配置信息
// */
// const val DEVICE_CONFIG = "${DEVICE_BASE_URL}/equipment/stEquipment/queryByEquipmentCode"
// const val BASE_URL = "https://dev.yixiong-tech.com:8081"
//// const val BASE_URL = "https://yyjk.shuziweidao.com/gateway"
//// const val BASE_URL = "http://192.168.1.201:14801"
// const val DEVICE_BASE_URL = BASE_URL
var GET_ACCESS_TOKEN = "${GlobalData.appBaseUrl}/restaurant/equipment/stEquipment/getEquipmentToken"
var GET_SHELF_LIST =
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getShelvesListByDeviceId"
var GET_GOODS_LIST =
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/getGoodsPageList"
var SAVE_SHELF_GOODS_LIST =
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/saveShelvesGoodsList"
// v1.1 新接口
var CABINET_INIT = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/init"
var CLEAN_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/clean-package-options"
var MEAL_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/meal-package-options"
var CABINET_RECORD_SYNC = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/record/sync"
}
@@ -0,0 +1,24 @@
package com.shuwei.intelligent.shelves.serial
/**
* 心跳数据(命令 0x01 / 0x81,终端发起)
*
* @param scaleCount 秤的数量
* @param weights 各台秤的重量列表,索引从1开始
* @param simCardNo 物联卡号(20字节ASCII
* @param lock1Door 锁1门状态:0-关 1-开
* @param lock1Lock 锁1锁状态:0-关 1-开
* @param lock2Door 锁2门状态:0-关 1-开
* @param lock2Lock 锁2锁状态:0-关 1-开
* @param temperature 温度值(原始字节,单位由温控器决定)
*/
data class HeartbeatData(
val scaleCount: Int,
val weights: List<ScaleWeight>,
val simCardNo: String,
val lock1Door: Int,
val lock1Lock: Int,
val lock2Door: Int,
val lock2Lock: Int,
val temperature: Int
)
@@ -0,0 +1,198 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.App
/**
* 串口通信协议常量定义
* 统一管理所有协议头、协议尾、各类指令及帧解析偏移量
*/
object ProtocolConstants {
/** 协议头 */
const val HEADER = "D6DADEDB"
/** 协议尾 */
const val FOOTER = "FFFF"
// ----------------------------------------------------------------
// 指令码(cmd 字段,4字符)
// ----------------------------------------------------------------
/** 终端激活指令 */
const val CMD_ACTIVATE = "0001"
/** 心跳指令(服务端发起) */
const val CMD_HEARTBEAT = "0101"
/** 心跳指令(终端发起) */
const val CMD_HEARTBEAT_TERMINAL = "8101"
/** 开关锁反馈 */
const val CMD_LOCK_FEEDBACK = "0202"
/** 获取设备状态 */
const val CMD_DEVICE_STATUS = "0302"
/** 卡号上发 */
const val CMD_CARD_UPLOAD = "0501"
/** 操作记录上传 */
const val CMD_OPERATION_RECORD = "0B01"
/** 对时 */
const val CMD_TIME_SYNC = "0C01"
/** 终端位置上报 */
const val CMD_POSITION_REPORT = "0E01"
/** 温控信息上报 */
const val CMD_TEMP_REPORT = "1001"
/** 温控信息应答 */
const val CMD_TEMP_RESPONSE = "1002"
/** 温控应答(备用) */
const val CMD_TEMP_RESPONSE_ALT = "0F02"
// ----------------------------------------------------------------
// 帧数据偏移 / 尺寸常量
// ----------------------------------------------------------------
/** 心跳帧中重量数据起始偏移(相对帧起始) */
const val HEARTBEAT_WEIGHT_OFFSET = 14
/** 设备状态帧中重量数据起始偏移(含协议头长度) */
const val DEVICE_STATUS_WEIGHT_OFFSET = HEADER.length + 14
/** 货架数量(每侧5格,共10格,协议中12组) */
fun shelfSensorCount(): Int {
return if (device2Columns.contains(App.deviceId)) 12
else if (device3Columns.contains(App.deviceId)) 24
else 0
}
/** 每组重量数据字节数(十六进制字符数) */
const val BYTES_PER_WEIGHT = 8
/** 温度字段字符长度 */
const val TEMPERATURE_FIELD_LEN = 2
/** 心跳帧最小有效长度 */
fun heartbeatMinLength(): Int {
return if (device2Columns.contains(App.deviceId)) 200
else if (device3Columns.contains(App.deviceId)) 296
else 0
}
/** 心跳帧中温度字段起始偏移(经协议分析得出,前面含重量+其他传感器数据) */
fun heartbeatTemperatureStart(): Int {
return if (device2Columns.contains(App.deviceId)) 158
else if (device3Columns.contains(App.deviceId)) 158+96
else 0
}
/** 激活请求体有效长度 */
const val ACTIVATE_BODY_LENGTH = 44
/** 固定数据体有效长度(terminalId 30 + terminalVersion 10 + 其他 2 */
const val FIX_BODY_LENGTH = 42
// ----------------------------------------------------------------
// 完整指令
// ----------------------------------------------------------------
/** 激活指令(1楼餐厅) */
// const val ACTIVE_CMD =
// "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}"
// 410激活码
// const val ACTIVE_CMD = "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}"
/** 左货架开锁指令 */
const val LEFT_SHELF_OPEN_CMD =
"${HEADER}020101000000000000000000000000000000000000${FOOTER}"
/** 右货架开锁指令 */
const val RIGHT_SHELF_OPEN_CMD =
"${HEADER}020100010000000000000000000000000000000000${FOOTER}"
/** 获取设备信息指令 */
const val DEVICE_INFO_CMD = "${HEADER}0301000000000000000000000000${FOOTER}"
/** 启动温控指令 */
const val START_TEMP_CTRL_CMD = "${HEADER}0F01360000${FOOTER}"
/** 华氏温度单位指令 */
const val F_TEMP_CMD = "${HEADER}0F01390000${FOOTER}"
/** 摄氏温度单位指令 */
const val C_TEMP_CMD = "${HEADER}0F01390001${FOOTER}"
/** 关灯指令(继电器1关) */
const val LIGHT_CLOSE = "${HEADER}020102020102020000000000000000000000000000${FOOTER}"
/** 开灯指令(继电器1开) */
const val LIGHT_OPEN = "${HEADER}020102020002020000000000000000000000000000${FOOTER}"
/** 温控指令 */
const val TEMPERATURE_CTRL = "${HEADER}0F01040000${FOOTER}"
/**
* 设备2列,10格
*/
val device2Columns = listOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751",
// TODO: 测试设备----------
"2987f0c5-5754-33e9-b00a-251db5e2e55f"
)
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
val device3ColumnsOrder = listOf(1, 6, 7, 2, 8, 9, 3, 10, 11, 4, 12, 13, 5, 14, 15)
/**
* 设备2列,10格,左侧1列竖向编号1、2、3、4、5,右侧1列6,7,8,9,10
*/
val device2ColumnsOrder = listOf(1, 6, 2, 7, 3, 8, 4, 9, 5, 10)
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
val device3Columns = listOf(
//新设备1
"d369f9b0-1c5b-3066-ba72-988e1449cecc",
//新设备2
"505ce1b1-facc-3eb2-855a-3dadda5ba358"
)
/**
* 设备 cabinetType 映射,用于 V3 API
* 1=毛菜柜,2=净菜柜,默认 2
*/
val deviceCabinetType = mapOf(
// 原410货柜 → 毛菜柜
"7a991439-3a12-3ef7-809b-c0258b839473" to 1,
// 原1楼餐厅货柜 → 净菜柜
"4787e213-90ab-3e32-88e0-ac271a937751" to 2,
// 新设备 → 净菜柜
"d369f9b0-1c5b-3066-ba72-988e1449cecc" to 2,
"505ce1b1-facc-3eb2-855a-3dadda5ba358" to 2,
)
fun cabinetType(): Int = deviceCabinetType[App.deviceId] ?: 2
/** 激活指令mapkey为设备号,value为激活码 */
val ACTIVE_MAP = mutableMapOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473" to "${HEADER}000281C5D70A7D42894EA17650F5D0BB516838E7863C9ABD766C38958AFF0F3C062D${FOOTER}",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
//新设备1,临时使用1楼餐厅激活码
"d369f9b0-1c5b-3066-ba72-988e1449cecc" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
//新设备2,临时使用1楼餐厅激活码
"505ce1b1-facc-3eb2-855a-3dadda5ba358" to "${HEADER}000281C5D70A7D428B44A57454F5DACA241938938B4B9CBD73673E9C8E7FC0C4992D${FOOTER}",
)
}
@@ -0,0 +1,42 @@
package com.shuwei.intelligent.shelves.serial
/**
* 协议帧通用数据结构
* 每一帧解析后的原始信息,所有命令共用此结构
*
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
* @param flag 标志字节
* @param rawBody 数据体原始字节(不含协议头尾、命令、标志、固定数据体)
* @param terminalId 终端ID15字节),ASCII字符串
* @param firmwareVersion 固件版本(5字节),ASCII字符串
* @param cmdCount 命令次数
*/
data class ScaleFrame(
val cmd: Byte,
val flag: Byte,
val rawBody: ByteArray,
val terminalId: String,
val firmwareVersion: String,
val cmdCount: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ScaleFrame) return false
return cmd == other.cmd &&
flag == other.flag &&
rawBody.contentEquals(other.rawBody) &&
terminalId == other.terminalId &&
firmwareVersion == other.firmwareVersion &&
cmdCount == other.cmdCount
}
override fun hashCode(): Int {
var result = cmd.toInt()
result = 31 * result + flag.toInt()
result = 31 * result + rawBody.contentHashCode()
result = 31 * result + terminalId.hashCode()
result = 31 * result + firmwareVersion.hashCode()
result = 31 * result + cmdCount
return result
}
}
@@ -0,0 +1,281 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BODY_OFFSET
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.BYTES_PER_WEIGHT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIRMWARE_VERSION_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FIXED_BODY_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.SIM_CARD_NO_LEN
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.TERMINAL_ID_LEN
import com.shuwei.intelligent.shelves.utils.hexToBytes
/**
* 协议帧组装与解析器
*
* 职责一:将串口原始分片十六进制字符串拼装为完整协议帧
* 职责二:将完整帧解析为 ScaleFrame,自动提取固定数据体字段
* 职责三:提供心跳、状态等数据体的具体解析方法
* 职责四:提供重量字节解析工具(BCD编码、小端序、最高位符号位)
*
* 设计为无业务依赖,可独立复制到其他项目使用
*/
class ScaleFrameParser {
/** 分片拼装缓冲区 */
private val buffer = StringBuilder()
// ----------------------------------------------------------------
// 一、帧组装(串口分片 -> 完整帧十六进制字符串)
// ----------------------------------------------------------------
/**
* 追加串口原始分片数据,尝试拼装为完整帧
*
* @param srcData 串口收到的原始十六进制字符串片段
* @return 完整协议帧字符串;帧尚不完整时返回 null
*/
fun appendData(srcData: String): String? {
val hasHeader = srcData.contains(HEADER_HEX)
val hasFooter = srcData.contains(FOOTER_HEX)
return when {
hasHeader && hasFooter -> parseCompleteInSingleChunk(srcData)
hasHeader && !hasFooter -> {
// 只有帧头,等待后续分片
buffer.clear()
buffer.append(srcData.substring(srcData.lastIndexOf(HEADER_HEX))).append(",")
null
}
!hasHeader && !hasFooter -> {
// 中间分片,追加缓冲
buffer.append(srcData).append(",")
null
}
else -> {
// 只有帧尾,与缓冲区拼接成完整帧
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
buffer.append(srcData.substring(0, endIndex))
val frame = buffer.toString().replace(",", "")
buffer.clear()
frame.takeIf { it.isNotEmpty() }
}
}
}
/**
* 处理单个分片中同时含有帧头和帧尾的情况
*/
private fun parseCompleteInSingleChunk(srcData: String): String? {
buffer.clear()
val startIndex = srcData.lastIndexOf(HEADER_HEX)
val endIndex = srcData.indexOf(FOOTER_HEX) + FOOTER_HEX.length
return if (endIndex <= startIndex) {
// 帧尾在帧头之前:是上一帧的残留尾部 + 新帧头部
val partial = srcData.substring(startIndex)
if (partial.contains(FOOTER_HEX)) {
val newEnd = partial.indexOf(FOOTER_HEX) + FOOTER_HEX.length
partial.substring(0, newEnd)
} else {
buffer.append(partial).append(",")
null
}
} else {
// 正常完整帧
srcData.substring(startIndex, endIndex)
}
}
/** 清空分片缓冲区 */
fun reset() {
buffer.clear()
}
// ----------------------------------------------------------------
// 二、帧解析(完整帧十六进制字符串 -> ScaleFrame
// ----------------------------------------------------------------
/**
* 解析完整协议帧为 ScaleFrame
*
* 帧结构:[协议头4] [CMD1] [FLAG1] [数据体N] [终端ID15] [固件版本5] [命令次数1] [协议尾2]
*
* @param hexFrame 完整帧十六进制字符串(含协议头尾)
* @return 解析成功返回 ScaleFrame;校验失败或格式错误返回 null
*/
fun parseFrame(hexFrame: String): ScaleFrame? {
// 最小帧:协议头(8) + cmd(2) + flag(2) + 固定数据体(42) + 协议尾(4) = 58 字符
if (hexFrame.length < 58) return null
if (!hexFrame.startsWith(HEADER_HEX) || !hexFrame.endsWith(FOOTER_HEX)) return null
return runCatching {
val bytes = hexFrame.hexToBytes()
val totalLen = bytes.size
val cmd = bytes[ScaleProtocolConstants.HEADER_LEN]
val flag = bytes[ScaleProtocolConstants.HEADER_LEN + 1]
// 固定数据体位于协议尾之前
val fixedBodyStart = totalLen - FOOTER_LEN - FIXED_BODY_LEN
if (fixedBodyStart < BODY_OFFSET) return null
val fixedBody = bytes.copyOfRange(fixedBodyStart, totalLen - FOOTER_LEN)
// 解析固定数据体三个字段
val terminalId = String(fixedBody, 0, TERMINAL_ID_LEN, Charsets.US_ASCII)
val firmwareVersion = String(fixedBody, TERMINAL_ID_LEN, FIRMWARE_VERSION_LEN, Charsets.US_ASCII)
val cmdCount = fixedBody[TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN].toInt() and 0xFF
// 命令专属数据体(固定数据体和协议头尾之间的部分)
val rawBody = bytes.copyOfRange(BODY_OFFSET, fixedBodyStart)
ScaleFrame(
cmd = cmd,
flag = flag,
rawBody = rawBody,
terminalId = terminalId,
firmwareVersion = firmwareVersion,
cmdCount = cmdCount
)
}.getOrNull()
}
// ----------------------------------------------------------------
// 三、数据体解析
// ----------------------------------------------------------------
/**
* 解析心跳数据体(命令 0x01 / 0x81,终端发起)
*
* rawBody 格式:
* [秤数量 1] [秤重量 N×4] [物联卡号 20] [锁1门 1] [锁1锁 1] [锁2门 1] [锁2锁 1] [温度 1]
*
* @param body ScaleFrame.rawBody
* @return 解析成功返回 HeartbeatData;数据不足返回 null
*/
fun parseHeartbeat(body: ByteArray): HeartbeatData? {
if (body.isEmpty()) return null
return runCatching {
var offset = 0
val scaleCount = body[offset++].toInt() and 0xFF
val minBodyLen = 1 + scaleCount * BYTES_PER_WEIGHT + SIM_CARD_NO_LEN + 4 + 1
if (body.size < minBodyLen) return null
val weights = (1..scaleCount).map { index ->
val grams = parseWeightBytes(body, offset)
offset += BYTES_PER_WEIGHT
ScaleWeight(index, grams)
}
// 物联卡号:ASCII字符串,去除尾部空字符
val simCardNo = String(body, offset, SIM_CARD_NO_LEN, Charsets.US_ASCII)
.trimEnd('\u0000').trim()
offset += SIM_CARD_NO_LEN
val lock1Door = body[offset++].toInt() and 0xFF
val lock1Lock = body[offset++].toInt() and 0xFF
val lock2Door = body[offset++].toInt() and 0xFF
val lock2Lock = body[offset++].toInt() and 0xFF
val temperature = body[offset].toInt() and 0xFF
HeartbeatData(
scaleCount = scaleCount,
weights = weights,
simCardNo = simCardNo,
lock1Door = lock1Door,
lock1Lock = lock1Lock,
lock2Door = lock2Door,
lock2Lock = lock2Lock,
temperature = temperature
)
}.getOrNull()
}
/**
* 解析获取状态响应数据体(命令 0x03,终端响应)
*
* rawBody 格式:
* [锁1锁 1] [锁1门 1] [锁2锁 1] [锁2门 1] [秤数量 1] [秤重量 N×4] [温度 1]
*
* @param body ScaleFrame.rawBody
* @return 解析成功返回 StatusData;数据不足返回 null
*/
fun parseStatus(body: ByteArray): StatusData? {
if (body.size < 6) return null
return runCatching {
var offset = 0
val lock1Lock = body[offset++].toInt() and 0xFF
val lock1Door = body[offset++].toInt() and 0xFF
val lock2Lock = body[offset++].toInt() and 0xFF
val lock2Door = body[offset++].toInt() and 0xFF
val scaleCount = body[offset++].toInt() and 0xFF
val minBodyLen = 5 + scaleCount * BYTES_PER_WEIGHT + 1
if (body.size < minBodyLen) return null
val weights = (1..scaleCount).map { index ->
val grams = parseWeightBytes(body, offset)
offset += BYTES_PER_WEIGHT
ScaleWeight(index, grams)
}
val temperature = body[offset].toInt() and 0xFF
StatusData(
lock1Lock = lock1Lock,
lock1Door = lock1Door,
lock2Lock = lock2Lock,
lock2Door = lock2Door,
scaleCount = scaleCount,
weights = weights,
temperature = temperature
)
}.getOrNull()
}
// ----------------------------------------------------------------
// 四、重量解析工具
// ----------------------------------------------------------------
/**
* 解析4字节小端序重量数据
*
* 编码规则(BCD + 小端序 + 最高位符号位):
* - 4字节小端序存储,低字节在前,高字节在后
* - 高字节最高位(bit7)为符号位:0=正数,1=负数
* - 其余位组成8位BCD十进制字符串,作为十进制整数读取,单位为克
*
* 示例:
* [0x13, 0x10, 0x00, 0x00] → 大端序 "00001013" → 十进制 1013g = 1.013KG
* [0x13, 0x01, 0x02, 0x00] → 大端序 "00020113" → 十进制 2113g = 20.113KG(注:原例实为20113g,见下)
* [0x13, 0x10, 0x00, 0x80] → 大端序 "80001013" → 符号位=1 → 十进制 -1013g = -1.013KG
*
* 注意:大端序十六进制字符串按十进制解析(非十六进制),这是BCD编码的关键
*
* @param bytes 字节数组
* @param offset 起始偏移量(连续4字节)
* @return 重量(克),负数表示低于去皮零点
*/
fun parseWeightBytes(bytes: ByteArray, offset: Int): Int {
val b0 = bytes[offset].toInt() and 0xFF // 最低字节
val b1 = bytes[offset + 1].toInt() and 0xFF
val b2 = bytes[offset + 2].toInt() and 0xFF
val b3 = bytes[offset + 3].toInt() and 0xFF // 最高字节(含符号位)
// 检查最高字节的最高位是否为1(负数标志)
val isNegative = (b3 and 0x80) != 0
val b3Cleared = b3 and 0x7F // 清除符号位
// 构造大端序8位十六进制字符串
val hexStr = "%02X%02X%02X%02X".format(b3Cleared, b2, b1, b0)
// 将十六进制字符串作为十进制整数解析(BCD解码)
// "00001013" → toLong() = 1013,而非 toLong(16) = 4115
val absValue = hexStr.toLongOrNull() ?: 0L
return if (isNegative) -absValue.toInt() else absValue.toInt()
}
}
@@ -0,0 +1,589 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_ACTIVATE
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_CARD_UPLOAD
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_GET_STATUS
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_HEARTBEAT_STORE
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_OPERATION_RECORD
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_POSITION_REPORT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TEMP_REPORT
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_TIME_SYNC
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.CMD_UNLOCK
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.FOOTER_HEX
import com.shuwei.intelligent.shelves.serial.ScaleProtocolConstants.HEADER_HEX
import com.shuwei.intelligent.shelves.utils.hexToBytes
import com.shuwei.intelligent.shelves.utils.toHexString2
import io.github.jeadyx.jserialport.AndroidSerialPort
import io.github.jeadyx.jserialport.SerialPort
import io.github.jeadyx.jserialport.SerialPortFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
import java.util.TimeZone
/**
* 称重控制板管理器(单例入口)
*
* 职责:
* - 管理串口连接生命周期(start/release
* - 通过内部发送队列(Channel)保证指令按顺序写入串口
* - 接收串口原始分片数据,自动拼装帧并分发到对应回调
* - 内置各命令的自动应答逻辑(心跳/对时/卡号等)
* - 提供链式回调注册 和 常用指令构建
*
* 典型用法:
* ```kotlin
* lifecycleScope.launch {
* ScaleManager
* .init(activateCode = ACTIVE_MAP[deviceId] ?: "")
* .onHeartbeat { _, data ->
* data.weights.forEach { Log.d("Scale", "秤${it.index}: ${it.grams}g") }
* }
* .onLog { msg -> Log.d("Scale", msg) }
* .start { data ->
* // 主线程前置钩子,处理看门狗计时等副作用
* }
* }
*
* // 主动发送指令
* ScaleManager.sendCmd(ScaleManager.buildGetStatusCmd())
*
* // 页面销毁时释放(关闭串口 + 清空队列 + 清理回调)
* runBlocking { ScaleManager.release() }
* ```
*/
object ScaleManager {
/** 串口端口名,默认 /dev/ttyS1 */
var portName: String = "/dev/ttyS1"
/** 串口波特率,默认 115200 */
var baudRate: Int = 115200
private val parser = ScaleFrameParser()
private var serialPort: AndroidSerialPort? = null
private var scope = CoroutineScope(Dispatchers.IO)
/** 发送队列:保证串口写入顺序,避免并发写冲突 */
private val sendChannel = Channel<String>(capacity = Channel.UNLIMITED)
/** 当前设备激活码,非空时收到激活请求自动应答 */
private var activateCode: String = ""
/** 心跳应答:心跳上报间隔(秒),默认1秒,保证重量更新实时性 */
var heartbeatInterval: Int = 1
/** 心跳应答:开锁后未开门自动落锁时间(秒),默认6秒 */
var lockTimeout: Int = 6
// 各命令监听列表
private val heartbeatListeners = mutableListOf<(ScaleFrame, HeartbeatData) -> Unit>()
private val statusListeners = mutableListOf<(ScaleFrame, StatusData) -> Unit>()
private val lockListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val cardListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val operationListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val timeSyncListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val positionListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val tempReportListeners = mutableListOf<(ScaleFrame) -> Unit>()
private val activateListeners = mutableListOf<(ScaleFrame) -> Unit>()
/** 任意命令的原始帧监听,用于协议中尚未提供专项解析的命令 */
private val rawListeners = mutableMapOf<Byte, MutableList<(ScaleFrame) -> Unit>>()
private val logListeners = mutableListOf<(String) -> Unit>()
// ----------------------------------------------------------------
// 初始化
// ----------------------------------------------------------------
/**
* 初始化,设置设备激活码
*
* @param activateCode 当前设备的激活应答帧十六进制字符串,非空时收到激活请求自动应答
* @return this,支持链式调用
*/
fun init(activateCode: String = ""): ScaleManager {
this.activateCode = activateCode
return this
}
// ----------------------------------------------------------------
// 串口生命周期
// ----------------------------------------------------------------
/**
* 打开串口并启动接收,在回调注册完成后调用(链式末尾)
*
* @param preHook 每帧数据到达时的前置钩子(主线程),用于看门狗计时、UI副作用等
* @return true=串口打开成功并已启动接收,false=串口打开失败
*/
suspend fun start(preHook: ((String) -> Unit)? = null): Boolean {
if (!openPort()) return false
startReceive(preHook)
return true
}
private suspend fun openPort(): Boolean {
if (!scope.isActive) {
scope = CoroutineScope(Dispatchers.IO)
}
return try {
serialPort = SerialPortFactory.create().apply {
open(
portName = portName,
baudRate = baudRate,
dataBits = 8,
stopBits = 1,
parity = SerialPort.PARITY_NONE
)
} as AndroidSerialPort?
// 启动单一消费者协程,顺序消费发送队列
scope.launch {
for (hexStr in sendChannel) {
try {
serialPort?.write(hexStr.hexToBytes())
} catch (e: Exception) {
e.printStackTrace()
log("发送命令失败: ${e.message}")
}
}
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
private fun startReceive(preHook: ((String) -> Unit)? = null) {
scope.launch {
while (isActive) {
try {
val flow = serialPort?.read()
flow?.collect { buffer ->
val data = buffer.toHexString2().uppercase(Locale.ROOT)
// IO 线程:帧拼装与解析
val parsed = tryParseCompleteFrame(data)
if (parsed != null) {
// IO 线程:发送自动应答(不占用主线程)
sendAutoResponse(parsed.first)
// IO 线程:心跳日志降频
if (parsed.first.cmd == CMD_HEARTBEAT || parsed.first.cmd == CMD_HEARTBEAT_STORE) {
logHeartbeatIfNeeded(parsed.first, parsed.second)
}
}
// 仅回调分发和 preHook 需要主线程
withContext(Dispatchers.Main) {
try {
preHook?.invoke(data)
if (parsed != null) {
invokeCallbacks(parsed.first)
}
} catch (e: Exception) {
e.printStackTrace()
log("串口数据处理异常: ${e.message}")
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
delay(10)
}
}
}
// ----------------------------------------------------------------
// 数据接收入口
// ----------------------------------------------------------------
/**
* 接收串口原始数据(公开 API,主线程安全)
*
* 内部自动完成分片拼装 → 帧解析 → 自动应答 → 回调分发
*
* @param hexData 串口收到的十六进制字符串(可能为分片数据)
*/
fun onDataReceived(hexData: String) {
val parsed = tryParseCompleteFrame(hexData) ?: return
sendAutoResponse(parsed.first)
invokeCallbacks(parsed.first)
}
/**
* IO 线程安全的帧拼装与解析(纯计算,无副作用)
*
* @return Pair<ScaleFrame, rawHex> 或 null(分片未完整/解析失败)
*/
private fun tryParseCompleteFrame(hexData: String): Pair<ScaleFrame, String>? {
val completeFrame = parser.appendData(hexData) ?: return null
val frame = parser.parseFrame(completeFrame)
if (frame == null) {
log("帧解析失败,原始数据:$completeFrame")
}
return frame?.let { it to completeFrame }
}
/**
* IO 线程:发送协议自动应答(不阻塞主线程)
*/
private fun sendAutoResponse(frame: ScaleFrame) {
when (frame.cmd) {
CMD_HEARTBEAT, CMD_HEARTBEAT_STORE -> {
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}0102%02X%02X${firmwareHex}${FOOTER_HEX}".format(
heartbeatInterval and 0xFF, lockTimeout and 0xFF
)
sendCmd(resp)
}
CMD_UNLOCK -> {
val terminalIdHex = frame.terminalId.toAsciiHex()
val firmwareHex = frame.firmwareVersion.toAsciiHex()
sendCmd("${HEADER_HEX}020200${terminalIdHex}${firmwareHex}${FOOTER_HEX}")
}
CMD_CARD_UPLOAD -> sendCmd("${HEADER_HEX}050203${FOOTER_HEX}")
CMD_OPERATION_RECORD -> sendCmd("${HEADER_HEX}0B0203${FOOTER_HEX}")
CMD_TEMP_REPORT -> sendCmd("${HEADER_HEX}100200${FOOTER_HEX}")
CMD_POSITION_REPORT -> sendCmd("${HEADER_HEX}0E0200${FOOTER_HEX}")
CMD_TIME_SYNC -> {
val nowMs = System.currentTimeMillis()
val timeSec = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
val timeHex = "%08X".format(timeSec.toInt())
.chunked(2).reversed().joinToString("")
sendCmd("${HEADER_HEX}0C02${timeHex}${FOOTER_HEX}")
}
CMD_ACTIVATE -> {
if (activateCode.isNotBlank()) sendCmd(activateCode)
}
}
}
/**
* 主线程:仅回调分发(不含 sendCmd 和日志)
*/
private fun invokeCallbacks(frame: ScaleFrame) {
when (frame.cmd) {
CMD_ACTIVATE -> {
activateListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_HEARTBEAT, CMD_HEARTBEAT_STORE -> {
val data = parser.parseHeartbeat(frame.rawBody)
if (data != null) {
heartbeatListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_UNLOCK -> {
lockListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_GET_STATUS -> {
val data = parser.parseStatus(frame.rawBody)
if (data != null) {
statusListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_CARD_UPLOAD -> {
cardListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_OPERATION_RECORD -> {
operationListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_TIME_SYNC -> {
timeSyncListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_POSITION_REPORT -> {
positionListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_TEMP_REPORT -> {
tempReportListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
else -> {
rawListeners[frame.cmd]?.forEach { it(frame) }
}
}
}
// ----------------------------------------------------------------
// 回调注册(链式调用)
// ----------------------------------------------------------------
/**
* 注册心跳回调(命令 0x01 / 0x81,终端发起)
* 回调参数:[ScaleFrame 帧信息] [HeartbeatData 心跳数据(含重量、锁状态、温度)]
*/
fun onHeartbeat(listener: (ScaleFrame, HeartbeatData) -> Unit): ScaleManager {
heartbeatListeners.add(listener)
return this
}
/**
* 注册获取状态回调(命令 0x03,服务端请求后终端响应)
* 回调参数:[ScaleFrame 帧信息] [StatusData 状态数据(含重量、锁状态、温度)]
*/
fun onStatus(listener: (ScaleFrame, StatusData) -> Unit): ScaleManager {
statusListeners.add(listener)
return this
}
/**
* 注册开锁反馈回调(命令 0x02,终端发起,关门后上报)
* 回调参数:[ScaleFrame 帧信息],锁状态等信息在 ScaleFrame.rawBody 中
*/
fun onLockFeedback(listener: (ScaleFrame) -> Unit): ScaleManager {
lockListeners.add(listener)
return this
}
/**
* 注册卡号上发回调(命令 0x05,终端发起)
* 回调参数:[ScaleFrame 帧信息],卡号等信息在 ScaleFrame.rawBody 中
*/
fun onCardUpload(listener: (ScaleFrame) -> Unit): ScaleManager {
cardListeners.add(listener)
return this
}
/**
* 注册操作记录上传回调(命令 0x0B,终端发起)
* 回调参数:[ScaleFrame 帧信息],操作记录在 ScaleFrame.rawBody 中
*/
fun onOperationRecord(listener: (ScaleFrame) -> Unit): ScaleManager {
operationListeners.add(listener)
return this
}
/**
* 注册对时回调(命令 0x0C,终端发起)
* 管理器已自动回复当前时间戳,此回调用于业务侧感知对时事件
*/
fun onTimeSync(listener: (ScaleFrame) -> Unit): ScaleManager {
timeSyncListeners.add(listener)
return this
}
/**
* 注册终端位置上报回调(命令 0x0E,终端发起,仅4G版本)
* 回调参数:[ScaleFrame 帧信息],基站信息(MCC/MNC/LAC/CI)在 ScaleFrame.rawBody 中
*/
fun onPositionReport(listener: (ScaleFrame) -> Unit): ScaleManager {
positionListeners.add(listener)
return this
}
/**
* 注册温控信息上报回调(命令 0x10,终端发起)
* 回调参数:[ScaleFrame 帧信息],寄存器内容在 ScaleFrame.rawBody 中
*/
fun onTempReport(listener: (ScaleFrame) -> Unit): ScaleManager {
tempReportListeners.add(listener)
return this
}
/**
* 注册终端激活回调(命令 0x00,终端发起)
* 注意:激活应答(含激活码)需在此回调中手动调用 sendCmd() 发送
*/
fun onActivate(listener: (ScaleFrame) -> Unit): ScaleManager {
activateListeners.add(listener)
return this
}
/**
* 注册任意命令的原始帧监听(用于协议中尚未专项解析的命令,或扩展监听)
*
* @param cmd 命令字节,对应 ScaleProtocolConstants.CMD_* 常量
*/
fun onRawFrame(cmd: Byte, listener: (ScaleFrame) -> Unit): ScaleManager {
rawListeners.getOrPut(cmd) { mutableListOf() }.add(listener)
return this
}
/**
* 注册日志回调,所有内部日志通过此回调输出
*/
fun onLog(listener: (String) -> Unit): ScaleManager {
logListeners.add(listener)
return this
}
// ----------------------------------------------------------------
// 发送指令
// ----------------------------------------------------------------
/**
* 发送十六进制字符串指令,指令进入发送队列按顺序写入串口
*
* @param hexStr 完整帧十六进制字符串,含协议头尾
*/
fun sendCmd(hexStr: String) {
if (!scope.isActive) {
log("警告:ScaleManager 串口未打开或已释放")
return
}
sendChannel.trySend(hexStr)
}
// ----------------------------------------------------------------
// 常用指令构建
// ----------------------------------------------------------------
/**
* 构建获取设备状态指令
* 协议:CMD=03 FLAG=01 [预留12字节]
*/
fun buildGetStatusCmd(): String =
"${HEADER_HEX}0301${"00".repeat(12)}${FOOTER_HEX}"
/**
* 构建开锁/控制指令
*
* @param lock1 锁1操作:1=开 0=关 其他值(如0xFF)=不动作
* @param lock2 锁2操作:1=开 0=关 其他值=不动作
* @param relay1 继电器11=开 0=关 其他值=不动作
* @param relay2 继电器21=开 0=关 其他值=不动作
* @param relay3 继电器31=开 0=关 其他值=不动作
* @param orderId 订单号,超出14字节截断,不足补零
*/
fun buildUnlockCmd(
lock1: Int = 0xFF,
lock2: Int = 0xFF,
relay1: Int = 0xFF,
relay2: Int = 0xFF,
relay3: Int = 0xFF,
orderId: String = ""
): String {
val orderBytes = orderId.toByteArray(Charsets.US_ASCII).copyOf(14)
val orderHex = orderBytes.joinToString("") { "%02X".format(it) }
return "${HEADER_HEX}0201" +
"%02X%02X%02X%02X%02X".format(lock1 and 0xFF, lock2 and 0xFF, relay1 and 0xFF, relay2 and 0xFF, relay3 and 0xFF) +
orderHex +
FOOTER_HEX
}
/**
* 构建开锁1指令(左门)
* @param orderId 订单号,可选
*/
fun buildOpenLock1Cmd(orderId: String = ""): String =
buildUnlockCmd(lock1 = 1, lock2 = 0xFF, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
/**
* 构建开锁2指令(右门)
* @param orderId 订单号,可选
*/
fun buildOpenLock2Cmd(orderId: String = ""): String =
buildUnlockCmd(lock1 = 0xFF, lock2 = 1, relay1 = 0xFF, relay2 = 0xFF, relay3 = 0xFF, orderId = orderId)
/**
* 构建秤置零指令
* 协议:CMD=04 FLAG=01 [秤号1字节]
*
* @param scaleIndex 秤号,从1开始
*/
fun buildScaleZeroCmd(scaleIndex: Int): String =
"${HEADER_HEX}0401%02X${FOOTER_HEX}".format(scaleIndex and 0xFF)
/**
* 构建秤标定指令
* 协议:CMD=06 FLAG=01 [秤号1字节] [砝码值4字节小端序]
*
* @param scaleIndex 秤号,从1开始
* @param weightGrams 砝码重量(克),如10kg砝码传入10000
*/
fun buildScaleCalibrateCmd(scaleIndex: Int, weightGrams: Int): String {
// 砝码值转小端序4字节(BCD编码:克数按十进制写入十六进制位)
val weightHex = "%08d".format(weightGrams) // BCD:十进制值直接作为十六进制字符串
val leHex = weightHex.chunked(2).reversed().joinToString("")
return "${HEADER_HEX}0601%02X${leHex}${FOOTER_HEX}".format(scaleIndex and 0xFF)
}
/**
* 构建温控器控制指令
* 协议:CMD=0F FLAG=01 [操作编码1字节] [设定值2字节]
*
* @param opCode 操作编码(参考温控器说明书)
* @param setValue 设定值
*/
fun buildTempCtrlCmd(opCode: Int, setValue: Int): String =
"${HEADER_HEX}0F01%02X%04X${FOOTER_HEX}".format(opCode and 0xFF, setValue and 0xFFFF)
/**
* 构建临时参数设置指令
* 协议:CMD=11 FLAG=01 [参数编码1字节] [设定值1字节]
*
* @param paramCode 参数编码(1=关门语音触发条件 2=未关门语音提示 3=锁常开设置)
* @param value 设定值
*/
fun buildTempParamSetCmd(paramCode: Int, value: Int): String =
"${HEADER_HEX}1101%02X%02X${FOOTER_HEX}".format(paramCode and 0xFF, value and 0xFF)
// ----------------------------------------------------------------
// 资源释放
// ----------------------------------------------------------------
/**
* 释放所有资源:清空回调列表、发送函数及帧缓冲
* 通常在 Activity/Fragment 销毁或不再使用时调用
*/
suspend fun release() {
sendChannel.close()
scope.cancel()
serialPort?.close()
serialPort = null
heartbeatListeners.clear()
statusListeners.clear()
lockListeners.clear()
cardListeners.clear()
operationListeners.clear()
timeSyncListeners.clear()
positionListeners.clear()
tempReportListeners.clear()
activateListeners.clear()
rawListeners.clear()
logListeners.clear()
activateCode = ""
lastHeartbeatLogSecond = 0
parser.reset()
}
// ----------------------------------------------------------------
// 日志与工具
// ----------------------------------------------------------------
/** 上次心跳日志时间(秒),用于降频:同一秒内不重复记录心跳日志 */
private var lastHeartbeatLogSecond: Long = 0
/**
* IO 线程:心跳日志降频 — 每秒最多记录一次
*/
private fun logHeartbeatIfNeeded(frame: ScaleFrame, rawHex: String) {
val nowSec = System.currentTimeMillis() / 1000
if (nowSec == lastHeartbeatLogSecond) return
lastHeartbeatLogSecond = nowSec
val data = parser.parseHeartbeat(frame.rawBody)
log("收到[心跳]指令,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
}
private fun log(message: String) {
logListeners.forEach { it(message) }
}
/**
* 将 ASCII 字符串转为十六进制字符串,用于指令构造
* 例:"V2.10" → "56322E3130"
*/
private fun String.toAsciiHex(): String =
toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
}
@@ -0,0 +1,121 @@
package com.shuwei.intelligent.shelves.serial
/**
* 称重控制板接口协议常量
* 统一管理协议头尾、所有命令字节及帧结构偏移量
* 设计为独立常量对象,不依赖任何业务类,可复制到其他项目直接使用
*/
object ScaleProtocolConstants {
// ----------------------------------------------------------------
// 协议头尾
// ----------------------------------------------------------------
/** 协议头字节数组 */
val HEADER: ByteArray = byteArrayOf(0xD6.toByte(), 0xDA.toByte(), 0xDE.toByte(), 0xDB.toByte())
/** 协议头十六进制字符串 */
const val HEADER_HEX: String = "D6DADEDB"
/** 协议尾字节数组 */
val FOOTER: ByteArray = byteArrayOf(0xFF.toByte(), 0xFF.toByte())
/** 协议尾十六进制字符串 */
const val FOOTER_HEX: String = "FFFF"
// ----------------------------------------------------------------
// 命令字节(cmd 字段,1字节)
// 发起方说明:终端=秤控制板主动上报;服务端=Android端主动发送
// ----------------------------------------------------------------
/** 0x00 终端激活(终端发起) */
const val CMD_ACTIVATE: Byte = 0x00
/** 0x01 心跳(终端发起) */
const val CMD_HEARTBEAT: Byte = 0x01
/** 0x81 心跳(终端发起,需存储串口屏显示信息) */
const val CMD_HEARTBEAT_STORE: Byte = 0x81.toByte()
/** 0x02 开锁(服务端发起) */
const val CMD_UNLOCK: Byte = 0x02
/** 0x03 获取状态(服务端发起) */
const val CMD_GET_STATUS: Byte = 0x03
/** 0x04 秤置零(服务端发起) */
const val CMD_SCALE_ZERO: Byte = 0x04
/** 0x05 卡号上发(终端发起) */
const val CMD_CARD_UPLOAD: Byte = 0x05
/** 0x06 秤标定(服务端发起) */
const val CMD_SCALE_CALIBRATE: Byte = 0x06
/** 0x07 价签设置(服务端发起) */
const val CMD_PRICE_TAG_SET: Byte = 0x07
/** 0x08 串口屏(服务端发起) */
const val CMD_SERIAL_SCREEN: Byte = 0x08
/** 0x09 语音播报(服务端发起) */
const val CMD_VOICE_BROADCAST: Byte = 0x09
/** 0x0A 价签(第二块串口屏/透传)设置(服务端发起) */
const val CMD_PRICE_TAG_SET2: Byte = 0x0A
/** 0x0B 操作记录上传(终端发起) */
const val CMD_OPERATION_RECORD: Byte = 0x0B
/** 0x0C 对时(终端发起) */
const val CMD_TIME_SYNC: Byte = 0x0C
/** 0x0E 终端位置上报(终端发起) */
const val CMD_POSITION_REPORT: Byte = 0x0E
/** 0x0F 温控器控制(服务端发起) */
const val CMD_TEMP_CTRL: Byte = 0x0F
/** 0x10 温控信息上报(终端发起) */
const val CMD_TEMP_REPORT: Byte = 0x10
/** 0x11 临时参数设置(服务端发起) */
const val CMD_TEMP_PARAM_SET: Byte = 0x11
// ----------------------------------------------------------------
// 帧结构固定偏移量(单位:字节)
// ----------------------------------------------------------------
/** 协议头长度 */
const val HEADER_LEN: Int = 4
/** 命令字段长度 */
const val CMD_LEN: Int = 1
/** 标志字段长度 */
const val FLAG_LEN: Int = 1
/** 数据体起始偏移(协议头 + 命令 + 标志) */
const val BODY_OFFSET: Int = HEADER_LEN + CMD_LEN + FLAG_LEN
/** 协议尾长度 */
const val FOOTER_LEN: Int = 2
/** 单台秤重量字节数 */
const val BYTES_PER_WEIGHT: Int = 4
/** 物联卡号字节数 */
const val SIM_CARD_NO_LEN: Int = 20
/** 固定数据体:终端ID字节数 */
const val TERMINAL_ID_LEN: Int = 15
/** 固定数据体:固件版本字节数 */
const val FIRMWARE_VERSION_LEN: Int = 5
/** 固定数据体:命令次数字节数 */
const val CMD_COUNT_LEN: Int = 1
/** 固定数据体总长度(终端ID + 固件版本 + 命令次数) */
const val FIXED_BODY_LEN: Int = TERMINAL_ID_LEN + FIRMWARE_VERSION_LEN + CMD_COUNT_LEN
}
@@ -0,0 +1,15 @@
package com.shuwei.intelligent.shelves.serial
/**
* 单台秤的重量数据
*
* @param index 秤的编号,从1开始
* @param grams 重量,单位克,负数表示去皮/负重
*/
data class ScaleWeight(
val index: Int,
val grams: Int
) {
/** 重量转换为千克,保留3位小数 */
val kg: Double get() = grams / 1000.0
}
@@ -1,69 +0,0 @@
package com.shuwei.intelligent.shelves.serial
import com.shuwei.intelligent.shelves.utils.hexToBytes
import com.shuwei.intelligent.shelves.utils.toHexString2
import io.github.jeadyx.jserialport.AndroidSerialPort
import io.github.jeadyx.jserialport.SerialPort
import io.github.jeadyx.jserialport.SerialPortFactory
import kotlinx.coroutines.*
import java.nio.charset.Charset
import java.util.Locale
object SerialPortManager {
private const val portName: String = "/dev/ttyS1"
private const val baudRate: Int = 115200
private var serialPort: AndroidSerialPort? = null
// private var serialPort: SerialPort? = null
private val scope = CoroutineScope(Dispatchers.IO)
// 打开串口
suspend fun open(): Boolean {
return try {
serialPort = SerialPortFactory.create().apply {
open(
portName = portName,
baudRate = baudRate,
dataBits = 8,
stopBits = 1,
parity = SerialPort.PARITY_NONE
)
} as AndroidSerialPort?
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// 发送数据
suspend fun send(data: String): Boolean {
return try {
// serialPort?.write(data.toByteArray())
serialPort?.write(data.hexToBytes())
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// 启动接收协程
fun startReceive(callback: (String) -> Unit) {
scope.launch {
while (isActive) {
serialPort?.read()?.collect { buffer ->
// val data = String(buffer)
val data = buffer.toHexString2().toUpperCase()
callback(data)
}
delay(50)
}
}
}
// 关闭串口
suspend fun close() {
scope.cancel()
serialPort?.close()
}
}
@@ -0,0 +1,22 @@
package com.shuwei.intelligent.shelves.serial
/**
* 获取设备状态数据(命令 0x03,服务端发起后终端响应)
*
* @param lock1Lock 锁1锁状态:0-关 1-开
* @param lock1Door 锁1门状态:0-关 1-开
* @param lock2Lock 锁2锁状态:0-关 1-开
* @param lock2Door 锁2门状态:0-关 1-开
* @param scaleCount 秤的数量
* @param weights 各台秤的重量列表,索引从1开始
* @param temperature 温度值(原始字节,单位由温控器决定)
*/
data class StatusData(
val lock1Lock: Int,
val lock1Door: Int,
val lock2Lock: Int,
val lock2Door: Int,
val scaleCount: Int,
val weights: List<ScaleWeight>,
val temperature: Int
)
@@ -1,40 +0,0 @@
package com.shuwei.intelligent.shelves.task
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.shuwei.intelligent.shelves.HomeActivity
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SyncTask(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
companion object {
private const val TAG = "SyncTask"
}
override suspend fun doWork(): Result {
return try {
// 执行后台任务逻辑
performSync()
TaskManager.startTask()
Result.success()
} catch (e: Exception) {
e.printStackTrace()
Result.retry()
}
}
private suspend fun performSync() {
withContext(Dispatchers.IO) {
val list = HomeActivity.list
Log.d(TAG, "performSync: list:${list.toJsonString()}")
}
}
}
@@ -1,44 +0,0 @@
package com.shuwei.intelligent.shelves.task
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.shuwei.intelligent.shelves.App
import java.util.concurrent.TimeUnit
object TaskManager {
const val TASK_NAME = "IntelligentShelvesSyncTask"
fun startTask() {
val nextRequest = OneTimeWorkRequestBuilder<SyncTask>()
.setInitialDelay(3, TimeUnit.MINUTES)
// .setInitialDelay(10, TimeUnit.SECONDS)
// .setInputData(inputData)
.build()
WorkManager.getInstance(App.get())
.enqueueUniqueWork(TASK_NAME, ExistingWorkPolicy.REPLACE, nextRequest)
// val constraints = Constraints.Builder()
// .setRequiredNetworkType(NetworkType.CONNECTED)
// .setRequiresBatteryNotLow(true)
// .build()
// val syncRequest = PeriodicWorkRequestBuilder<SyncTask>(
// 3L, TimeUnit.MINUTES, // 最小间隔15分钟
// 3L, TimeUnit.MINUTES // 弹性时间窗口
// )
// .setConstraints(constraints)
// .build()
// WorkManager.getInstance(App.get())
// .enqueueUniquePeriodicWork(
// TASK_NAME,
// ExistingPeriodicWorkPolicy.KEEP,
// syncRequest
// )
}
fun cancelTask() {
WorkManager.getInstance(App.get())
.cancelUniqueWork(TASK_NAME)
}
}
@@ -0,0 +1,750 @@
package com.shuwei.intelligent.shelves.utils;
import static android.content.Context.TELEPHONY_SERVICE;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.provider.Settings;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.Log;
import androidx.core.content.FileProvider;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.net.NetworkInterface;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
public class AppUtil {
public static String getAppPackageName(Context context) {
String packageName = "";
try {
PackageManager pm = context.getPackageManager();
PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
packageName = pi.packageName;
if (AppUtil.isEmpty(packageName)) {
return "";
}
} catch (Exception e) {
e.printStackTrace();
}
return packageName;
}
public static String getAppVersionName(Context context) {
String versionName = "";
// int versioncode=1;
try {
PackageManager pm = context.getPackageManager();
PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
versionName = pi.versionName;
// versioncode = pi.versionCode;表示更新了多少次
if (versionName == null || versionName.length() <= 0) {
return "";
}
} catch (Exception e) {
e.printStackTrace();
}
return versionName;
}
public static int getAppVersionCode(Context context) {
int versioncode = 1;
try {
PackageManager pm = context.getPackageManager();
PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0);
versioncode = pi.versionCode;
} catch (Exception e) {
e.printStackTrace();
}
return versioncode;
}
//判断微信是否安装
public static boolean isWeixinInstalled(Context context) {
final PackageManager packageManager = context.getPackageManager();// 获取packagemanager
List<PackageInfo> pinfo = packageManager.getInstalledPackages(0);// 获取所有已安装程序的包信息
if (pinfo != null) {
for (int i = 0; i < pinfo.size(); i++) {
String pn = pinfo.get(i).packageName;
if (pn.equals("com.tencent.mm")) {
return true;
}
}
}
return false;
}
/**
* 打电话
* <p>
* Intent.ACTION_DIAL Intent.ACTION_CALL
*
* @param context
* @param mobile
*/
public static void callUp(Context context, String mobile) {
Intent intent = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:"
+ mobile));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
/**
* 获取设备ID
*
* @param context
* @return
*/
public static String getDevId(Context context) {
TelephonyManager TelephonyMgr = (TelephonyManager) context
.getSystemService(Context.TELEPHONY_SERVICE);
return TelephonyMgr.getDeviceId();
}
/**
* 姓名脱敏
*
* @param fullName
* @return
*/
public static String desensitizedName(String fullName) {
if (fullName == null || fullName.length() <= 1) {
return fullName;
}
char[] nameArr = fullName.toCharArray();
if (nameArr.length > 2) {
for (int i = 1; i < nameArr.length - 1; i++) {
nameArr[i] = '*';
}
} else {
nameArr[1] = '*';
}
return new String(nameArr);
}
public static String formatDateGetFull(String date) {
if (isEmpty(date)) {
return "";
}
Date d = new Date(Long.parseLong(date));
SimpleDateFormat dateFormat1 = new SimpleDateFormat("yyyy-MM-dd HH:mm");
return dateFormat1.format(d);
}
public static String formatDateGetCurrentTime() {
Date d = new Date(System.currentTimeMillis());
SimpleDateFormat dateFormat1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//.SSS
return dateFormat1.format(d);
}
public static String formatDateGetFull(long date) {
if (date == 0) {
return "";
}
Date d = new Date(date);
SimpleDateFormat dateFormat1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
return dateFormat1.format(d);
}
public static String formatDateGetDay(long date) {
if (date == 0) {
return "";
}
Date d = new Date(date);
SimpleDateFormat dateFormat1 = new SimpleDateFormat("yyyy-MM-dd");
return dateFormat1.format(d);
}
public static boolean isEmpty(String s) {
if (TextUtils.isEmpty(s) || s.trim().equals("null")) {
return true;
} else {
return false;
}
}
/**
* 格式化浮点型
*
* @param data
* @return
*/
public static String formatDouble(double data) {
return new DecimalFormat("0.00").format(data);
}
/**
* 格式化分钟
*
* @param minutes
* @return
*/
public static String formatMinutes(int minutes) {
int hour = minutes / 60;
int minute = minutes % 60;
if (hour > 0 && minute > 0) {
return hour + "小时" + minute + "分钟";
} else if (hour > 0) {
return hour + "小时";
} else {
return minute + "分钟";
}
}
//com.fawan.news
public static void goToMarket(Context context, String packageName) {
Uri uri = Uri.parse("market://details?id=" + packageName);
Intent goToMarket = new Intent(Intent.ACTION_VIEW, uri);
try {
context.startActivity(goToMarket);
} catch (ActivityNotFoundException e) {
e.printStackTrace();
}
}
/**
* true为存在,false为不存在
*
* @param context
* @param packageName
* @return
*/
public static boolean isInstallApp(Context context, String packageName) {
try {
context.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_UNINSTALLED_PACKAGES);
return true;
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
/**
* 格式化float 保留两位小数
*
* @param data
* @return
*/
public static float formatFloat2(float data) {
// DecimalFormat decimalFormat = new DecimalFormat("0.00");//构造方法的字符格式这里如果小数不足2位,会以0补足.
// return decimalFormat.format(data);//返回字符串
int scale = 1;//设置位数
int roundingMode = 4;//表示四舍五入,可以选择其他舍值方式,例如去尾,等等.
BigDecimal bd = new BigDecimal((double) data);
bd = bd.setScale(scale, roundingMode);
data = bd.floatValue();
return data;
}
/**
* Android 6.0 之前(不包括6.0)获取mac地址
* 必须的权限 <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"></uses-permission>
*
* @param context * @return
*/
public static String getMacDefault(Context context) {
String mac = "";
if (context == null) {
return mac;
}
WifiManager wifi = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
WifiInfo info = null;
try {
info = wifi.getConnectionInfo();
} catch (Exception e) {
e.printStackTrace();
}
if (info == null) {
return null;
}
mac = info.getMacAddress();
if (!TextUtils.isEmpty(mac)) {
mac = mac.toUpperCase(Locale.ENGLISH);
}
return mac;
}
/**
* Android 6.0-Android 7.0 获取mac地址
*/
public static String getMacAddress() {
String macSerial = null;
String str = "";
try {
Process pp = Runtime.getRuntime().exec("cat/sys/class/net/wlan0/address");
InputStreamReader ir = new InputStreamReader(pp.getInputStream());
LineNumberReader input = new LineNumberReader(ir);
while (null != str) {
str = input.readLine();
if (str != null) {
macSerial = str.trim();//去空格
break;
}
}
} catch (IOException ex) {
// 赋予默认值
ex.printStackTrace();
}
return macSerial;
}
/**
* Android 7.0之后获取Mac地址
* 遍历循环所有的网络接口,找到接口是 wlan0
* 必须的权限 <uses-permission android:name="android.permission.INTERNET"></uses-permission>
*
* @return
*/
public static String getMacFromHardware() {
try {
ArrayList<NetworkInterface> all = Collections.list(NetworkInterface.getNetworkInterfaces());
for (NetworkInterface nif : all) {
if (!nif.getName().equals("wlan0"))
continue;
byte[] macBytes = nif.getHardwareAddress();
if (macBytes == null) return "";
StringBuilder res1 = new StringBuilder();
for (Byte b : macBytes) {
res1.append(String.format("%02X:", b));
}
if (!TextUtils.isEmpty(res1)) {
res1.deleteCharAt(res1.length() - 1);
}
return res1.toString();
}
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
/**
* 获取mac地址(适配所有Android版本)
*
* @return
*/
public static String getMac(Context context) {
String mac = "";
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
mac = getMacDefault(context);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
mac = getMacAddress();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mac = getMacFromHardware();
}
return mac;
}
//把String转化为float
public static double convertToFloat(String number, double defaultValue) {
if (TextUtils.isEmpty(number)) {
return defaultValue;
}
try {
return Double.parseDouble(number);
} catch (Exception e) {
return defaultValue;
}
}
/**
* 获取AndroidId
*
* @param context
* @return
*/
public static String getAndroidId(Context context) {
String androidId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
return androidId;
}
/**
* 获取设备唯一 UDID
*
* @param context
* @return
*/
@SuppressLint("MissingPermission")
public static String getUDID(Context context) {
// return "SWZC-HG-001";
//// //"test111";
String androidID = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
if (!androidID.equals("")) {
try {
if (!"9774d56d682e549c".equals(androidID)) {
androidID = UUID.nameUUIDFromBytes(androidID.getBytes("utf8")).toString();
} else {
@SuppressLint("MissingPermission") final String deviceId = ((TelephonyManager) context.getSystemService(TELEPHONY_SERVICE)).getDeviceId();
androidID = deviceId != null ? UUID.nameUUIDFromBytes(deviceId.getBytes("utf8")).toString() : UUID.randomUUID().toString();
}
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
return androidID;
}
//需要权限 android.permission.READ_PHONE_STATE
TelephonyManager TelephonyMgr = (TelephonyManager) context.getSystemService(TELEPHONY_SERVICE);
String szImei = TelephonyMgr.getDeviceId();
if (!szImei.equals("")) {
return szImei;
}
//需要权限 android.permission.ACCESS_WIFI_STATE
WifiManager wm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE);
String m_szWLANMAC = wm.getConnectionInfo().getMacAddress();
if (!m_szWLANMAC.equals("")) {
return m_szWLANMAC;
}
//需要权限 android.permission.BLUETOOTH
BluetoothAdapter m_BluetoothAdapter = null;
m_BluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
String m_szBTMAC = m_BluetoothAdapter.getAddress();
if (!m_szBTMAC.equals("")) {
return m_szBTMAC;
}
return getUniquePsuedoID();
}
//获得 Psuedo ID
public static String getUniquePsuedoID() {
String serial = null;
String m_szDevIDShort = "35" +
Build.BOARD.length() % 10 + Build.BRAND.length() % 10 +
Build.CPU_ABI.length() % 10 + Build.DEVICE.length() % 10 +
Build.DISPLAY.length() % 10 + Build.HOST.length() % 10 +
Build.ID.length() % 10 + Build.MANUFACTURER.length() % 10 +
Build.MODEL.length() % 10 + Build.PRODUCT.length() % 10 +
Build.TAGS.length() % 10 + Build.TYPE.length() % 10 +
Build.USER.length() % 10; //13 位
try {
serial = Build.class.getField("SERIAL").get(null).toString();
//API>=9 使用serial号
return new UUID(m_szDevIDShort.hashCode(), serial.hashCode()).toString();
} catch (Exception exception) {
//serial需要一个初始化,随意值
serial = "serial";
}
//使用硬件信息拼凑出来的15位号码
return new UUID(m_szDevIDShort.hashCode(), serial.hashCode()).toString();
}
public static String getCPUSerial() {
String line = "";
String TAG = "aaa";
Log.e(TAG, " get_quck_Sn() ");
Class<?> c = null;
try {
c = Class.forName("android.os.SystemProperties");
Method get = c.getMethod("get", String.class);
line = (String) get.invoke(c, "ro.serialno");
} catch (ClassNotFoundException | NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
Log.e(TAG, " get_quck_Sn() " + line);
System.out.println("设备串号" + line);
return line;
}
/**
* 判断网络连接状态
*
* @param context
* @return
*/
public static boolean isNetworkConnected(Context context) {
if (context != null) {
ConnectivityManager mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo mNetworkInfo = mConnectivityManager.getActiveNetworkInfo();
if (mNetworkInfo != null) {
return mNetworkInfo.isAvailable();
}
}
return false;
}
/**
* 判断WiFi连接状态
*
* @param context
* @return
*/
public static boolean isWifiConnected(Context context) {
if (context != null) {
ConnectivityManager mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo mWiFiNetworkInfo = mConnectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
if (mWiFiNetworkInfo != null) {
return mWiFiNetworkInfo.isAvailable();
}
}
return false;
}
/**
* 判断移动网络状态
*
* @param context
* @return
*/
public static boolean isMobileConnected(Context context) {
if (context != null) {
ConnectivityManager mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo mMobileNetworkInfo = mConnectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
if (mMobileNetworkInfo != null) {
return mMobileNetworkInfo.isAvailable();
}
}
return false;
}
/**
* 获取网络连接类型
*
* @param context
* @return
*/
public static int getConnectedType(Context context) {
if (context != null) {
ConnectivityManager mConnectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo mNetworkInfo = mConnectivityManager.getActiveNetworkInfo();
if (mNetworkInfo != null && mNetworkInfo.isAvailable()) {
return mNetworkInfo.getType();
}
}
return -1;
}
/**
* 根据字符的起始和结束索引提取子串
*
* @param input 原始字符串
* @param startIndex 起始索引(包含,从0开始)
* @param endIndex 结束索引(不包含)
* @return 子串,若输入无效或索引越界则返回空字符串
*/
public static String getSubstringByIndices(String input, int startIndex, int endIndex) {
if (input == null) {
return "";
}
// 处理索引越界问题
int safeStart = Math.max(startIndex, 0);
int safeEnd = Math.min(endIndex, input.length());
if (safeStart > safeEnd) {
return "";
}
return input.substring(safeStart, safeEnd);
}
public static String getSubstringByIndex(String input, int startIndex, int length) {
if (input == null) {
return "";
}
// 处理索引越界问题
int safeStart = Math.max(startIndex, 0);
int safeEnd = Math.min(startIndex + length, input.length());
if (safeStart > safeEnd) {
return "";
}
return input.substring(safeStart, safeEnd);
}
/**
* 十进制转十六进制
*
* @param decimal
* @return
*/
public static String decimalToHexWithPadding(int decimal, int padding) {
// 将十进制转换为十六进制,并转换为字符串
String hex = Integer.toHexString(decimal);
// 确保字符串长度为至少4位,不足部分前面补0
while (hex.length() < padding) {
hex = "0" + hex;
}
return hex.toUpperCase(); // 返回大写形式的十六进制字符串
}
/**
* 十进制转二进制,且返回的二进制为至少7位数
*
* @param decimal
* @return
*/
public static String decimalToBinary(int decimal) {
// 如果输入为0,直接返回"0"
if (decimal == 0) {
return "0";
}
StringBuilder binary = new StringBuilder();
// 除2取余法,将余数加入二进制字符串
while (decimal > 0) {
int remainder = decimal % 2;
binary.insert(0, remainder);
decimal = decimal / 2;
}
int length = binary.length();
if (length < 7) {
int padding = 7 - length;
for (int i = 0; i < padding; i++) {
binary.insert(0, '0');
}
}
return binary.toString();
}
/**
* 将二进制字符串转换为十六进制字符串,每8位转换为两位十六进制,不足两位前面补零
*
* @param binaryStr 输入的二进制字符串(仅包含0和1)
* @return 转换后的十六进制字符串
* @throws IllegalArgumentException 如果输入不是有效的二进制字符串
*/
public static String binaryToHex(String binaryStr) {
// 校验输入合法性
if (binaryStr == null || !binaryStr.matches("[01]+")) {
throw new IllegalArgumentException("Invalid binary string");
}
// 补前导零使长度成为8的倍数
int length = binaryStr.length();
int padding = (8 - (length % 8)) % 8; // 计算需要补零的数量
StringBuilder paddedBinary = new StringBuilder();
for (int i = 0; i < padding; i++) {
paddedBinary.append('0');
}
paddedBinary.append(binaryStr);
// 每8位转换为两位十六进制
StringBuilder hexStr = new StringBuilder();
for (int i = 0; i < paddedBinary.length(); i += 8) {
String byteStr = paddedBinary.substring(i, i + 8);
int decimalValue = Integer.parseInt(byteStr, 2);
hexStr.append(String.format("%02X", decimalValue & 0xFF));
}
return hexStr.toString();
}
/**
* 数据校验 异或处理
*/
public static String getXor(String content) {
int a = 0;
for (int i = 0; i < content.length() / 2; i++) {
a = a ^ Integer.parseInt(content.substring(i * 2, (i * 2) + 2), 16);
}
String result = Integer.toHexString(a).toUpperCase();
if (result.length() == 1) {
return "0" + result;
} else {
return result;
}
}
public static double formatPersonInfo(String input, int startIndex, int length) {
if (input == null) {
return 0;
}
// 处理索引越界问题
int safeStart = Math.max(startIndex, 0);
int safeEnd = Math.min(startIndex + length, input.length());
if (safeStart > safeEnd) {
return 0;
}
String result = input.substring(safeStart, safeEnd);
double num = Integer.parseInt(result, 16);
return num;
}
/**
* 安装apk
*
* @param activity
* @param apkFile
*/
public static void installApk(Context activity, File apkFile) {
//文件有所有者概念,现在是属于当前进程的,需要把这个文件暴露给系统安装程序(其他进程)去安装
//因此,可能会存在权限问题,需要做下面的设置
//如果文件是sdcard上的,就不需要这个操作了
try {
apkFile.setExecutable(true, false);
apkFile.setReadable(true, false);
apkFile.setWritable(true, false);
} catch (Exception e) {
e.printStackTrace();
}
Intent intent = new Intent();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(Intent.ACTION_VIEW);
Uri uri;
//TODO N FileProvider
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
uri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileProvider", apkFile);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} else {
uri = Uri.fromFile(apkFile);
}
intent.setDataAndType(uri, "application/vnd.android.package-archive");
activity.startActivity(intent);
//TODO 0 INSTALL PERMISSION
//在AndroidManifest中加入权限即可
}
}
@@ -0,0 +1,18 @@
package com.shuwei.intelligent.shelves.utils
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.sw.scalefusion.shelf.InitActivity
import kotlin.jvm.java
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
// 启动服务或Activity
val launchIntent = Intent(context, InitActivity::class.java)
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(launchIntent)
}
}
}
@@ -1,8 +0,0 @@
package com.shuwei.intelligent.shelves.utils
/**
* 命令常亮定义
*/
object Constant {
}
@@ -0,0 +1,251 @@
package com.shuwei.intelligent.shelves.utils
import android.app.ActivityManager
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
//fun getCrashReportFiles(context: Context): Array<File> {
// val crashDir = getCrashDir()
// return if (crashDir.exists() && crashDir.isDirectory) {
// crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
// } else {
// emptyArray()
// }
//}
//
//fun clearCrashReports(context: Context) {
// getCrashReportFiles(context).forEach { it.delete() }
//}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
try {
Thread.sleep(3000)
} catch (e: InterruptedException) {
e.printStackTrace()
}
// 如果系统提供了默认的异常处理器,则交给系统去结束程序
// 否则自己结束程序
defaultHandler?.uncaughtException(thread, ex) ?: run {
Process.killProcess(Process.myPid())
exitProcess(1)
}
}
/**
* 自动重启app
*/
// private fun restartApp() {
// // 延迟1秒后重启应用
// Handler(Looper.getMainLooper()).postDelayed({
// val intent = Intent(context, MainActivity::class.java).apply {
// addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// }
//
// val pendingIntent = PendingIntent.getActivity(
// context, 0, intent,
// PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
// )
//
// val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent)
//
// Process.killProcess(Process.myPid())
// exitProcess(1)
// }, 1000)
// }
private fun handleException(thread: Thread, ex: Throwable) {
// 收集设备信息和异常信息
val crashInfo = collectCrashInfo(thread, ex)
// 保存日志文件
saveCrashInfoToFile(crashInfo)
// 这里可以添加其他处理逻辑,比如上传到服务器等
}
private fun collectCrashInfo(thread: Thread, ex: Throwable): String {
return buildString {
// 收集设备信息
collectDeviceInfo(this)
// 收集应用日志
append("\n\n").append(collectLogs())
// 收集线程和异常信息
append("\n\n========== Thread & Exception Info ==========\n")
append("Thread: ${thread.name}\n")
append("Stack Trace:\n")
val sw = StringWriter()
val pw = PrintWriter(sw)
ex.printStackTrace(pw)
var cause: Throwable? = ex.cause
while (cause != null) {
cause.printStackTrace(pw)
cause = cause.cause
}
pw.close()
append(sw.toString())
}
}
private fun collectDeviceInfo(sb: StringBuilder) {
sb.append("========== Device Info ==========\n")
try {
// 应用信息
val pm = context.packageManager
val pi = pm.getPackageInfo(context.packageName, 0)
sb.append("App Version: ${pi.versionName}_${pi.versionCode}\n")
// Android 版本信息
sb.append("OS Version: ${Build.VERSION.RELEASE}_${Build.VERSION.SDK_INT}\n")
// 设备信息
sb.append("Vendor: ${Build.MANUFACTURER}\n")
sb.append("Model: ${Build.MODEL}\n")
sb.append("CPU ABI: ${Build.SUPPORTED_ABIS[0]}\n")
// 其他信息
sb.append("Locale: ${Locale.getDefault()}\n")
sb.append(
"Current Time: ${
SimpleDateFormat(
"yyyy-MM-dd HH:mm:ss",
Locale.getDefault()
).format(Date())
}\n"
)
// 内存信息
val memoryInfo = ActivityManager.MemoryInfo()
val activityManager =
context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
activityManager.getMemoryInfo(memoryInfo)
sb.append("Available Memory: ${memoryInfo.availMem / (1024 * 1024)}MB\n")
sb.append("Total Memory: ${memoryInfo.totalMem / (1024 * 1024)}MB\n")
sb.append("Low Memory: ${memoryInfo.lowMemory}\n")
} catch (e: Exception) {
Log.e("","Error while collecting device info")
sb.append("Error while collecting device info: ${e.message}\n")
}
}
private fun collectLogs(): String {
return buildString {
append("========== Application Logs ==========\n")
try {
val process = Runtime.getRuntime().exec("logcat -d -v threadtime")
val reader = process.inputStream.bufferedReader()
val logLines = reader.readLines()
val start = maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Log.e(TAG, "Error collecting logs")
append("Error collecting logs: ${e.message}\n")
}
}
}
private fun saveCrashInfoToFile(crashInfo: String) {
try {
val time = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
val fileName = "crash_$time.log"
val crashDir = getCrashDir()
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Log.d(TAG,"Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Error saving crash info to file")
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = getCrashDir()
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
private fun getCrashDir():File {
//val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
var crashDir = File(context.filesDir, CRASH_REPORTS_DIR)
if (!crashDir.exists()) {
crashDir.mkdirs()
}
if (!(crashDir.exists())) {
crashDir = File(context.cacheDir, CRASH_REPORTS_DIR)
}
return crashDir
}
}
@@ -1,9 +1,11 @@
package com.shuwei.intelligent.shelves.utils
import androidx.annotation.NonNull
import java.text.SimpleDateFormat
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Date
import java.util.Locale
@@ -19,6 +21,19 @@ object DateTimeUtil {
return sdf.format(dateTime)
}
fun stringToDate(dateText: String, pattern: String = YYYY_MM_DD_HH_MM_SS): Date? {
val sdf = SimpleDateFormat(pattern, Locale.CHINA)
return sdf.parse(dateText)
}
fun addDays(date: Date, days: Int): Date {
val calendar = Calendar.getInstance().apply {
time = date
add(Calendar.DAY_OF_MONTH, days)
}
return calendar.time
}
// fun convert(dateStr: String, pattern: String = YYYY_MM_DD_HH_MM_SS): Date {
// val formatter = DateTimeFormatter.ofPattern(pattern)
// val ldt = LocalDateTime.parse(dateStr, formatter)
@@ -45,3 +45,49 @@ fun byteArrayToHexString(bytes: ByteArray): String {
fun String.hexToBytes(): ByteArray = hexStringToByteArray(this)
fun ByteArray.toHexString2(): String = byteArrayToHexString(this)
fun hexToBinary(hex: String): String {
val hexToBinaryMap = mapOf(
'0' to "0000", '1' to "0001", '2' to "0010", '3' to "0011",
'4' to "0100", '5' to "0101", '6' to "0110", '7' to "0111",
'8' to "1000", '9' to "1001", 'A' to "1010", 'B' to "1011",
'C' to "1100", 'D' to "1101", 'E' to "1110", 'F' to "1111"
)
return hex.map { char ->
hexToBinaryMap[char] ?: throw IllegalArgumentException("非法十六进制字符: $char")
}.joinToString("")
}
fun binaryToHex(binary: String): String {
// 补齐到4的倍数长度(方便分组)
val paddedBinary = binary.padStart(
length = ((binary.length + 3) / 4) * 4,
padChar = '0'
)
// 每4位二进制对应1位十六进制
return paddedBinary.chunked(4).joinToString("") { nibble ->
when (nibble) {
"0000" -> "0"
"0001" -> "1"
"0010" -> "2"
"0011" -> "3"
"0100" -> "4"
"0101" -> "5"
"0110" -> "6"
"0111" -> "7"
"1000" -> "8"
"1001" -> "9"
"1010" -> "A"
"1011" -> "B"
"1100" -> "C"
"1101" -> "D"
"1110" -> "E"
"1111" -> "F"
else -> throw IllegalArgumentException("无效的二进制段: $nibble")
}
}
.trimStart('0') // 去除前导零
.ifEmpty { "0" } // 处理全零情况
}
@@ -0,0 +1,91 @@
package com.shuwei.intelligent.shelves.utils
import android.content.Context
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
/**
* 文件日志记录器(异步版)
*
* log() 仅投递原始消息到 Channel(任意线程安全),
* 格式化与文件写入在单一后台协程顺序执行,避免 SimpleDateFormat 多线程竞争。
*/
class FileLogger(context: Context) {
companion object {
private const val FILE_PREFIX = "log_"
private const val FILE_EXTENSION = ".txt"
/** 全局单线程日志写入 */
private val logChannel = Channel<RawEntry>(capacity = Channel.UNLIMITED)
private val writerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
writerScope.launch {
// 单协程顺序消费:SimpleDateFormat 在此线程安全
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
for (entry in logChannel) {
try {
val fileName = "${FILE_PREFIX}${dateFormat.format(Date())}${FILE_EXTENSION}"
val timestamp = timeFormat.format(Date())
val logLine = "[$timestamp] [${entry.level}] ${entry.message}\n"
val output = entry.appContext.openFileOutput(
fileName, Context.MODE_APPEND
)
BufferedWriter(OutputStreamWriter(output)).use { it.write(logLine) }
} catch (_: Exception) {
// 写入失败静默丢弃
}
}
}
}
private data class RawEntry(
val appContext: Context,
val message: String,
val level: LogLevel
)
}
/** 持有 ApplicationContext 避免 Activity 泄漏 */
private val appContext: Context = context.applicationContext
/**
* 记录日志(异步,线程安全)
*/
fun log(message: String, level: LogLevel = LogLevel.INFO) {
logChannel.trySend(RawEntry(appContext, message, level))
}
fun readLogsByDate(date: String): List<String> {
return readLogsByName("${FILE_PREFIX}${date}${FILE_EXTENSION}")
}
fun readLogsByName(fileName: String): List<String> {
try {
val input = appContext.openFileInput(fileName)
val reader = BufferedReader(InputStreamReader(input))
return reader.useLines { lines -> lines.toList().toMutableList() }
} catch (_: FileNotFoundException) {
return emptyList()
}
}
fun getCrashDir(context: Context): File {
var crashDir = File(context.filesDir, "crash_reports")
if (!crashDir.exists()) crashDir.mkdirs()
if (!crashDir.exists()) crashDir = File(context.cacheDir, "crash_reports")
return crashDir
}
fun getLogFiles(): List<String> {
return appContext.fileList()
.filter { it.startsWith(FILE_PREFIX) && it.endsWith(FILE_EXTENSION) }
}
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
}
@@ -0,0 +1,64 @@
package com.shuwei.intelligent.shelves.utils
import kotlinx.coroutines.*
class IntervalExecutor {
/**
* 启动定时任务
* @param delayMillis 延迟时间(毫秒)
* @param action 要执行的方法
* @return Job 可用于取消任务
*/
fun startIntervalTask(delayMillis: Long, action: suspend () -> Unit): Job {
return CoroutineScope(Dispatchers.Default).launch {
while (isActive) {
action()
delay(delayMillis)
}
}
}
/**
* 启动定时任务(带初始延迟)
* @param initialDelay 初始延迟时间(毫秒)
* @param delayMillis 后续执行间隔(毫秒)
* @param action 要执行的方法
* @return Job 可用于取消任务
*/
fun startIntervalTaskWithInitialDelay(
initialDelay: Long,
delayMillis: Long,
action: suspend () -> Unit
): Job {
return CoroutineScope(Dispatchers.Default).launch {
delay(initialDelay)
while (isActive) {
action()
delay(delayMillis)
}
}
}
}
// 使用示例
fun main() = runBlocking {
val executor = IntervalExecutor()
// 示例1:每隔10秒执行一次
val job1 = executor.startIntervalTask(10000) {
println("定时任务执行: ${System.currentTimeMillis()}")
// 这里可以执行你的业务逻辑
}
// 示例2:先延迟5秒,然后每隔3秒执行一次
val job2 = executor.startIntervalTaskWithInitialDelay(5000, 3000) {
println("带初始延迟的定时任务: ${System.currentTimeMillis()}")
}
// 运行30秒后取消任务
delay(30000)
job1.cancel()
job2.cancel()
println("所有定时任务已取消")
}
@@ -13,5 +13,9 @@ object KeyboardUtil {
view.clearFocus() // 清除焦点避免键盘再次弹出
}
fun hideKeyboard(view: View) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus() // 清除焦点避免键盘再次弹出
}
}
@@ -0,0 +1,24 @@
package com.shuwei.intelligent.shelves.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
/**
* 网络连接检测工具类
* 提供检测设备网络连接状态的功能
*/
object NetworkUtils {
/**
* 检测设备是否连接到网络
* @param context 应用上下文
* @return true 表示已连接网络,false 表示未连接
*/
fun isNetworkConnected(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
@@ -1,57 +0,0 @@
//package com.shuwei.intelligent.shelves.utils
//
//
//import android.serialport.*
////import io.github.jeadyx.jserialport.SerialPort
//import java.io.File
//import java.io.IOException
//import java.io.InputStream
//import java.io.OutputStream
//
//class SerialPortHelper(
// private val devicePath: String = "/dev/ttyS1",
// private val baudRate: Int = 115200
//) {
// private var serialPort: SerialPort? = null
// private var inputStream: InputStream? = null
// private var outputStream: OutputStream? = null
//
// // 打开串口
// fun open(): Boolean {
// return try {
// serialPort = SerialPort(File(devicePath), baudRate, 0)
// inputStream = serialPort?.inputStream
// outputStream = serialPort?.outputStream
// true
// } catch (e: IOException) {
// false
// }
// }
//
// // 发送数据
// fun send(data: ByteArray): Boolean {
// return try {
// outputStream?.write(data)
// outputStream?.flush()
// true
// } catch (e: IOException) {
// false
// }
// }
//
// // 接收数据(需在子线程调用)
// fun receive(buffer: ByteArray): Int {
// return inputStream?.read(buffer) ?: -1
// }
//
// // 关闭串口
// fun close() {
// try {
// inputStream?.close()
// outputStream?.close()
// serialPort?.close()
// } catch (e: IOException) {
// e.printStackTrace()
// }
// }
//}
@@ -0,0 +1,38 @@
package com.shuwei.intelligent.shelves.utils
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalKey
import com.shuwei.intelligent.shelves.utils.ext.put
import kotlin.to
object SpTool {
const val DEVICE_CONFIG_CACHE = "deviceConfigCache"
const val TOKEN = "token"
const val LAUNCH_PAGE_TYPE = "launchPageType"
const val DEVICE_ID = "deviceId"
val pref = App.getSharedPref()!!
fun put(key: String, value: Any) {
pref.put(key to value)
}
fun getInt(key: String, defValue: Int = 0): Int {
return pref.getInt(key, defValue)
}
fun getString(key: String, defValue: String? = ""): String {
return pref.getString(key, defValue) ?: ""
}
fun getBoolean(key: String, defValue: Boolean = false): Boolean {
return pref.getBoolean(key, defValue)
}
var baseUrl: String
get() = getString(GlobalKey.KEY_BASE_URL, "")
set(value) {
put(GlobalKey.KEY_BASE_URL, value)
}
}
@@ -1,14 +0,0 @@
package com.shuwei.intelligent.shelves.utils
import android.widget.Toast
import com.shuwei.intelligent.shelves.App
//D6DADEDB0302000001000C06
object UIUtils {
fun toast(strRes: Int) {
Toast.makeText(App.get(), strRes, Toast.LENGTH_SHORT).show()
}
fun toast(str: String) {
Toast.makeText(App.get(), str, Toast.LENGTH_SHORT).show()
}
}
@@ -10,6 +10,7 @@ import android.os.Bundle
import android.util.TypedValue
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import android.widget.Toast
import androidx.core.app.ActivityOptionsCompat
@@ -22,6 +23,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import android.content.ClipData
import android.content.ClipboardManager
import android.os.SystemClock
fun Context.toast(
message: String?,
@@ -151,3 +155,43 @@ fun View.clickWithDebounce(delay: Long = 500, action: () -> Unit) {
}
}
}
fun View.hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(this.windowToken, 0)
this.clearFocus() // 清除焦点避免键盘再次弹出
}
fun String.copyTextToClipboard(context: Context) {
// 获取系统服务中的ClipboardManager
val clipboard: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
// 创建一个ClipData对象,包含要复制的文本
val clip = ClipData.newPlainText("label", this)
// 将ClipData对象设置到剪贴板
clipboard.setPrimaryClip(clip)
// 显示一个简单的Toast提示用户文本已复制
Toast.makeText(context, "文本已复制到剪贴板", Toast.LENGTH_SHORT).show()
}
/**
* 为View添加双击点击事件的扩展函数
*/
fun View.setOnDoubleClickListener(onDoubleClickListener: (View) -> Unit) {
var lastClickTime = 0L
var lastClickView: View? = null
this.setOnClickListener { view ->
val currentTime = SystemClock.elapsedRealtime()
if (lastClickView === view && currentTime - lastClickTime < 500) {
// 双击事件触发
onDoubleClickListener(view)
lastClickTime = 0
} else {
lastClickTime = currentTime
lastClickView = view
}
}
}
@@ -1,70 +0,0 @@
package com.shuwei.intelligent.shelves.utils.ext
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import androidx.core.graphics.toColorInt
interface DrawableDsl {
fun shape(init: ShapeDsl.() -> Unit)
}
interface ShapeDsl {
fun solidColor(color: Int)
fun stroke(width: Int, color: Int)
fun cornerRadius(radius: Float)
}
class DrawableBuilder : DrawableDsl {
val drawables = mutableListOf<ShapeDrawable>()
override fun shape(init: ShapeDsl.() -> Unit) {
val shape = ShapeDrawable()
init(shape)
drawables.add(shape)
}
}
class ShapeDrawable : ShapeDsl {
var color: Int? = null
var strokeWidth: Int? = null
var strokeColor: Int? = null
var cornerRadius: Float? = null
override fun solidColor(color: Int) {
this.color = color
}
override fun stroke(width: Int, color: Int) {
this.strokeWidth = width
this.strokeColor = color
}
override fun cornerRadius(radius: Float) {
this.cornerRadius = radius
}
}
fun createCustomDrawable(
solidColor: String,
strokeWidth: Int,
strokeColor: String,
radius: Int
): Drawable {
val builder = DrawableBuilder()
builder.shape {
solidColor(solidColor.toColorInt())
stroke(strokeWidth, strokeColor.toColorInt())
cornerRadius(radius.toFloat())
}
return convertToDrawable(builder.drawables[0])
}
fun convertToDrawable(shapeDrawable: ShapeDrawable): Drawable {
val drawable = GradientDrawable()
shapeDrawable.color?.let { drawable.setColor(it) }
shapeDrawable.cornerRadius?.let { drawable.cornerRadius = it }
shapeDrawable.strokeWidth?.let { drawable.setStroke(it, shapeDrawable.strokeColor ?: Color.BLACK) }
return drawable
}
@@ -1,24 +0,0 @@
package com.shuwei.intelligent.shelves.utils.ext
import android.text.SpannableStringBuilder
import android.text.Spanned
inline fun buildSpannableString(builderAction: SpannableStringBuilder.() -> Unit): SpannableStringBuilder {
return SpannableStringBuilder().apply(builderAction)
}
fun SpannableStringBuilder.appendText(text: String, vararg spans: Any): SpannableStringBuilder {
val start = length
append(text)
spans.forEach { span ->
setSpan(span, start, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
return this
}
fun SpannableStringBuilder.withSpan(span: Any, block: SpannableStringBuilder.() -> Unit) {
val start = length
block()
setSpan(span, start, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
@@ -1,19 +0,0 @@
//package com.shuwei.intelligent.shelves.viewmodel
//
//import androidx.lifecycle.LiveData
//import androidx.lifecycle.MutableLiveData
//import androidx.lifecycle.ViewModel
//import com.shuwei.intelligent.shelves.model.ShelfModel
//
//class ShelfDataViewModel : ViewModel() {
//
// private val _shelfItems = MutableLiveData<MutableList<ShelfModel>>()
// val shelfItems: LiveData<MutableList<ShelfModel>> = _shelfItems
//
// fun updateItems(newItems: MutableList<ShelfModel>) {
// _shelfItems.value = newItems
// }
//
// fun getCurrentItems(): MutableList<ShelfModel> = _shelfItems.value ?: mutableListOf()
//
//}
@@ -0,0 +1,445 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DefaultItemAnimator
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeV3Binding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.ShelfV3Adapter
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SlotWeight
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class HomeV3Activity : BaseActivity() {
companion object {
/** 重量同步后台间隔(秒) */
private const val SYNC_INTERVAL_SECONDS = 30L
/** 首次同步延迟(秒) */
private const val SYNC_INITIAL_DELAY_SECONDS = 10L
}
override val enableRightStatusMenu: Boolean = true
private val list: MutableList<SlotModel> = mutableListOf()
/** 秤号(slotNo 数字部分)→ list 索引 的快速查找表,心跳回调中 O(1) 定位 */
private val scaleIndexToPosition = mutableMapOf<Int, Int>()
private var shelfIndex = 0
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
private var deviceName = ""
private var cabinetType: Int = 0
private var cabinetId: String = ""
private var canteenId: Long = 0
private lateinit var binding: ActivityHomeV3Binding
private val viewModel: NetViewModelV3 by viewModels()
/** 记录每台秤的上次重量(克),用于跳过无变化的 UI 刷新和日志 */
private val lastWeightGrams = mutableMapOf<Int, Int>()
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val pos = scaleIndexToPosition[weight.index] ?: return@forEach
// 重量不变则跳过 UI 刷新、日志和 EventBus,避免主线程无效计算
if (lastWeightGrams[weight.index] == weight.grams) return@forEach
lastWeightGrams[weight.index] = weight.grams
val model = list[pos]
model.weight = weight.grams / 1000.0
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: slotNo=${model.slotNo}, realWeight=${weight.grams}, temp=${data.temperature}")
EventBus.getDefault().post(SendWeightEvent(weight.index, weight.grams))
}
}
.onTempReport { frame ->
log("onTempReport: $frame")
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeV3Binding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {}
updateLeftStatus("")
initScaleManager()
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
saveGoodsTask()
overdueTask()
viewModel.slots(
onLoading = { showProgress() },
onSuccess = { record -> updateUI(record) },
onError = { msg -> showError(msg) }
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(record: SlotRecord?) {
binding.include.root.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
if (record == null) {
loadEmptyView()
return
}
deviceName = record.deviceName
cabinetType = record.cabinetType
cabinetId = record.cabinetId
canteenId = record.canteenId
updateLeftStatus(deviceName)
val slots = record.slots ?: mutableListOf()
if (slots.isEmpty()) {
loadEmptyView()
return
}
// val columns = if (ProtocolConstants.device3Columns.contains(App.deviceId)) 3 else 2
// val rows = if (slots.size % columns == 0) slots.size / columns else 5
// val columns = record.verticalCount
// val rows = record.horizontalRows
val rows = record.verticalCount
val columns = record.horizontalRows
log("updateUI: slots=${slots.size}, rows=$rows, columns=$columns")
list.clear()
list.addAll(reorderSlotsByDeviceLayout(slots, columns, rows))
buildScaleIndexMap()
val displayMetrics = resources.displayMetrics
// 减去 RecyclerView + item 自身的 margin
shelfAdapter.itemWidth = (displayMetrics.widthPixels - 24.dp) / columns - 24.dp
shelfAdapter.itemHeight = (displayMetrics.heightPixels - 68.dp) / rows - 24.dp
if (binding.rvShelf.adapter == null) {
initRecyclerView()
} else {
shelfAdapter.notifyDataSetChanged()
}
}
/**
* 按设备物理布局重排格口列表。
*
* API 返回的 slots 按 slotNo 升序排列。FlexboxLayoutManager(FlexDirection.COLUMN)
* 按列填充(先填满第一列 5 行,再换第二列),3 列设备需要重排才能正确显示:
*
* 期望显示: 列填充需要的列表序:
* [1] [6] [7] [1, 2, 3, 4, 5, ← 第1列
* [2] [8] [9] 6, 8, 10, 12, 14, ← 第2列
* [3][10] [11] 7, 9, 11, 13, 15] ← 第3列
* [4][12] [13]
* [5][14] [15]
*
* 2 列设备 API 原序 [1..10] 列填充后恰好正确,无需重排。
*
* @param slots API 返回的格口列表(按 slotNo 升序)
* @param columns 列数(API horizontalRows
* @param rows 行数(API verticalCount
*/
private fun reorderSlotsByDeviceLayout(
slots: List<SlotModel>,
columns: Int,
rows: Int
): List<SlotModel> {
// 2 列及以下无需重排,API 原序列填充后恰好正确
if (columns <= 2) return ArrayList(slots)
// 按 slotNo 数字部分建立查找表
val slotMap = HashMap<Int, SlotModel>(slots.size)
for (slot in slots) {
val num = slot.slotNo.filter { it.isDigit() }.toIntOrNull() ?: continue
slotMap[num] = slot
}
val result = ArrayList<SlotModel>(slots.size)
for (displayCol in 0 until columns) {
for (displayRow in 0 until rows) {
// 物理布局 → FlexDirection.COLUMN 列填充所需的列表位置
val slotNo = when (displayCol) {
0 -> displayRow + 1 // 左列: 1,2,3,4,5
1 -> rows + 1 + displayRow * 2 // 中列: 6,8,10,12,14
else -> rows + 2 + displayRow * 2 // 右列: 7,9,11,13,15
}
slotMap[slotNo]?.let { result.add(it) }
}
}
return result
}
/** 预建秤号→列表索引映射表,供心跳回调 O(1) 查找 */
private fun buildScaleIndexMap() {
scaleIndexToPosition.clear()
list.forEachIndexed { pos, model ->
val scaleNo = model.slotNo.filter { it.isDigit() }.toIntOrNull() ?: return@forEachIndexed
scaleIndexToPosition[scaleNo] = pos
}
}
private fun showError(message: String) {
Loading.dismiss()
toast(message)
loadEmptyView()
}
private var tipDialog: CommonDialog? = null
private var lastDataReceivedTime = System.currentTimeMillis()
private var noDataWarningDialog: CommonDialog? = null
private val noDataWatchdogTask = object : Runnable {
override fun run() {
if (isFinishing || isDestroyed) return
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeV3Activity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
}
handler.postDelayed(this, 30 * 1000L)
}
}
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
private val shelfAdapter by lazy {
ShelfV3Adapter(list).apply {
setOnItemClickListener { _, _, position ->
onShelfItemClicked(position)
}
}
}
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
val slotNum = list[position].slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val openCmd = if (slotNum in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
val model = list[position]
launch(Intent(this@HomeV3Activity, ShelfV3Activity::class.java).also {
it.putExtra(ShelfV3Activity.EXTRA_CABINET_TYPE, model.cabinetType)
it.putExtra(ShelfV3Activity.EXTRA_CABINET_ID, model.cabinetId)
it.putExtra(ShelfV3Activity.EXTRA_SLOT_NO, model.slotNo)
it.putExtra(ShelfV3Activity.EXTRA_CANTEEN_ID, canteenId)
it.putExtra(ShelfV3Activity.EXTRA_MATER_NAME, model.materName)
it.putExtra(ShelfV3Activity.EXTRA_VEG_TYPE_NAME, model.vegTypeName)
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
@Suppress("DEPRECATION")
private fun onShelfActivityResult(position: Int, intent: Intent) {
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL, SlotModel::class.java)
} else {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL) as? SlotModel
} ?: return
val weight = list[position].weight
model.weight = weight
list[position] = model
shelfAdapter.notifyItemChanged(position)
}
private fun initRecyclerView() {
val layoutManager = FlexboxLayoutManager(this).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
}
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let { animator ->
if (animator is DefaultItemAnimator) {
animator.supportsChangeAnimations = false
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
override fun onResume() {
super.onResume()
hideStatusBar()
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
scaleIndexToPosition.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
viewModel.slots(
onLoading = { showProgress() },
onSuccess = { record -> updateUI(record) },
onError = { msg -> showError(msg) }
)
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
it.tvEmptyContent.setTextColor(Color.WHITE)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun clearEmptyShelf(event: ClearShelfEvent) {
val index = scaleIndexToPosition[event.shelfNo] ?: return
list[index] = list[index].copy(materName = "")
shelfAdapter.notifyItemChanged(index)
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job? = null
private fun saveGoodsTask() {
saveTaskJob =
taskExecutor.startIntervalTaskWithInitialDelay(
SYNC_INITIAL_DELAY_SECONDS * 1000L,
SYNC_INTERVAL_SECONDS * 1000L
) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job? = null
private fun overdueTask() {
// overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
// viewModel.slots()
// }
}
// private fun applyOverdueState(items: List<RecordItem>) {
// if (items.size != list.size) return
// items.forEachIndexed { index, newItem ->
// val oldItem = list[index]
// if (oldItem.slotStatus != newItem.slotStatus) {
// list[index] = newItem
// shelfAdapter.notifyItemChanged(index)
// log("更新格口 ${newItem.slotNo} 物料:${newItem.materName} 的状态:${newItem.slotStatus}")
// }
// }
// }
/** 全量同步格口重量 */
private fun syncShelfGoodsToServer() {
if (list.isEmpty()) return
val submitList = list.sortedBy { it.slotNo.toIntOrNull() ?: 0 }
viewModel.syncWeight(
SyncWeightRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotList = submitList.map {
SlotWeight(slotNo = it.slotNo, weight = it.weight ?: 0.0)
}
)
)
}
}
@@ -0,0 +1,178 @@
package com.sw.scalefusion.shelf
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.core.graphics.toColorInt
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.utils.AppUtil
import com.shuwei.intelligent.shelves.utils.NetworkUtils
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ext.invisible
import com.shuwei.intelligent.shelves.utils.ext.startActivity
class InitActivity : BaseActivity() {
companion object {
const val TAG = "InitActivity"
}
private lateinit var binding: ActivityInitBinding
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L
private var lastNetworkCheckTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID(this)
// TODO: 测试设备号
// App.deviceId = "d369f9b0-1c5b-3066-ba72-988e1449cecc"
// App.deviceId = "25b777a9-6298-30d3-a5f8-3b3eef2a50f7"
// App.deviceId = "505ce1b1-facc-3eb2-855a-3dadda5ba358"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
// val appBaseUrl = SpTool.baseUrl
// if (!appBaseUrl.isBlank()) {
// GlobalData.appBaseUrl = appBaseUrl
// } else {
// GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
// }
GlobalData.appBaseUrl = GlobalData.TEST_BASE_URL
App.canteenId = "0"
binding.ivQrCode.invisible()
binding.btnInit.invisible()
initViews()
// 禁用返回键
onBackPressedDispatcher.addCallback(this) {}
}
/**
* 初始化控件事件
*/
private fun initViews() {
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200
repeatCount = RotateAnimation.INFINITE
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
showNetworkButton()
}
}
}, 100)
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeV3Activity
*/
private fun navigateToHome() {
startActivity<HomeV3Activity>()
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
}
@@ -0,0 +1,376 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfV3Binding
import com.shuwei.intelligent.shelves.dialog.ChipSelectDialog
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.PendingInboundAdapter
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@SuppressLint("NotifyDataSetChanged")
class ShelfV3Activity : BaseActivity() {
companion object {
const val SHELF_MODEL = "shelfModelV3"
const val EXTRA_CABINET_TYPE = "cabinetType"
const val EXTRA_CABINET_ID = "cabinetId"
const val EXTRA_CANTEEN_ID = "canteenId"
const val EXTRA_SLOT_NO = "slotNo"
const val EXTRA_MATER_NAME = "materName"
const val EXTRA_VEG_TYPE_NAME = "vegTypeName"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
}
/** 缓存的日期格式化器,避免 onClockTick 每秒 new 对象 */
private val clockFormatter by lazy {
SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
}
private lateinit var binding: ActivityShelfV3Binding
private var shelfModel: SlotModel? = null
private var realWeight: Int = 0
private var startTime = 0L
private val viewModel: NetViewModelV3 by viewModels()
private val optionList: MutableList<FoodItem> = mutableListOf()
private val optionAdapter by lazy {
PendingInboundAdapter(optionList).apply {
setOnDebouncedItemClick { _, _, position ->
val vegTypeList = getItem(position)?.vegTypeList ?: emptyList()
if (vegTypeList.isEmpty()) {
selectCategory(position)
return@setOnDebouncedItemClick
}
val categoryList = vegTypeList.map { it.vegTypeName }
if (categoryList.isEmpty()) {
CommonDialog(this@ShelfV3Activity).apply {
dialogTitle = "净菜选择"
dialogContent = "暂无净菜类别,请联系管理员添加,或稍后重试"
show()
}
return@setOnDebouncedItemClick
}
showChipSelectDialog(categoryList) { index ->
getItem(position)?.typeSelectedIndex = index
selectCategory(position)
}
}
}
}
private fun selectCategory(position: Int) {
optionList.forEachIndexed { index, option -> option.isSelected = index == position }
optionAdapter.notifyDataSetChanged()
binding.tvFoodName.text = optionList[position].materName
val index = optionList[position].typeSelectedIndex
val typeList = optionList[position].vegTypeList ?: emptyList()
val selectType = typeList.getOrNull(index)?.vegTypeName ?: ""
binding.tvVegTypeName.text = selectType
KeyboardUtil.hideKeyboard(this@ShelfV3Activity.window.decorView)
}
private var cabinetType: Int = 0
private var canteenId: Long = 0
private var cabinetId: String = ""
private var slotNo: String = ""
private var materName: String = ""
private var vegTypeName: String = ""
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfV3Binding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
EventBus.getDefault().register(this)
cabinetType = intent.getIntExtra(EXTRA_CABINET_TYPE, 0)
canteenId = intent.getLongExtra(EXTRA_CANTEEN_ID, 0)
cabinetId = intent.getStringExtra(EXTRA_CABINET_ID) ?: ""
slotNo = intent.getStringExtra(EXTRA_SLOT_NO) ?: ""
materName = intent.getStringExtra(EXTRA_MATER_NAME) ?: ""
vegTypeName = intent.getStringExtra(EXTRA_VEG_TYPE_NAME) ?: ""
binding.tvShelfName.text = slotNo
binding.tvFoodName.text = materName.ifBlank { "-" }
binding.tvVegTypeName.text = vegTypeName
initRecyclerView()
initSearchBox()
initButtons()
sendCmd(ScaleManager.buildGetStatusCmd())
viewModel.slotDetail(
request = SlotDetailRequest(cabinetType, cabinetId, slotNo),
onLoading = { showProgress() },
onSuccess = onSuccess@{ model ->
if (model == null) return@onSuccess
shelfModel = model
updateUI(model)
loadList()
},
onError = { msg -> showError(msg) }
)
}
/** 从 slotDetail 响应更新 UI */
private fun updateUI(model: SlotModel) {
binding.tvFoodName.text = if (model.materName.isNullOrBlank()) "-" else model.materName
binding.tvVegTypeName.text = model.vegTypeName
val weightG = model.weight ?: 0.0
realWeight = (weightG * 1000.0).toInt()
// 负漂移抑制:-5克以内的负读数显示时归零(realWeight 保持真实值)
val displayWeight = if (realWeight in -5..0) 0 else realWeight
binding.tvFoodWeight.text = if (weightG >= 1.0) "%.3f千克".format(weightG)
else "${displayWeight}"
startTime = System.currentTimeMillis()
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfV3Activity, 2)
adapter = optionAdapter
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { loadList() }
}
}
private fun initSearchBox() {
binding.etInputFood.run {
addOnActionSearchListener {
loadList()
KeyboardUtil.hideKeyboard(context, this)
}
addTextChangedListener(afterTextChanged = { editable ->
if (editable.isNullOrBlank()) loadList()
})
}
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
private fun initButtons() {
binding.ivBack.setOnClickListener { finish() }
binding.btnClearZero.setOnClickListener {
Loading.show(this@ShelfV3Activity)
clearZero()
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
binding.btnClearEmpty.setOnClickListener { v ->
clearZero()
binding.tvFoodName.text = "-"
binding.tvVegTypeName.text = ""
Loading.show(this@ShelfV3Activity)
v.postDelayed({
Loading.dismiss()
val emptyModel = shelfModel?.copy(
materName = "",
materId = null,
weight = null,
traceCode = null,
storeTime = null,
inboundNo = null
)
val result = Intent().apply {
putExtra(SHELF_MODEL, emptyModel)
}
setResult(RESULT_OK, result)
finish()
}, 3000)
}
binding.btnConfirm.clickWithDebounce {
val item = optionList.firstOrNull { it.isSelected }
if (item == null) {
toast("请选择食材")
return@clickWithDebounce
}
val model = shelfModel ?: return@clickWithDebounce
val vegType = item.vegTypeList?.getOrNull(item.typeSelectedIndex)
viewModel.put(
request = PutSlotRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotNo = slotNo,
materId = item.materId,
materName = item.materName,
weight = shelfModel?.weight ?: 0.0
).also {
it.vegTypeId = vegType?.vegTypeId
},
onLoading = { showProgress() },
onSuccess = onSuccess@{ inboundNo ->
Loading.dismiss()
// if (inboundNo.isNullOrBlank()) {
// toast("入库失败")
// return@onSuccess
// }
val resultModel = item.toSlotModel(model).also {
// it.inboundNo = inboundNo
it.vegTypeId = vegType?.vegTypeId
it.vegTypeName = vegType?.vegTypeName
}
val result = Intent().apply {
putExtra(SHELF_MODEL, resultModel)
}
setResult(RESULT_OK, result)
finish()
},
onError = { msg -> showError(msg) }
)
}
}
private fun loadList() {
viewModel.searchFood(
request = SearchFoodRequest(
cabinetType = shelfModel?.cabinetType ?: 0,
keyword = binding.etInputFood.text.toString().trim()
),
onLoading = { showProgress() },
onSuccess = { items -> updateOptionList(items) },
onError = { msg -> showError(msg) }
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateOptionList(items: List<FoodItem>?) {
finishRefresh()
binding.include.root.gone()
binding.root.postDelayed({ Loading.dismiss() }, 200)
if (items.isNullOrEmpty()) {
loadEmptyView(); return
}
optionList.clear()
optionList.addAll(items)
optionAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
finishRefresh()
Loading.dismiss()
toast(message)
loadEmptyView()
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
optionList.clear()
optionAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener { loadList() }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_gray)
it.ivEmptyIcon.updateLayoutParams<LinearLayout.LayoutParams> {
val swDpValue = resources?.configuration?.smallestScreenWidthDp ?: 0
width = if (swDpValue > 600) 200.dp else 120.dp
}
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) it.finishRefresh(500)
else if (it.state == RefreshState.Loading) it.finishLoadMore(500)
}
}
private fun clearZero() {
lifecycleScope.launch {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(slotNum)
log("格口${shelfModel?.slotNo}执行清零指令:$zeroClearingCmd")
sendCmd(zeroClearingCmd)
realWeight = 0
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
}
KeyboardUtil.hideKeyboard(window.decorView)
}
override fun onClockTick() {
val arr = clockFormatter.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
override fun onResume() {
super.onResume()
hideStatusBar()
}
override fun onDestroy() {
EventBus.getDefault().unregister(this)
super.onDestroy()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun receiveWeightEvent(event: SendWeightEvent) {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: return
if (event.shelfNo != slotNum) return
val intervalTime = System.currentTimeMillis() - startTime
val weight = event.weight
log("格口${shelfModel?.slotNo}获取重量:${weight}克,间隔:${intervalTime}ms")
// 负漂移抑制:-5克以内的负读数显示时归零
val displayWeight = if (weight in -5..0) 0 else weight
binding.tvFoodWeight.text = if (displayWeight < 1000) "${displayWeight}"
else "%.3f千克".format(displayWeight / 1000.0)
startTime = System.currentTimeMillis()
window.decorView.postDelayed({ Loading.dismiss() }, 1000)
}
private fun showChipSelectDialog(categoryList: List<String?>, callback: (Int) -> Unit) {
ChipSelectDialog(this).apply {
dialogTitle = "净菜分类"
items = categoryList
onItemSelected = callback
show()
}
}
}
@@ -0,0 +1,39 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.sw.scalefusion.shelf.model.FoodItem
class PendingInboundAdapter(list: MutableList<FoodItem>) :
BaseQuickAdapter<FoodItem, PendingInboundAdapter.VH>(list) {
inner class VH(val binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodItem?) {
item ?: return
val isSelected = item.isSelected
holder.binding.btnFoodName.run {
text = item.materName
setTextColor(getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999))
strokeColor = ColorStateList.valueOf(
(if (isSelected) "#4969F5" else "#E6E6E6").toColorInt()
)
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,85 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfV3Binding
import com.sw.scalefusion.shelf.model.SlotModel
import kotlin.math.abs
class ShelfV3Adapter(list: MutableList<SlotModel>) :
BaseQuickAdapter<SlotModel, ShelfV3Adapter.VH>(list) {
/** 每个格口 item 的高度(像素),由 Activity 动态计算 */
var itemHeight: Int = 0
/** 每个格口 item 的宽度(像素),由 Activity 动态计算 */
var itemWidth: Int = 0
inner class VH(val binding: ListItemShelfV3Binding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemShelfV3Binding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SlotModel?) {
item ?: return
// 动态设置 item 宽高
val lp = holder.itemView.layoutParams as? FlexboxLayoutManager.LayoutParams
lp?.apply {
if (itemWidth > 0) width = itemWidth
if (itemHeight > 0) height = itemHeight
}
val binding = holder.binding
// 格口编号
binding.tvShelfName.text = item.slotNo.padStart(2, '0')
val weightG = item.weight ?: 0.0
// 负漂移抑制:空秤零点可能出现 -5克以内的负读数,显示时归零
val displayG = if (weightG in -0.005..0.0) 0.0 else weightG
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
text = if (abs(displayG) >= 1.0) "%.3f千克".format(displayG)
else "${(displayG * 1000.0).toInt()}"
}
val isBlank = item.materName.isNullOrBlank()
if (isBlank) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_white))
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
binding.tvFoodType.text = ""
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_blue))
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.materName
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.storeTime
}
binding.tvFoodType.text = item.vegTypeName
binding.tvFoodType.setTextColor(getColor(R.color.black666))
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,29 @@
package com.sw.scalefusion.shelf.model
data class PendingInboundRequest(
var cabinetType: Int,
var canteenId: Long,
var cabinetId: String
)
data class SearchFoodRequest(
var cabinetType: Int,
var keyword: String? = null
)
data class PutSlotRequest(
val cabinetType: Int? = null,
val cabinetId: String? = null,
val slotNo: String? = null,
val materId: Long? = null,
var vegTypeId: Long? = null,
val materName: String? = null,
val traceCode: String? = null,
val weight: Double? = null,
)
data class SlotDetailRequest(
var cabinetType: Int,
var cabinetId: String,
var slotNo: String
)
@@ -0,0 +1,25 @@
package com.sw.scalefusion.shelf.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.util.Date
data class FoodItem(
var materId:Long? = null,
var materName:String? = null,
var materUrl:String? = null,
var vegTypeList:List<FoodType>? = null,
var isSelected:Boolean = false,
var typeSelectedIndex:Int = -1
) {
/** 转换为 SlotModel,保留原格口的 slotNo/cabinetId 等 */
fun toSlotModel(model: SlotModel) = model.copy(
materId = materId ,
materName = materName,
storeTime = DateTimeUtil.formatDateTime(dateTime = Date()),
)
}
data class FoodType(
var vegTypeId:Long? = null,
var vegTypeName:String? = null
)
@@ -0,0 +1,32 @@
package com.sw.scalefusion.shelf.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.util.Date
/**
* /pending-inbound 接口响应 — 待入柜物料
*/
data class PendingInboundItem(
val id: Long = 0,
val materId: Long = 0,
val materName: String = "",
val traceCode: String? = null,
val weight: Double = 0.0,
val inboundNo: String = "",
val inboundTime: String? = null,
val vegTypeId: Long? = null,
val vegTypeName: String? = null,
val spec: String? = null,
var isSelected: Boolean = false
) {
/** 转换为 SlotModel,保留原格口的 slotNo/cabinetId 等 */
fun toSlotModel(model: SlotModel) = model.copy(
materId = materId ,
materName = materName,
traceCode = traceCode,
inboundNo = inboundNo,
storeTime = DateTimeUtil.formatDateTime(dateTime = Date()),
// weight = weight
)
}
@@ -0,0 +1,23 @@
package com.sw.scalefusion.shelf.model
import java.io.Serializable
/**
* /slots 接口响应模型 — 格口分组数据
*/
data class SlotModel(
val id: String = "",
val slotNo: String = "",
val cabinetId: String,
val cabinetType: Int,
val hasContent: Boolean = false,
var inboundNo: String? = null,
val materId: Long? = null,
val materName: String? = null,
val traceCode: String? = null,
var vegTypeId: Long? = null,
var vegTypeName: String? = null,
//weight单位千克
var weight: Double? = 0.0,
var storeTime: String? = null,
): Serializable
@@ -0,0 +1,12 @@
package com.sw.scalefusion.shelf.model
data class SlotRecord(
val cabinetType: Int,
val cabinetId: String,
val deviceName: String,
val canteenId: Long,
val canteenName: String,
val horizontalRows: Int,
val verticalCount: Int,
val slots: List<SlotModel>? = null
)
@@ -0,0 +1,15 @@
package com.sw.scalefusion.shelf.model
/**
* /sync-weight 接口请求体
*/
data class SyncWeightRequest(
val cabinetType: Int,
val cabinetId: String,
val slotList: List<SlotWeight>
)
data class SlotWeight(
val slotNo: String,
val weight: Double
)
@@ -0,0 +1,9 @@
package com.sw.scalefusion.shelf.net
data class ApiResponse<T>(
val code: String,
val msg: String? = "",
val data: T? = null
) {
fun isSuccess(): Boolean = ("00000" == code)
}
@@ -0,0 +1,43 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface ApiServiceV3 {
/** 格口列表 */
@GET("/nutrition/neglect/mater-cabinet/init")
suspend fun slots(@Query("cabinetId") cabinetId: String = App.deviceId): ApiResponse<SlotRecord?>
/** 待入柜列表 */
@POST("/nutrition/neglect/mater-cabinet/pending-inbound")
suspend fun pendingInbound(@Body request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?>
/** 搜索净菜、毛菜 */
@POST("/nutrition/neglect/mater-cabinet/ingredients")
suspend fun searchFood(@Body request: SearchFoodRequest): ApiResponse<List<FoodItem>?>
/** 全量同步重量 */
@POST("/nutrition/neglect/mater-cabinet/sync-weight")
suspend fun syncWeight(@Body request: SyncWeightRequest): ApiResponse<Any?>
/** 格口入柜 */
@POST("/nutrition/neglect/mater-cabinet/put")
suspend fun put(@Body request: PutSlotRequest): ApiResponse<String?>
/** 格口详情 */
@POST("/nutrition/neglect/mater-cabinet/slot-detail")
suspend fun slotDetail(@Body request: SlotDetailRequest): ApiResponse<SlotModel?>
}
@@ -0,0 +1,66 @@
package com.sw.scalefusion.shelf.net
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.net.ssl.SSLHandshakeException
abstract class BaseRepository {
suspend fun <T> safeApiCall(apiCall: suspend () -> ApiResponse<T>): ApiResponse<T> {
return try {
apiCall()
} catch (e: Exception) {
Log.e("BaseRepository","safeApiCall Exception: ${e.stackTraceToString()}")
when (e) {
is HttpException -> {
// 优先解析错误响应体中后台返回的业务 msg,解析失败再回退到 HTTP 状态短语
val bodyMsg = e.parseApiMessage()
ApiResponse(code = "${e.code()}", msg = bodyMsg ?: e.message())
}
is SocketTimeoutException -> {
ApiResponse(code = "-2", msg = "请求超时: ${e.message}")
}
is ConnectException -> {
ApiResponse(code = "-3", msg = "连接失败: ${e.message}")
}
is SSLHandshakeException -> {
ApiResponse(code = "-4", msg = "SSL握手失败: ${e.message}")
}
is JsonParseException -> {
ApiResponse(code = "-5", msg = "JSON解析错误: ${e.message}")
}
is IOException -> {
ApiResponse(code = "-6", msg = "网络IO错误: ${e.message}")
}
else -> {
ApiResponse(code = "-1", msg = "未知错误: ${e.message ?: "无错误信息"}")
}
}
}
}
/**
* 从 HttpException 的错误响应体中解析后台返回的业务 msg。
* 后端业务错误通常以非 2xx 状态码返回,body 仍为 {"code":...,"msg":...,"data":...} 结构,
* 此处提取 msg 供上层 toast 直接展示;解析失败返回 null,由调用方回退到 HTTP 状态短语。
*/
private fun HttpException.parseApiMessage(): String? {
return try {
val raw = response()?.errorBody()?.string() ?: return null
val parsed = Gson().fromJson(raw, ApiResponse::class.java)
parsed?.msg
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,95 @@
package com.sw.scalefusion.shelf.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import kotlinx.coroutines.launch
class NetViewModelV3 : ViewModel() {
private val repository = ShelfRepository()
/** 格口列表 */
fun slots(
onLoading: () -> Unit = {},
onSuccess: (SlotRecord?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slots()
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 待入柜列表 */
fun pendingInbound(
request: PendingInboundRequest,
onLoading: () -> Unit = {},
onSuccess: (List<PendingInboundItem>?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.pendingInbound(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 搜索净菜、毛菜 */
fun searchFood(request: SearchFoodRequest, onLoading: () -> Unit = {}, onSuccess: (List<FoodItem>?) -> Unit, onError: (String) -> Unit = {}) {
viewModelScope.launch {
onLoading()
val response = repository.searchFood(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 格口详情 */
fun slotDetail(
request: SlotDetailRequest,
onLoading: () -> Unit = {},
onSuccess: (SlotModel?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slotDetail(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 全量同步重量(无回调,fire-and-forget */
fun syncWeight(request: SyncWeightRequest) {
viewModelScope.launch {
repository.syncWeight(request)
}
}
/** 格口入柜(无回调,fire-and-forget */
fun put(
request: PutSlotRequest,
onLoading: () -> Unit = {},
onSuccess: (String?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.put(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
}
@@ -0,0 +1,39 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.net.apiServiceV3
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
class ShelfRepository : BaseRepository() {
/** 格口列表 */
suspend fun slots(cabinetId: String = App.deviceId): ApiResponse<SlotRecord?> =
safeApiCall { apiServiceV3.slots(cabinetId) }
/** 待入柜列表 */
suspend fun pendingInbound(request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?> =
safeApiCall { apiServiceV3.pendingInbound(request) }
/** 搜索净菜、毛菜 */
suspend fun searchFood(request: SearchFoodRequest): ApiResponse<List<FoodItem>?> =
safeApiCall { apiServiceV3.searchFood(request) }
/** 全量同步重量 */
suspend fun syncWeight(request: SyncWeightRequest): ApiResponse<Any?> =
safeApiCall { apiServiceV3.syncWeight(request) }
/** 格口入柜 */
suspend fun put(request: PutSlotRequest): ApiResponse<String?> =
safeApiCall { apiServiceV3.put(request) }
/** 格口详情 */
suspend fun slotDetail(request: SlotDetailRequest): ApiResponse<SlotModel?> =
safeApiCall { apiServiceV3.slotDetail(request) }
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bg_card_blue" android:state_checked="true" />
<item android:color="@color/white" android:state_checked="false" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bg_card_blue" android:state_checked="false" />
<item android:color="@android:color/transparent" android:state_checked="true" />
</selector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_checked="true" />
<item android:color="@color/bg_card_blue" android:state_checked="false" />
</selector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- android:width="800dp"-->
<!-- android:height="1280dp"-->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_page" />
</shape>
</item>
<item
android:width="450dp"
android:height="430dp"
android:gravity="top|center_horizontal"
android:top="150dp">
<bitmap android:src="@drawable/img_init" android:scaleType="fitCenter"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="@color/bg_card_blue" />
</shape>
</item>
</ripple>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_card_blue" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#30000000">
<item>
<shape android:shape="rectangle">
<solid android:color="#ff08C6DC" />
<corners android:radius="8dp" />
</shape>
</item>
</ripple>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/white" android:state_pressed="false" />
<item android:drawable="@color/gray_edit" android:state_pressed="true" />
</selector>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- android:width="800dp"-->
<!-- android:height="1280dp"-->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_page" />
</shape>
</item>
<item
android:width="225dp"
android:height="215dp"
android:gravity="top|center_horizontal"
android:top="75dp">
<bitmap android:src="@drawable/img_init" android:scaleType="fitCenter"/>
</item>
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/white"/>
<corners android:radius="12dp"/>
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#4ECDC4" />
<corners android:radius="12dp" />
<stroke
android:width="2dp"
android:color="#2BA39F" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 网络断开的图标 -->
<path
android:fillColor="#FF6B6B"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2c-4.42,-4.42 -11.58,-4.42 -16,0zM9,17h6v2h-6z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<!-- 由小到大排列的圆点,构成旋转加载指示器 -->
<path
android:fillColor="#ffffff"
android:pathData="M876.864,782.592c3.264,0 6.272,-3.2 6.272,-6.656c0,-3.456 -3.008,-6.592 -6.272,-6.592c-3.264,0 -6.272,3.2 -6.272,6.592c0,3.456 3.008,6.656 6.272,6.656z" />
<path
android:fillColor="#ffffff"
android:pathData="M736.32,935.936c2.304,2.432 5.568,3.84 8.768,3.84a12.16,12.16 0,0 0,8.832 -3.84a13.76,13.76 0,0 0,0 -18.56a12.224,12.224 0,0 0,-8.832 -3.84a12.16,12.16 0,0 0,-8.768 3.84a13.696,13.696 0,0 0,0 18.56z" />
<path
android:fillColor="#ffffff"
android:pathData="M552.32,1018.24c3.456,3.648 8.32,5.76 13.184,5.76a18.368,18.368 0,0 0,13.184 -5.76a20.608,20.608 0,0 0,0 -27.968a18.368,18.368 0,0 0,-13.184 -5.824a18.368,18.368 0,0 0,-13.184 5.76a20.608,20.608 0,0 0,0 28.032z" />
<path
android:fillColor="#ffffff"
android:pathData="M353.984,1012.48c4.608,4.8 11.072,7.68 17.6,7.68a24.448,24.448 0,0 0,17.536 -7.68a27.456,27.456 0,0 0,0 -37.248a24.448,24.448 0,0 0,-17.536 -7.68a24.448,24.448 0,0 0,-17.6 7.68a27.52,27.52 0,0 0,0 37.184z" />
<path
android:fillColor="#ffffff"
android:pathData="M178.304,920.64c5.76,6.08 13.824,9.6 21.952,9.6a30.592,30.592 0,0 0,22.016 -9.6a34.368,34.368 0,0 0,0 -46.592a30.592,30.592 0,0 0,-22.016 -9.6a30.592,30.592 0,0 0,-21.952 9.6a34.368,34.368 0,0 0,0 46.592z" />
<path
android:fillColor="#ffffff"
android:pathData="M57.152,761.28c6.912,7.36 16.64,11.648 26.368,11.648a36.736,36.736 0,0 0,26.432 -11.584a41.28,41.28 0,0 0,0 -55.936a36.736,36.736 0,0 0,-26.432 -11.584a36.8,36.8 0,0 0,-26.368 11.52a41.28,41.28 0,0 0,0 56z" />
<path
android:fillColor="#ffffff"
android:pathData="M12.736,564.672a42.88,42.88 0,0 0,30.784 13.44a42.88,42.88 0,0 0,30.784 -13.44a48.128,48.128 0,0 0,0 -65.216a42.88,42.88 0,0 0,-30.72 -13.44a42.88,42.88 0,0 0,-30.848 13.44a48.128,48.128 0,0 0,0 65.216z" />
<path
android:fillColor="#ffffff"
android:pathData="M52.544,369.28a48.96,48.96 0,0 0,35.2 15.36a48.96,48.96 0,0 0,35.2 -15.36a54.976,54.976 0,0 0,0 -74.56a48.96,48.96 0,0 0,-35.2 -15.424a48.96,48.96 0,0 0,-35.2 15.424a54.976,54.976 0,0 0,0 74.56z" />
<path
android:fillColor="#ffffff"
android:pathData="M168.32,212.48c10.368,11.008 24.96,17.408 39.68,17.408c14.592,0 29.184,-6.4 39.552,-17.408a61.888,61.888 0,0 0,0 -83.84a55.104,55.104 0,0 0,-39.616 -17.408c-14.656,0 -29.248,6.4 -39.616,17.408a61.888,61.888 0,0 0,0 83.84z" />
<path
android:fillColor="#ffffff"
android:pathData="M337.344,124.8c11.52,12.16 27.712,19.264 43.968,19.264c16.256,0 32.448,-7.04 43.968,-19.264a68.672,68.672 0,0 0,0 -93.184a61.248,61.248 0,0 0,-43.968 -19.264a61.248,61.248 0,0 0,-43.968 19.264a68.736,68.736 0,0 0,0 93.184z" />
<path
android:fillColor="#ffffff"
android:pathData="M526.976,123.712c12.672,13.44 30.528,21.248 48.448,21.248s35.712,-7.808 48.384,-21.248a75.584,75.584 0,0 0,0 -102.464A67.392,67.392 0,0 0,575.36 0c-17.92,0 -35.776,7.808 -48.448,21.248a75.584,75.584 0,0 0,0 102.464z" />
<path
android:fillColor="#ffffff"
android:pathData="M700.8,210.304c13.824,14.592 33.28,23.104 52.736,23.104c19.584,0 39.04,-8.512 52.8,-23.104a82.432,82.432 0,0 0,0 -111.744a73.472,73.472 0,0 0,-52.8 -23.168c-19.52,0 -38.912,8.512 -52.736,23.168a82.432,82.432 0,0 0,0 111.744z" />
<path
android:fillColor="#ffffff"
android:pathData="M824.832,368.832c14.976,15.872 36.032,25.088 57.216,25.088c21.12,0 42.24,-9.216 57.152,-25.088a89.344,89.344 0,0 0,0 -121.088a79.616,79.616 0,0 0,-57.152 -25.088c-21.184,0 -42.24,9.216 -57.216,25.088a89.344,89.344 0,0 0,0 121.088z" />
<path
android:fillColor="#ffffff"
android:pathData="M875.264,572.864c16.128,17.088 38.784,27.008 61.632,27.008c22.784,0 45.44,-9.92 61.568,-27.008a96.256,96.256 0,0 0,0 -130.432a85.76,85.76 0,0 0,-61.568 -27.072c-22.848,0 -45.44,9.984 -61.632,27.072a96.192,96.192 0,0 0,0 130.432z" />
</vector>
+2 -2
View File
@@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bg_page">
tools:background="@color/bg_page">
<FrameLayout
android:id="@+id/flStatusBar"
@@ -23,7 +23,7 @@
android:layout_gravity="start"
android:textSize="24sp"
android:fontFamily="sans-serif-medium"
tools:text="1-1冷藏柜 -2°C / 87%" />
android:text=" " />
<TextView
android:id="@+id/tv_right_status"
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
@@ -15,6 +15,12 @@
android:overScrollMode="never"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
tools:listitem="@layout/list_item_shelf"/>
tools:listitem="@layout/list_item_shelf_v3"/>
</LinearLayout>
<include
android:id="@+id/include"
android:layout_width="match_parent"
android:layout_height="match_parent"
layout="@layout/layout_empty_view"
android:visibility="gone"/>
</FrameLayout>
+145
View File
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/llDeviceInit"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="450dp"
android:layout_height="430dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="150dp"
android:src="@drawable/img_init"
tools:ignore="ContentDescription"
android:visibility="invisible"/>
<TextView
android:id="@+id/btnInit"
android:layout_width="300dp"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="100dp"
android:background="@drawable/bg_init_button"
android:gravity="center"
android:padding="10dp"
android:text="设备初始化"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
android:visibility="visible"
tools:ignore="HardcodedText" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageView
android:id="@+id/ivQrCode"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center_horizontal|bottom"
android:layout_marginBottom="50dp"
tools:ignore="ContentDescription"
tools:src="@mipmap/ic_logo512" />
</LinearLayout>
<LinearLayout
android:id="@+id/llNetwork"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center">
<!-- Loading 动画容器 -->
<LinearLayout
android:id="@+id/llLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="visible"
android:layout_marginTop="260dp">
<!-- 加载动画 -->
<ImageView
android:id="@+id/ivLoading"
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/loading"
android:contentDescription="加载中" />
<!-- 倒计时文字 -->
<TextView
android:id="@+id/tvCountdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="60秒"
android:textSize="40sp"
android:textColor="@color/white"
android:textStyle="bold"
android:layout_marginTop="50dp" />
<!-- 提示文字 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络连接检测中,请稍后……"
android:textSize="22sp"
android:textColor="@color/white"
android:layout_marginTop="20dp" />
</LinearLayout>
<!-- 连接网络按钮容器 -->
<LinearLayout
android:id="@+id/llNetworkButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<!-- 网络错误图标 -->
<ImageView
android:layout_width="140dp"
android:layout_height="140dp"
android:src="@drawable/ic_network_error"
android:contentDescription="网络连接失败"
android:layout_marginBottom="30dp" />
<!-- 连接失败提示 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络连接失败"
android:textSize="36sp"
android:textColor="@color/white"
android:textStyle="bold"
android:layout_marginBottom="40dp" />
<!-- 连接网络按钮 -->
<Button
android:id="@+id/btnConnectNetwork"
android:layout_width="350dp"
android:layout_height="70dp"
android:text="连接网络"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@android:color/white"
android:background="@drawable/btn_connect_network" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/ivBack"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_gravity="top|start"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:src="@drawable/ic_back_512"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="设置"
android:textColor="@color/black"
android:textSize="28sp" />
<Button
android:id="@+id/btnFind"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="15dp"
android:textSize="18sp"
android:layout_gravity="end|center_vertical"
android:text="查找1001指令"/>
</FrameLayout>
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@android:color/white"
android:hint="搜索日志..."
android:paddingHorizontal="12dp"
android:textSize="14sp"
android:singleLine="true"
tools:ignore="Autofill,TextFields" />
<HorizontalScrollView
android:id="@+id/hsvTimeNav"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/llTimeNav"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingHorizontal="12dp" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvLogList"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabJump"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_sort_by_size"
app:fabSize="normal"
tools:ignore="ContentDescription" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
@@ -35,10 +35,10 @@
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_marginTop="24dp"
android:fontFamily="sans-serif-medium"
android:gravity="center"
android:textColor="@color/bg_page"
android:textSize="30sp"
android:fontFamily="sans-serif-medium"
tools:text="货架 - 03" />
<Space
@@ -74,6 +74,16 @@
android:textStyle="bold"
tools:text="金针菇" />
<TextView
android:id="@+id/tvVegTypeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_name_black"
android:textSize="22sp"
tools:text="金针菇" />
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
@@ -89,17 +99,26 @@
android:layout_marginEnd="24dp"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清零"
android:fontFamily="sans-serif-medium"
android:textColor="@color/white"
android:textSize="30sp"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<ImageView
android:id="@+id/ivBack"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="top|start"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:src="@drawable/ic_back_512"
tools:ignore="ContentDescription" />
</FrameLayout>
</com.google.android.material.card.MaterialCardView>
@@ -120,22 +139,25 @@
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 搜索框 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="48dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="48dp"
android:background="@drawable/shape_search"
android:gravity="center_vertical"
android:orientation="horizontal">
android:orientation="horizontal"
android:visibility="visible">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="24dp"
android:layout_marginEnd="24dp"
android:src="@drawable/ic_search_food" />
android:src="@drawable/ic_search_food"
tools:ignore="ContentDescription" />
<EditText
android:id="@+id/etInputFood"
@@ -143,56 +165,118 @@
android:layout_height="60dp"
android:layout_marginEnd="24dp"
android:background="@null"
android:hint="输入食材名称"
android:maxLines="1"
android:inputType="text"
android:text=""
android:fontFamily="sans-serif-medium"
android:hint="输入名称搜索"
android:imeOptions="actionSearch"
android:textColor="@color/food_name_black"
android:inputType="text"
android:maxLines="1"
android:paddingStart="1dp"
android:paddingEnd="1dp"
android:text=""
android:textColor="@color/food_name_black"
android:textColorHint="#B4BEC8"
android:fontFamily="sans-serif-medium"
android:textSize="30sp"
tools:ignore="Autofill,HardcodedText,TextFields" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="36dp"
android:layout_marginTop="36dp"
android:layout_marginEnd="36dp"
android:layout_weight="1"
android:overScrollMode="never"
tools:listitem="@layout/list_item_search"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="10"/>
android:layout_marginTop="24dp"
android:layout_weight="1">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfirm"
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingStart="36dp"
android:paddingTop="12dp"
android:paddingEnd="36dp"
android:paddingBottom="12dp"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="10"
tools:listitem="@layout/list_item_search" />
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<include
android:id="@+id/include"
layout="@layout/layout_empty_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginTop="48dp"
android:layout_marginEnd="48dp"
android:layout_marginStart="48dp"
android:layout_marginBottom="48dp"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="确定"
android:textStyle="bold"
android:textColor="@color/white"
android:textSize="30sp"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearEmpty"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="48dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="清空"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="48dp"
android:layout_marginBottom="24dp"
android:layout_weight="1"
android:backgroundTint="@color/bg_page"
android:ellipsize="end"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:elevation="0dp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="500dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 标题 -->
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="40dp"
android:gravity="center"
android:textColor="#ff141428"
android:textSize="32sp"
android:textStyle="bold" />
<!-- ChipGroup 选择区域 -->
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="30dp"
android:layout_marginVertical="50dp"
app:chipSpacingHorizontal="30dp"
app:chipSpacingVertical="18dp"
app:selectionRequired="false"
app:singleSelection="true" />
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#FFDCDCF0" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_pressed"
android:text="取消"
android:textColor="@android:color/darker_gray"
android:textSize="24sp" />
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#FFDCDCF0" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_pressed"
android:text="确定"
android:textColor="@color/bg_card_blue"
android:textSize="24sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="600dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_white_radius12"
android:orientation="vertical"
android:paddingHorizontal="40dp"
android:paddingVertical="36dp">
<!-- 标题 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="环境切换"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold" />
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 环境选项 -->
<RadioGroup
android:id="@+id/rgEnv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:orientation="vertical">
<RadioButton
android:id="@+id/rbTest"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="本地环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbUat"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="测试环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbProd"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="生产环境"
android:textColor="#ff141428"
android:textSize="28sp" />
</RadioGroup>
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="88dp"
android:layout_marginTop="24dp"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:layout_weight="1"
android:background="@drawable/bg_cancel"
android:text="取消"
android:textColor="@color/bg_card_blue"
android:textSize="28sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_weight="1"
android:background="@drawable/bg_confirm"
android:text="确认"
android:textColor="#FFFFFF"
android:textSize="28sp" />
</LinearLayout>
</LinearLayout>
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="400dp"
android:layout_height="300dp"
app:cardCornerRadius="10dp"
app:cardElevation="0dp"
xmlns:tools="http://schemas.android.com/tools"
app:cardBackgroundColor="@color/white">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginVertical="30dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textColor="@color/black"
android:textSize="22sp"
tools:text="标题"/>
<TextView
android:id="@+id/tvDialogContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_gravity="center_horizontal"
android:textColor="@color/black"
android:gravity="center"
android:layout_marginHorizontal="50dp"
android:textSize="16sp"
tools:text="内容"/>
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="120dp"
android:layout_height="50dp"
android:text="确定"
android:background="@color/bg_card_blue"
android:textColor="@color/white"
android:textSize="20sp"
android:layout_gravity="center_horizontal"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="20dp"
android:paddingTop="10dp"
android:paddingEnd="20dp"
android:paddingBottom="10dp"
android:background="@drawable/shape_dialog"
tools:ignore="MissingDefaultResource">
<ProgressBar
android:layout_width="60dp"
android:layout_height="60dp"
android:indeterminateTint="@color/white_f6" />
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="加载中……"
android:textColor="@color/white_f6"
android:textSize="26sp"
tools:ignore="HardcodedText" />
</LinearLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:layout_gravity="center"
android:orientation="vertical"
tools:ignore="MissingDefaultResource,UseCompoundDrawables">
<ImageView
android:id="@+id/ivEmptyIcon"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:src="@drawable/ic_empty_gray"
android:adjustViewBounds="true"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/tvEmptyContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black999"
android:layout_marginTop="50dp"
android:textSize="50sp"
android:text="暂无数据"
tools:ignore="HardcodedText" />
</LinearLayout>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="60dp">
<TextView
android:id="@+id/tvLogName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="30dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="20sp"
tools:text="2025-11-18" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="bottom"
android:layout_marginHorizontal="30dp"
app:dividerColor="@color/gray_edit" />
</FrameLayout>
@@ -36,6 +36,9 @@
android:maxLines="1"
app:paddingStart="5dp"
app:paddingEnd="5dp"
android:insetRight="0dp"
android:insetLeft="0dp"
android:paddingHorizontal="8dp"
android:textColor="@color/black999"
android:textSize="30sp"
app:cornerRadius="12dp"
@@ -3,17 +3,17 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layoutCard"
android:layout_width="match_parent"
android:layout_height="216dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="12dp"
app:cardBackgroundColor="@color/bg_card_blue"
app:cardCornerRadius="12dp"
app:cardElevation="5dp"
app:cardElevation="0dp"
app:rippleColor="#B0BEC5">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="216dp"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="24dp">
@@ -38,15 +38,28 @@
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
tools:text="空" />
<TextView
android:id="@+id/tvFoodType"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:ellipsize="end"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="20sp"
tools:text="切块" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.833" />
android:layout_weight="0.8" />
<TextView
android:id="@+id/tvFoodWeight"
@@ -62,7 +75,7 @@
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.933" />
android:layout_weight="0.9" />
<TextView
android:id="@+id/tvStoreDate"
@@ -75,5 +88,13 @@
tools:text="-" />
</LinearLayout>
<ImageView
android:id="@+id/ivStaleFood"
android:layout_width="100dp"
android:layout_height="100dp"
android:src="@drawable/ic_stale_food_512"
android:layout_gravity="top|end"
tools:ignore="ContentDescription"
android:visibility="gone"/>
</com.google.android.material.card.MaterialCardView>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:elevation="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_env_switch"
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:text="切换环境"
android:textColor="#333333"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:background="#E0E0E0" />
<TextView
android:id="@+id/tv_log"
android:layout_width="match_parent"
android:layout_height="50dp"
android:gravity="center"
android:text="查看日志"
android:textColor="#333333"
android:textSize="14sp" />
</LinearLayout>

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