37 Commits
Author SHA1 Message Date
马增飞 9818dfc438 结算 2025-11-19 12:05:12 +08:00
mazengfei cb9d68d2e3 功能优化 2025-11-17 16:09:01 +08:00
mazengfei b4087e435e 修改模式切换流程 2025-11-10 18:55:21 +08:00
lvmeng 4abc855b36 功能优化 2025-11-07 14:23:53 +08:00
mazengfei c898cbb3e2 修改模式切换流程 2025-11-06 19:19:45 +08:00
mazengfei 0a09814a61 修改菜品识别逻辑 2025-11-06 17:49:47 +08:00
mazengfei cc2fd2d743 修改菜品识别逻辑 2025-11-06 14:18:48 +08:00
mazengfei cd883af92f 修改菜品识别逻辑 2025-11-04 15:46:40 +08:00
mazengfei 9c60049843 修改菜品识别逻辑 2025-10-31 23:19:42 +08:00
mazengfei e47b362883 添加菜品采集 2025-10-31 09:53:51 +08:00
马增飞 ed6c5e83be 新增菜品采集 2025-10-18 15:06:07 +08:00
lvmeng a27cc5f7ee 识别图片处理、查询菜品数据 2025-10-17 14:47:28 +08:00
lvmeng dd51448aea 处理gradle冲突 2025-10-16 19:44:28 +08:00
lvmeng b62738aa15 Merge branch 'dev_lvmeng_1016'
# Conflicts:
#	app/build.gradle.kts
#	build.gradle.kts
#	settings.gradle.kts
2025-10-16 19:30:22 +08:00
lvmeng 7a60f4b34d Merge remote-tracking branch 'origin/master' 2025-10-16 19:23:58 +08:00
lvmeng c23aedf464 支持菜品识别 2025-10-16 19:20:59 +08:00
mazengfei 795e685a3a 修改配置参数 2025-10-16 16:40:43 +08:00
lvmeng d9b725cb63 Merge remote-tracking branch 'origin/master' 2025-10-16 13:35:06 +08:00
mazengfei bb63b47b97 导入pytorch 2025-10-16 13:32:59 +08:00
lvmeng caefbd0944 优化 2025-09-26 16:02:18 +08:00
zxj d48f41e917 添加注释 2025-08-22 16:36:17 +08:00
zxj 18fbdc9875 添加开机自启功能 2025-08-21 15:45:35 +08:00
zxj f055441340 添加了模拟识别菜品功能 2025-08-13 11:29:08 +08:00
zxj e93550a24b 处理副屏加载失败导致的崩溃问题 2025-08-11 17:53:20 +08:00
zxj fde2cd0544 调整了人脸识别背景及提示位置 2025-08-11 17:13:48 +08:00
zxj fc326a8f42 修复了柱状图显示问题,修复了菜品图片显示问题,处理了人脸采集框变形问题 2025-08-11 15:38:06 +08:00
zxj 4086f9c0b7 修复了识别窗口追踪功能 2025-08-08 10:25:54 +08:00
zxj 4a29b3adc9 添加灯操作 2025-08-07 17:54:45 +08:00
zxj 78a682b664 添加灯说明 2025-08-07 17:54:10 +08:00
zxj 2e20e7deab 添加了崩溃抓取 2025-08-07 17:10:59 +08:00
zxj c3489ab9e6 修复了首次显示人脸识别窗口慢的问题 2025-08-07 16:30:05 +08:00
zxj 1f1c3804ac 添加了初始化界面,修复了人脸数据更新后不能识别的问题 2025-08-07 16:23:21 +08:00
zxj 742b3a0775 清理了上一次的信息 2025-08-07 11:49:11 +08:00
zxj 71ce668e3c 调整了个人营养数据计算逻辑,修复了摄像头逻辑 2025-08-07 10:49:50 +08:00
zxj 561a2fc9b2 格式化文档 2025-08-07 10:47:21 +08:00
zxj 08611349aa 精简了称sdk调用 2025-08-06 14:45:07 +08:00
zxj bbf2e605b5 修复了多次步骤2到步骤3问题 2025-08-05 14:23:13 +08:00
135 changed files with 5027 additions and 808 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-07-31T00:35:57.879961400Z">
<DropdownSelection timestamp="2025-11-07T10:55:54.677927600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2FD2511004054749" />
<DeviceId pluginId="Default" identifier="serial=192.168.1.56:5555;connection=2687c52a" />
</handle>
</Target>
</DropdownSelection>
+1 -1
View File
@@ -6,7 +6,7 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="gradleJvm" value="azul-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
+1 -1
View File
@@ -1,6 +1,6 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="zulu-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+276
View File
@@ -0,0 +1,276 @@
{
"info": {
"_postman_id": "adfceb8d-2c35-41bf-82d9-40b87e10524d",
"name": "档口机",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "751386"
},
"item": [{
"name": "device获取token",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"
raw": "http://device.shuziweidao.com:8889/sys/getEquipmentToken?qrcodeId=3ea47dc0-3cf0-3c2f-909c-265a9a65572e",
"protocol": "http",
"host": [
"device",
"shuziweidao",
"com"
],
"port": "8889",
"path": [
"sys",
"getEquipmentToken"
],
"query": [{
"key": "qrcodeId",
"value": "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
}
]
}
},
"response": []
}, {
"name": "获取配置信息",
"request": {
"method": "GET",
"header": [{
"key": "X-Access-Token",
"value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTM4NDIzNDEsInVzZXJuYW1lIjoiYWRtaW4ifQ.gcKT7BeMWNve8NydRmTTrdXW8wdS7W_Ot9kVdtllyuc",
"type": "text"
}
],
"url": {
"
raw": "http://device.shuziweidao.com:8889/equipment/stEquipment/queryByEquipmentCode?equipmentCode=3ea47dc0-3cf0-3c2f-909c-265a9a65572e",
"protocol": "http",
"host": [
"device",
"shuziweidao",
"com"
],
"port": "8889",
"path": [
"equipment",
"stEquipment",
"queryByEquipmentCode"
],
"query": [{
"key": "equipmentCode",
"value": "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
}
]
}
},
"response": []
}, {
"name": "获取业务服务器token",
"request": {
"method": "GET",
"header": [],
"url": {
"
raw": "http://192.168.1.207/gateway/local/sys/getEquipmentToken?qrcodeId=3ea47dc0-3cf0-3c2f-909c-265a9a65572e",
"protocol": "http",
"host": [
"192",
"168",
"1",
"207"
],
"path": [
"gateway",
"local",
"sys",
"getEquipmentToken"
],
"query": [{
"key": "qrcodeId",
"value": "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
}
]
}
},
"response": []
}, {
"name": "获取人脸数据",
"request": {
"method": "GET",
"header": [{
"key": "X-Access-Token",
"value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiIyOCIsInR5cGUiOiI0IiwidXNlcklkIjoiMDAwMDAwMDAwMDAwMDAwMDAwMSJ9.Jy7Dc2k5FlqUAmGReRn-uEg3Du5gtlyUhXKUMZLlO-E",
"type": "text"
}
],
"url": {
"raw": "http://192.168.1.207/gateway/local/stapi/cquser/getUserFaceCache/v2?pageIndex=0",
"protocol": "http",
"host": [
"192",
"168",
"1",
"207"
],
"path": [
"gateway",
"local",
"stapi",
"cquser",
"getUserFaceCache",
"v2"
],
"query": [{
"key": "pageIndex",
"value": "0"
}
]
}
},
"response": []
}, {
"name": "获取档口菜品信息",
"request": {
"method": "GET",
"header": [{
"key": "X-Access-Token",
"value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiIyOCIsInR5cGUiOiI0IiwidXNlcklkIjoiMDAwMDAwMDAwMDAwMDAwMDAwMSJ9.Jy7Dc2k5FlqUAmGReRn-uEg3Du5gtlyUhXKUMZLlO-E",
"type": "text"
}
],
"url": {
"
raw": "http://192.168.1.207/gateway/local/zhstapi/zhst/getRestInfoFoodsByType/stall?restId=1678234139391512577&type=0&foodName=西红柿",
"protocol": "http",
"host": [
"192",
"168",
"1",
"207"
],
"path": [
"gateway",
"local",
"zhstapi",
"zhst",
"getRestInfoFoodsByType",
"stall"
],
"query": [{
"key": "restId",
"value": "1678234139391512577"
}, {
"key": "type",
"value": "0",
"description": "0全部 1餐次"
}, {
"key": "foodName",
"value": "西红柿"
}
]
}
},
"response": []
}, {
"name": "通过用户信息获取就餐数据",
"request": {
"method": "GET",
"header": [{
"key": "X-Access-Token",
"value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiIyOCIsInR5cGUiOiI0IiwidXNlcklkIjoiMDAwMDAwMDAwMDAwMDAwMDAwMSJ9.Jy7Dc2k5FlqUAmGReRn-uEg3Du5gtlyUhXKUMZLlO-E",
"type": "text"
}
],
"url": {
"
raw": "http://192.168.1.207/gateway/local/zhstapi/zhst/getUserNutritionData/face?restId=1678234139391512577&userId=4958698b725b4e89a514fa8552aa5fa3&foodId=1701417944860119041",
"protocol": "http",
"host": [
"192",
"168",
"1",
"207"
],
"path": [
"gateway",
"local",
"zhstapi",
"zhst",
"getUserNutritionData",
"face"
],
"query": [{
"key": "restId",
"value": "1678234139391512577"
}, {
"key": "userId",
"value": "4958698b725b4e89a514fa8552aa5fa3"
}, {
"key": "foodId",
"value": "1701417944860119041"
}
]
}
},
"response": []
}, {
"name": "提交就餐数据",
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"
raw": "http://192.168.1.207/gateway/local/zhstapi/zhst/userEatFood/doubleBalance/face/stall?restId=1678234139391512577&userId=4958698b725b4e89a514fa8552aa5fa3&foodId=1701417944860119041&faceTime&faceEndTime&eatWeight&foodWeight&deviceId",
"protocol": "http",
"host": [
"192",
"168",
"1",
"207"
],
"path": [
"gateway",
"local",
"zhstapi",
"zhst",
"userEatFood",
"doubleBalance",
"face",
"stall"
],
"body": [{
"appVersion": "1",
"deviceId": "abe60247-99d1-3c2a-9f10-c0b76f4b3f3b",
"eatWeight": 0.278,
"faceEndTime": 1754385701611,
"faceTime": 1754385694475,
"foodId": "1701417944860119041",
"foodWeight": 0.278,
"restId": "1678234139391512577",
"userId": "1937692681894932481"
}
]
}
},
"response": []
}
]
}
### 灯规则
默认 红
识别到菜 黄
识别到人 绿
+24 -12
View File
@@ -1,9 +1,12 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("com.google.devtools.ksp")
// alias(libs.plugins.android.application)
// alias(libs.plugins.kotlin.android)
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
id("kotlin-parcelize")
kotlin("kapt")
}
android {
@@ -29,6 +32,7 @@ android {
ndk {
abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/))
}
setProperty("archivesBaseName", "档口双屏_${versionName}")
}
buildTypes {
@@ -53,14 +57,7 @@ android {
}
dependencies {
// implementation(
// fileTree(
// mapOf(
// "dir" to "libs",
// "include" to listOf("*.aar", "*.jar")
// )
// )
// )
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
@@ -104,4 +101,19 @@ dependencies {
implementation(libs.androidx.fragment.ktx)
// 图片加载
implementation(libs.glide)
}
implementation(libs.core)
implementation(libs.android.core)
implementation(libs.pytorch.android)
implementation(libs.pytorch.android.torchvision)
val objectboxVersion = "5.0.1"
debugImplementation("io.objectbox:objectbox-android-objectbrowser:$objectboxVersion")
// releaseImplementation("io.objectbox:objectbox-android:$objectboxVersion")
implementation(libs.androidx.recyclerview)
}
apply(plugin = "io.objectbox")
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:594331511073099531",
"lastPropertyId": "4:1528837175750321569",
"name": "Food",
"properties": [
{
"id": "1:2317727855243736226",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:340951913928211738",
"name": "name",
"type": 9
},
{
"id": "3:5484846939472684412",
"name": "foodIdx",
"type": 5
},
{
"id": "4:1528837175750321569",
"name": "foodVector",
"indexId": "1:6010838397086628487",
"type": 28,
"flags": 8
}
],
"relations": []
}
],
"lastEntityId": "1:594331511073099531",
"lastIndexId": "1:6010838397086628487",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}
+27
View File
@@ -14,6 +14,7 @@
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".MyApp"
@@ -25,8 +26,18 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DualScreen"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- 注册BootReceiver,监听开机完成广播 -->
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name=".activity.InitActivity"
android:exported="true">
@@ -40,6 +51,22 @@
android:name=".activity.MainActivity"
android:exported="true">
</activity>
<activity
android:name=".activity.FoodCollectionActivity"
android:exported="true">
</activity>
<activity android:name="com.sw.dualscreen.activity.CollectedDataActivity" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"class_names": [
"回锅肉",
"手撕包菜",
"炒细面",
"红烧肉",
"肉沫酸豆角",
"蒜苔炒肉",
"西红柿鸡蛋",
"豆角茄子",
"麻婆豆腐",
"麻辣小面"
],
"class_to_idx": {
"回锅肉": 0,
"手撕包菜": 1,
"炒细面": 2,
"红烧肉": 3,
"肉沫酸豆角": 4,
"蒜苔炒肉": 5,
"西红柿鸡蛋": 6,
"豆角茄子": 7,
"麻婆豆腐": 8,
"麻辣小面": 9
},
"idx_to_class": {
"0": "回锅肉",
"1": "手撕包菜",
"2": "炒细面",
"3": "红烧肉",
"4": "肉沫酸豆角",
"5": "蒜苔炒肉",
"6": "西红柿鸡蛋",
"7": "豆角茄子",
"8": "麻婆豆腐",
"9": "麻辣小面"
},
"embedding_dim": 512,
"total_images": 500,
"build_time": "2025-10-16T17:51:06.285784"
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9]
@@ -7,6 +7,11 @@ object GlobalData {
// var globalEquipmentCode: String = "202501171634"
var restId: String = ""
/**
* 设备id
*/
var deviceId: String = ""
/**
* app版本号
*/
@@ -34,7 +39,7 @@ object GlobalData {
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var activeKey = "085F-118G-Q3AB-1WVJ"
var activeKey = "085F-118G-Q3LH-HPGZ"//2号楼"085F-118G-Q3AB-1WVJ" 5号楼"085F-118G-Q3LH-HPGZ"
}
/**
@@ -55,4 +60,5 @@ object GlobalKey {
const val KEY_TOKEN = "tokenKey"
const val KEY_FIRST_RUN = "firstRun"
const val KEY_USER_INFO = "userInfoKey"
const val KEY_PICKUP_MODE = "pickupMode"
}
@@ -1,15 +1,27 @@
package com.sw.dualscreen
import com.sw.dualscreen.objbox.ObjectBox
import com.sw.dualscreen.utils.CrashHandler
import com.sw.plate.App
import com.sw.plate.utils.AppUtil
import timber.log.Timber
class MyApp : App() {
companion object {
const val DEBUG: Boolean = true
var instance: MyApp?=null
}
override fun onCreate() {
super.onCreate()
instance = this
Timber.plant(Timber.DebugTree())
var deviceId = AppUtil.getUDID(this)
Timber.d("UDID = ${AppUtil.getUDID(this)}")
deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
GlobalData.deviceId = deviceId
ObjectBox.init(this)
// 初始化崩溃处理器
CrashHandler.init(this)
}
}
@@ -0,0 +1,100 @@
package com.sw.dualscreen.activity
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.sw.dualscreen.R
import com.sw.dualscreen.model.IActivityResult
import com.sw.dualscreen.view.CustomDialog
import com.sw.dualscreen.viewmodel.BaseViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
protected lateinit var binding: VB
protected lateinit var context: Context
private var mDialogWaiting: CustomDialog? = null
private lateinit var viewModel: BaseViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
context = this
//保持亮屏
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
binding = inflateViewBinding()
setContentView(binding.root)
viewModel = getViewModel()
initialize()
registerDataChange()
}
abstract fun getViewModel(): BaseViewModel
protected abstract fun inflateViewBinding(): VB
open fun initialize() {
}
open fun registerDataChange() {
lifecycleScope.launch {
viewModel.showLoading.collect {
Timber.d("registerDataChange 用户 showLoading = $it")
if (it) {
showWaitingDialog("")
} else {
hideWaitingDialog()
}
}
}
}
/**
* 显示等待提示框
*/
fun showWaitingDialog(tip: String?): Dialog? {
hideWaitingDialog()
val view = View.inflate(this, R.layout.dialog_waiting, null)
if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).text = tip
mDialogWaiting = CustomDialog(this, view, R.style.MyDialog)
mDialogWaiting!!.show()
mDialogWaiting!!.setCancelable(true)
return mDialogWaiting
}
/**
* 隐藏等待提示框
*/
fun hideWaitingDialog() {
mDialogWaiting?.dismiss()
mDialogWaiting = null
}
private var launchPermissionCallback: IActivityResult.RequestPermissionCallback? = null
fun requestSinglePermissionResult(
permission: String,
permissionCallback: IActivityResult.RequestPermissionCallback?
) {
this.launchPermissionCallback = permissionCallback
permissionLauncher.launch(permission)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { result: Boolean ->
launchPermissionCallback?.callback(result)
}
}
@@ -0,0 +1,134 @@
package com.sw.dualscreen.activity
import android.annotation.SuppressLint
import androidx.activity.viewModels
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.dualscreen.R
import com.sw.dualscreen.adapter.CollectedFoodAdapter
import com.sw.dualscreen.databinding.ActivityCollectedDataBinding
import com.sw.dualscreen.databinding.LayoutEmptySearchBinding
import com.sw.dualscreen.dialog.WarnDialog
import com.sw.dualscreen.ext.addOnActionSearchListener
import com.sw.dualscreen.ext.hideKeyboard
import com.sw.dualscreen.objbox.CollectedFoodBean
import com.sw.dualscreen.objbox.Food
import com.sw.dualscreen.objbox.ObjectBox
import com.sw.dualscreen.viewmodel.BaseViewModel
import com.sw.dualscreen.viewmodel.UserViewModel
import com.sw.plate.utils.ToastUtils
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import kotlin.getValue
class CollectedDataActivity : BaseActivity<ActivityCollectedDataBinding>() {
private val viewModel by viewModels<UserViewModel>()
override fun getViewModel(): BaseViewModel {
return viewModel
}
override fun inflateViewBinding(): ActivityCollectedDataBinding {
return ActivityCollectedDataBinding.inflate(layoutInflater)
}
private val list: MutableList<CollectedFoodBean> = mutableListOf()
private val adapter by lazy {
CollectedFoodAdapter(list).apply {
isStateViewEnable = true
addOnItemChildClickListener(R.id.ivDeleteFood) { _, _, position ->
deleteGoods(position)
}
}
}
override fun initialize() {
super.initialize()
binding.rvFoodList.let {
it.layoutManager = LinearLayoutManager(this)
it.adapter = adapter
}
binding.ivBack.setOnClickListener { finish() }
binding.ivGoodsSearch.setOnClickListener {
val searchName = binding.etInputGoods.text.toString().trim()
if (searchName.isBlank()) {
ToastUtils.showToast("请输入物品名称")
return@setOnClickListener
}
getCollectGoods(searchName)
}
binding.etInputGoods.let { v ->
v.addOnActionSearchListener {
val searchName = binding.etInputGoods.text.toString().trim()
if (searchName.isBlank()) {
ToastUtils.showToast("请输入物品名称")
return@addOnActionSearchListener
}
getCollectGoods(searchName)
}
v.addTextChangedListener {
if (it.isNullOrBlank()) {
getCollectGoods()
}
}
}
getCollectGoods()
}
private var box: Box<Food>? = null
@SuppressLint("NotifyDataSetChanged")
private fun getCollectGoods(searchName:String?=null) {
if (box == null) {
box = ObjectBox.boxStore.boxFor(Food::class)
}
list.clear()
var queryList = box?.all?.distinctBy { it.name }
if (searchName.isNullOrBlank().not()) {
queryList = queryList?.filter { it.name?.contains(searchName) == true }
}
queryList?.forEach {
list.add(CollectedFoodBean(foodName = it.name))
}
adapter.notifyDataSetChanged()
if (list.isEmpty()) {
loadEmptyView()
}
binding.root.hideKeyboard()
}
private var emptyBinding: LayoutEmptySearchBinding? = null
private fun loadEmptyView() {
if (emptyBinding == null) {
emptyBinding = LayoutEmptySearchBinding.inflate(layoutInflater, binding.rvFoodList, false)
}
emptyBinding?.root?.let { layout ->
layout.setOnClickListener { layout.hideKeyboard() }
adapter.stateView = layout
}
}
private fun deleteGoods(position: Int) {
WarnDialog(
context = context,
content = "请确认是否删除菜品:${list[position].foodName}",
confirmBlock = {
Thread {
runOnUiThread {
showWaitingDialog("加载中……")
}
val name = list[position].foodName
val filterIdList = box?.all?.filter { it.name == name }?.map { it.id }
box?.removeByIds(filterIdList)
runOnUiThread {
hideWaitingDialog()
adapter.removeAt(position)
ToastUtils.showToast("删除成功")
if (list.isEmpty()) {
loadEmptyView()
}
}
}.start()
}).show()
}
}
@@ -0,0 +1,370 @@
package com.sw.dualscreen.activity
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.net.Uri
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.viewModels
import androidx.camera.view.PreviewView
import androidx.core.net.toUri
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.example.utils.FloatBase64Utils
import com.sw.dualscreen.R
import com.sw.dualscreen.adapter.FoodCollectionAdapter
import com.sw.dualscreen.adapter.GenericItemAdapter
import com.sw.dualscreen.adapter.GridSpacingItemDecoration
import com.sw.dualscreen.adapter.dpToPx
import com.sw.dualscreen.databinding.ActivityFoodCollectionBinding
import com.sw.dualscreen.databinding.ItemSearchFoodInfoBinding
import com.sw.dualscreen.databinding.LayoutCameraPreviewBinding
import com.sw.dualscreen.ext.clickWithDebounce
import com.sw.dualscreen.ext.dp
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.objbox.Food
import com.sw.dualscreen.objbox.FoodCollectionBean
import com.sw.dualscreen.objbox.FoodModule
import com.sw.dualscreen.objbox.ObjectBox
import com.sw.dualscreen.utils.BitmapCropper
import com.sw.dualscreen.utils.BitmapSaver
import com.sw.dualscreen.utils.CameraHelper
import com.sw.dualscreen.utils.CameraUtils
import com.sw.dualscreen.utils.Debouncer
import com.sw.dualscreen.utils.ImageUtil
import com.sw.dualscreen.viewmodel.BaseViewModel
import com.sw.dualscreen.viewmodel.UserViewModel
import com.sw.plate.utils.ToastUtils
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.launch
import org.pytorch.IValue
import org.pytorch.Module
import org.pytorch.torchvision.TensorImageUtils
import timber.log.Timber
import kotlin.math.max
class FoodCollectionActivity : BaseActivity<ActivityFoodCollectionBinding>() {
companion object {
val MAX_COUNT = 100
}
private val viewModel by viewModels<UserViewModel>()
private var selectedFoodId: String? = ""
private var selectedFoodName: String? = ""
private var box: Box<Food>? = null
private val foodCollectionList: MutableList<FoodCollectionBean> = mutableListOf()
// private lateinit var cameraHelper: CameraHelper
private lateinit var foodAdapter: GenericItemAdapter<FoodInfo, ItemSearchFoodInfoBinding>
private val foodList = mutableListOf<FoodInfo>() // 适配器内部维护的数据列表
private val debouncer = Debouncer(2000)
private lateinit var previewView: PreviewView
private val cameraUtils: CameraUtils by lazy {
CameraUtils(this)
}
private val collectionAdapter: FoodCollectionAdapter by lazy {
FoodCollectionAdapter(foodCollectionList).apply {
// setOnItemClickListener { _, _, position ->
// if (list[position].isShowCamera) {
//// takePhoto()
// cameraHelper.openCamera()
// }
// }
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
// list.removeAt(position)
// collectionAdapter.notifyItemRemoved(position)
// collectionAdapter.notifyItemRangeChanged(position, list.size)
foodCollectionList[position].let {
it.bitmap = null
it.isShowCamera = true
it.isFinish = false
}
notifyItemChanged(position)
}
}
}
override fun getViewModel(): BaseViewModel {
return viewModel
}
override fun inflateViewBinding(): ActivityFoodCollectionBinding {
return ActivityFoodCollectionBinding.inflate(layoutInflater)
}
private val cameraCallback: (Uri) -> Unit = { uri ->
val index = foodCollectionList.indexOfFirst { it.bitmap == null }
if (index == -1) {
ToastUtils.showToast("每次只允许保存${MAX_COUNT}条数据")
rerurn@ cameraCallback
}
ImageUtil.uriToBitmap(this, uri)?.let { bitmap ->
// val cropBitmap = BitmapCropper.cropCenter(
// original = bitmap,
// targetWidth = 1300, targetHeight = 900,
// //offsetX = 30, offsetY = 100
// )
val file = BitmapSaver.saveToAppFilesDir(
bitmap, this, "IMG_CROP_${System.currentTimeMillis()}.jpg"
)
Timber.d("${this.javaClass.simpleName}-cameraCallback-裁剪bitmap保存文件路径:${file?.absolutePath}")
foodCollectionList[index].let {
it.bitmap = bitmap
it.isShowCamera = false
it.imageUri = file?.toUri()
}
collectionAdapter.notifyItemChanged(index)
}
}
@SuppressLint("NotifyDataSetChanged")
private fun takePhoto() {
val count = foodCollectionList.count { it.bitmap != null }
if (count >= MAX_COUNT) {
ToastUtils.showToast("每次只允许保存${MAX_COUNT}条数据")
return
}
cameraUtils.takePhoto(cameraCallback)
}
override fun initialize() {
cameraUtils.initCamera()
val previewBinding =
LayoutCameraPreviewBinding.inflate(layoutInflater, binding.flCameraPreview)
previewView = previewBinding.previewView.also {
it.updateLayoutParams {
width = 180.dp
height = 180.dp
}
}
cameraUtils.setPreviewController(previewView)
// 初始化 CameraHelper
// cameraHelper = CameraHelper(
// context = this,
// caller = this,
// authority = "${packageName}.fileprovider"
// ) { uri, path ->
// //if (foodCollectionList.size < 6) {
// // val insertIndex = if (foodCollectionList.isEmpty()) 0 else foodCollectionList.size - 1
// // foodCollectionList.add(insertIndex, FoodCollectionBean(imageUri = uri))
// //} else {
// // foodCollectionList[foodCollectionList.size - 1] = FoodCollectionBean(imageUri = uri)
// //}
// //collectionAdapter.notifyDataSetChanged()
// cameraCallback(uri)
// }
binding.ivBack.setOnClickListener {
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
repeat(MAX_COUNT) {
foodCollectionList.add(FoodCollectionBean(isShowCamera = true))
}
binding.rvFoodCollection.let {
it.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.VERTICAL, false)
it.adapter = collectionAdapter
}
binding.btnFoodSearch.setOnClickListener {
searchInfo()
}
binding.btnSave.setOnClickListener {
if (selectedFoodName.isNullOrBlank()) {
Toast.makeText(this, "请选择菜品名称", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
if (foodCollectionList.size <= 1) {
Toast.makeText(this, "请拍摄菜品照片", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
vectorThread()
}
binding.editFoodName.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
searchInfo()
val imm =
v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
true
} else {
false
}
}
binding.btnCollectedGoods.setOnClickListener {
startActivity(Intent(this, CollectedDataActivity::class.java))
}
binding.btnTakePhoto.clickWithDebounce {
binding.btnTakePhoto.text = "拍照"
val count = foodCollectionList.count { it.bitmap != null }
if (count >= MAX_COUNT) {
ToastUtils.showToast("每次只允许保存${MAX_COUNT}条数据")
return@clickWithDebounce
}
// cameraHelper.openCamera()
takePhoto()
}
binding.btnClearData.setOnClickListener { clearData() }
foodAdapter = createAdapter()
binding.recyclerview.layoutManager = GridLayoutManager(context, 2)
// 添加间距装饰(12dp)
binding.recyclerview.addItemDecoration(
GridSpacingItemDecoration(
spanCount = 2,
spacing = dpToPx(30),
includeEdge = false // 包含边缘间距
)
)
binding.recyclerview.adapter = foodAdapter
}
private fun vectorThread() {
//Loading.show(this)
showWaitingDialog("加载中……")
Thread {
foodCollectionList
.filter { it.bitmap != null }
.forEachIndexed { index, it ->
image2VectorTask(it, index)
}
runOnUiThread {
window.decorView.postDelayed({
//Loading.dismiss()
hideWaitingDialog()
}, 1000)
}
}.start()
}
private fun initBox() {
if (box == null) {
box = ObjectBox.boxStore.boxFor(Food::class)
}
}
private fun image2VectorTask(item: FoodCollectionBean, position: Int) {
initBox()
val imageVector = FoodModule.bitmap2FloatArray(item.bitmap!!)
val base64Str = FloatBase64Utils.floatArrayToBase64(imageVector)
Timber.tag("mzf1").e(base64Str)
// viewModel.postImageData(
// context,
// foodId = selectedFoodId.toString(),
// foodName = selectedFoodName.toString(),
// foodVector = base64Str,
// uri = item.imageUri!!
// )
box?.put(Food(name = checkedItem!!.foodName, foodIdx = 0, foodVector = imageVector))
foodCollectionList[position].let {
it.imageVector = imageVector
it.isFinish = true
}
runOnUiThread {
collectionAdapter.notifyItemChanged(position)
}
}
private fun searchInfo() {
debouncer.debounce {
viewModel.searchByFoodName(binding.editFoodName.text.toString())
}
}
override fun registerDataChange() {
super.registerDataChange()
lifecycleScope.launch {
viewModel.searchFoodInfoList.collect {
// foodList.clear()
// foodList.addAll(it)
foodAdapter.updateData(it)
}
}
}
private var checkedItem: FoodInfo? = null
private fun createAdapter(): GenericItemAdapter<FoodInfo, ItemSearchFoodInfoBinding> {
return GenericItemAdapter(
items = emptyList(),
bindingInflater = ItemSearchFoodInfoBinding::inflate,
bindCallback = { item, position ->
this.tvName.text = item.foodName
if (item.id != checkedItem?.id) {
this.tvName.typeface = Typeface.defaultFromStyle(Typeface.NORMAL)
this.tvName.setTextColor(resources.getColor(R.color.search_normal))
this.llRoot.setBackgroundResource(R.drawable.grid_search_item_normal)
} else {
this.tvName.typeface = Typeface.defaultFromStyle(Typeface.BOLD)
this.tvName.setTextColor(resources.getColor(R.color.search_checked))
this.llRoot.setBackgroundResource(R.drawable.grid_search_item_checked)
}
this.llRoot.setOnClickListener {
Timber.d("itemClick ${item.foodName}, position = $position")
checkedItem = item
// viewModel.updateCurrentItem(item)
// itemClickCallback(item)
selectedFoodName = item.foodName
selectedFoodId = item.id
foodAdapter.notifyDataSetChanged()
}
}
)
}
private var clickIndex = -1
@SuppressLint("NotifyDataSetChanged")
private fun clearData() {
foodCollectionList.forEach {
it.bitmap = null
it.isShowCamera = true
it.isFinish = false
}
collectionAdapter.notifyDataSetChanged()
clickIndex = -1
binding.editFoodName.setText("")
// foodList.clear()
// foodAdapter.updateData(mutableListOf())
// foodAdapter.notifyDataSetChanged()
//loadEmptyView()
}
override fun onResume() {
super.onResume()
cameraUtils.bind()
binding.llCameraFlag.run {
visibility = View.VISIBLE
postDelayed({
visibility = View.GONE
}, 3000)
}
}
override fun onPause() {
super.onPause()
cameraUtils.unbind()
binding.llCameraFlag.visibility = View.VISIBLE
}
}
@@ -5,35 +5,48 @@ import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope
import com.sw.dualscreen.GlobalData
import com.sw.dualscreen.databinding.ActivityInitBinding
import com.sw.dualscreen.utils.QRCodeUtil
import com.sw.dualscreen.viewmodel.BaseViewModel
import com.sw.dualscreen.viewmodel.DeviceViewModel
import com.sw.plate.utils.AppUtil
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
class InitActivity : AppCompatActivity() {
private lateinit var binding: ActivityInitBinding
class InitActivity : BaseActivity<ActivityInitBinding>() {
private val viewModel by viewModels<DeviceViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
val deviceId = AppUtil.getUDID(this)
override fun getViewModel(): BaseViewModel {
return viewModel
}
override fun inflateViewBinding(): ActivityInitBinding {
return ActivityInitBinding.inflate(layoutInflater)
}
override fun initialize() {
super.initialize()
val isSuccess = viewModel.checkEquipmentInfo()
if (isSuccess) {
goMainActivity()
return
}
binding.tvDeviceId.text = deviceId
binding.btConfirm.setOnClickListener {
binding.imgQr.setImageBitmap(
QRCodeUtil.generateQRCode(
content = GlobalData.deviceId,
size = 200
)
)
binding.initButton.setOnClickListener {
viewModel.getDeviceToken()
}
registerDataChange()
}
private fun registerDataChange() {
override fun registerDataChange() {
super.registerDataChange()
lifecycleScope.launch {
viewModel.deviceInfoResult.drop(1).collect {
if (it != true) return@collect
@@ -1,17 +1,18 @@
package com.sw.dualscreen.activity
import android.content.Context
import android.content.Intent
import android.graphics.Outline
import android.graphics.Typeface
import android.hardware.display.DisplayManager
import android.os.Bundle
import android.net.Uri
import android.text.TextUtils
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.enableEdgeToEdge
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
@@ -22,6 +23,8 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.google.common.util.concurrent.ListenableFuture
import com.sw.dualscreen.GlobalData
import com.sw.dualscreen.GlobalKey
import com.sw.dualscreen.R
import com.sw.dualscreen.adapter.GenericItemAdapter
import com.sw.dualscreen.adapter.GridSpacingItemDecoration
@@ -29,53 +32,81 @@ import com.sw.dualscreen.adapter.dpToPx
import com.sw.dualscreen.databinding.ActivityMainBinding
import com.sw.dualscreen.databinding.ItemFoodInfoBinding
import com.sw.dualscreen.ext.dp
import com.sw.dualscreen.ext.format2String
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.objbox.FoodModule
import com.sw.dualscreen.objbox.FoodModule.IdNameScore
import com.sw.dualscreen.presentation.SecondaryScreenPresentation
import com.sw.dualscreen.sdk.SensorScaleUtils
import com.sw.dualscreen.utils.AssetsTool
import com.sw.dualscreen.utils.BitmapCropper
import com.sw.dualscreen.utils.BitmapSaver
import com.sw.dualscreen.utils.Debouncer
import com.sw.dualscreen.utils.GlideUtils
import com.sw.dualscreen.utils.GsonUtils
import com.sw.dualscreen.utils.ImageUtil
import com.sw.dualscreen.utils.SPUtil
import com.sw.dualscreen.view.CustomBottomSheetDialog
import com.sw.dualscreen.viewmodel.BaseViewModel
import com.sw.dualscreen.viewmodel.UserViewModel
import com.sw.plate.utils.AppUtil
import com.sw.plate.utils.LightManager
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.util.concurrent.Executors
import kotlin.math.roundToInt
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
/**
* 服务员显示界面
*/
class MainActivity : BaseActivity<ActivityMainBinding>() {
private val viewModel by viewModels<UserViewModel>()
private var imageCapture: ImageCapture? = null
private var isAnalyzing = true // 控制是否进行图像分析
private lateinit var presentation: SecondaryScreenPresentation
private var presentation: SecondaryScreenPresentation? = null
private val executor = Executors.newSingleThreadExecutor()
private lateinit var adapter: GenericItemAdapter<FoodInfo, ItemFoodInfoBinding>
private var checkedItem: FoodInfo? = null
private var imageAnalysis: ImageAnalysis? = null
private var cameraProviderFuture: ListenableFuture<ProcessCameraProvider>? = null
private var canIdentify: Boolean = true
private var canIdentify: Boolean = false
private var bottomSheetDialog: CustomBottomSheetDialog? = null
private var lastWeight: Double = 0.0
private var lastPhotoUri: Uri? = null // 最后拍照的图片
private var debouncer = Debouncer(2000)
private var isRecognitionFood = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.main)
override fun getViewModel(): BaseViewModel {
return viewModel
}
Timber.d("UDID = ${AppUtil.getUDID(this)}")
override fun inflateViewBinding(): ActivityMainBinding {
return ActivityMainBinding.inflate(layoutInflater)
}
override fun initialize() {
super.initialize()
initView()
setupSecondaryDisplay()
setupCamera()
initData()
FoodModule.init(this)
}
private fun initData() {
viewModel.getEquipmentToken()
presentation.step1()
presentation?.step1()
}
private fun initView() {
viewModel.activeEngine()
binding.foodDisplayLayout.setOnClickListener {
val intent = Intent(this, FoodCollectionActivity::class.java)
startActivity(intent)
finish()
}
binding.previewView.outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 12f.dp)
@@ -83,19 +114,22 @@ class MainActivity : AppCompatActivity() {
}
binding.previewView.clipToOutline = true
binding.tvToSearch.setOnClickListener {
val bottomSheetDialog = CustomBottomSheetDialog.newInstance(viewModel) {
bottomSheetDialog = CustomBottomSheetDialog.newInstance(viewModel) {
it.photoUri = null
updateCurrentFood(it)
}
bottomSheetDialog.show(supportFragmentManager, "CustomBottomSheetDialog")
bottomSheetDialog!!.show(supportFragmentManager, "CustomBottomSheetDialog")
}
val spinnerAdapter = ArrayAdapter<String>(
this,
R.layout.item_for_custom_spinner,
arrayOf("即放即取", "持续取餐")
arrayOf("即放即取", "余量取餐")
)
binding.spinner.adapter = spinnerAdapter
binding.spinner.setSelection(0)
val mode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0)
binding.spinner.setSelection(mode ?: 0)
presentation?.updateMealPickupMode(mode ?: 0)
binding.spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
@@ -103,12 +137,15 @@ class MainActivity : AppCompatActivity() {
position: Int,
id: Long
) {
SPUtil.getInstance().put(GlobalKey.KEY_PICKUP_MODE, position)
updateCurrentFood(null)
isRecognitionFood = true
presentation?.updateMealPickupMode(position)
SensorScaleUtils.zero()
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
adapter = createAdapter()
binding.recyclerview.layoutManager = GridLayoutManager(this, 2)
@@ -122,22 +159,32 @@ class MainActivity : AppCompatActivity() {
)
binding.recyclerview.adapter = adapter
binding.tvFoodName.setOnClickListener {
if (presentation.currentStep == 2) {
presentation.step3()
} else {
presentation.step1()
}
SensorScaleUtils.zero()
}
binding.tvRescan.setOnClickListener {
debouncer.debounce { recognizeFood() }
}
registerDataChange()
}
fun updateCurrentFood(foodInfo: FoodInfo?) {
presentation.updateFood(foodInfo)
if (foodInfo != null) {
presentation?.updateFood(foodInfo)
binding.tvFoodName.text = foodInfo.foodName
binding.previewView.visibility = View.GONE
binding.ivImg.visibility = View.VISIBLE
GlideUtils.loadRoundCornerImage(this, foodInfo.imgUrl, binding.ivImg, 12)
val imgUrl = foodInfo.imgUrl
if (TextUtils.isEmpty(imgUrl)) {
takePhoto { imgUri ->
GlideUtils.loadRoundCornerImage(this, imgUri, binding.ivImg, 12)
presentation?.updateFood(foodInfo.copy(imgUrl = imgUri.toString()))
}
} else {
if (foodInfo.photoUri != null) {
GlideUtils.loadRoundCornerImage(this, foodInfo.photoUri, binding.ivImg, 12)
} else {
GlideUtils.loadRoundCornerImage(this, foodInfo.imgUrl, binding.ivImg, 12)
}
}
pauseAnalysis()
} else {
binding.tvFoodName.text = "-"
@@ -148,24 +195,107 @@ class MainActivity : AppCompatActivity() {
}
}
private fun registerDataChange() {
override fun registerDataChange() {
super.registerDataChange()
// lifecycleScope.launch {
// viewModel.identifiedFoodInfoList2.collect { list ->
// updateFoodInfo(list)
// }
// }
lifecycleScope.launch {
viewModel.identifiedFoodInfoList.collect { list ->
binding.recyclerview
adapter.updateData(list)
// presentation.step2()
}
}
viewModel.getIdentifiedFoodList()
SensorScaleUtils.startScale { weight ->
// Timber.d("weight = $weight")
if (weight > 0.0) {
if (canIdentify) {
canIdentify = false
viewModel.getIdentifiedFoodList()
if (list.isNotEmpty()) {
checkedItem = list[0]
checkedItem!!.photoUri = lastPhotoUri
updateCurrentFood(checkedItem)
lastPhotoUri = null
}
}
}
SensorScaleUtils.startScale { weight ->
Timber.d("registerDataChange weight = $weight")
presentation?.updateWeight(weight)
if (weight <= 0.005) {//余量取餐,检测到秤上没有东西,重新启动识别菜品 && presentation?.mealPickupMode == 1
isRecognitionFood = true
}
if (weight - lastWeight > 0.05 && isRecognitionFood) { // 大于50g
if (presentation?.mealPickupMode == 1) {
isRecognitionFood = false
}
debouncer.debounce {
recognizeFood()
}
}
lastWeight = weight
}
}
private fun recognizeFood() {
LightManager.openRedLight()
LightManager.openGreenLight()
takePhoto { photoUri ->
lastPhotoUri = photoUri
Timber.d("registerDataChange photoUri = ${photoUri.path}")
// viewModel.getIdentifiedFoodList()
ImageUtil.uriToBitmap(this, photoUri)?.let { bitmap ->
Timber.d("registerDataChange photoUri 拿到bitmap")
// val bmp = BitmapCropper.cropCenter(bitmap, 1300, 900)
Timber.d("registerDataChange photoUri bitmap裁剪完成")
val file = BitmapSaver.saveToAppFilesDir(
bitmap,
this,
"IMG_CROP_${System.currentTimeMillis()}.jpg"
)
Timber.d("registerDataChange photoUri bitmap保存文件路径:${file?.absolutePath}")
//val nameList = FoodModule.queryFood(bitmap)
val scoreList = FoodModule.getFoodScoreList(bitmap)
Timber.d(
"registerDataChange photoUri 拿到识别数据:${
GsonUtils.toJson(
scoreList
)
}"
)
val nameList = scoreList.map { it.name }
val foodName = nameList.joinToString(separator = ",")
Timber.d("registerDataChange photoUri 识别数据名称:$foodName")
if (TextUtils.isEmpty(foodName)) {
LightManager.closeGreenLight()
LightManager.closeRedLight()
binding.layoutRescan.visibility = View.VISIBLE
} else {
binding.layoutRescan.visibility = View.GONE
viewModel.getFoodInfo(foodName) { list ->
runOnUiThread {
updateFoodInfo(list, scoreList)
}
}
}
}
}
}
private fun updateFoodInfo(list: List<FoodInfo>, scoreList: List<IdNameScore>) {
Timber.d("registerDataChange识别后查询接口数据:${GsonUtils.toJson(list)}")
list.forEach { foodInfo ->
val scoreItem = scoreList.firstOrNull { it.name == foodInfo.foodName }
val score = scoreItem?.score ?: 0.0
foodInfo.score = ((1 - score) * 10000).roundToInt()
}
adapter.updateData(list)
if (list.isNotEmpty()) {
checkedItem = list[0]
checkedItem!!.photoUri = lastPhotoUri
updateCurrentFood(checkedItem)
lastPhotoUri = null
LightManager.closeRedLight()
}
}
private fun createAdapter(): GenericItemAdapter<FoodInfo, ItemFoodInfoBinding> {
@@ -173,7 +303,8 @@ class MainActivity : AppCompatActivity() {
items = emptyList(),
bindingInflater = ItemFoodInfoBinding::inflate,
bindCallback = { item, position ->
this.tvName.text = item.foodName
val score = (item.score / 100.0).format2String(2)
this.tvName.text = "${item.foodName}\n${score}%"
if (item.id != checkedItem?.id) {
this.tvName.typeface = Typeface.defaultFromStyle(Typeface.NORMAL)
this.tvName.setTextColor(resources.getColor(R.color.identify_normal))
@@ -186,6 +317,7 @@ class MainActivity : AppCompatActivity() {
//
this.llRoot.setOnClickListener {
Timber.d("itemClick ${item.foodName}, position = $position")
item.photoUri = null
checkedItem = item
adapter.notifyDataSetChanged()
updateCurrentFood(item)
@@ -217,7 +349,7 @@ class MainActivity : AppCompatActivity() {
// 仅在步骤1时传递
if (isAnalyzing) {
val bitmap = imageProxy.toBitmap()
presentation.updateImage(bitmap) // 更新副屏
presentation?.updateImage(bitmap) // 更新副屏
}
imageProxy.close() // 必须关闭以释放资源
}
@@ -257,9 +389,9 @@ class MainActivity : AppCompatActivity() {
if (isAnalyzing) return
isAnalyzing = true
imageAnalysis?.setAnalyzer(executor) { imageProxy ->
if (presentation.currentStep == 1) {
if (presentation?.currentStep == 1) {
val bitmap = imageProxy.toBitmap()
presentation.updateImage(bitmap)
presentation?.updateImage(bitmap)
}
imageProxy.close()
}
@@ -289,24 +421,35 @@ class MainActivity : AppCompatActivity() {
}
// 拍照功能
private fun takePhoto() {
private fun takePhoto(callback: (Uri) -> Unit) {
Timber.d("takePhoto")
// 临时停止分析以避免干扰
isAnalyzing = false
// 创建输出选项
val outputFileOptions = ImageCapture.OutputFileOptions.Builder(
File(
cacheDir.path, "IMG_${System.currentTimeMillis()}.jpg"
)
).build()
val executor = ContextCompat.getMainExecutor(this)
val cacheDir = cacheDir
val photoFile = File.createTempFile(
"IMG_${System.currentTimeMillis()}",
".jpg",
cacheDir
)
val cacheOutputOptions =
ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 执行拍照
imageCapture?.takePicture(
outputFileOptions,
cacheOutputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
runOnUiThread {
ToastUtils.showToast("照片保存成功: ${outputFileResults.savedUri}")
val savedUri = outputFileResults.savedUri
Timber.d("takePhoto savedUri = $savedUri")
// ToastUtils.showToast("照片保存成功: $savedUri")
if (savedUri != null) {
callback(savedUri)
}
}
// 恢复图像分析
isAnalyzing = true
@@ -340,18 +483,28 @@ class MainActivity : AppCompatActivity() {
viewModel = viewModel,
recognizeViewModel = viewModels<RecognizeViewModel>().value
)
presentation.setStepChangeCallback { step ->
presentation!!.setStepChangeCallback { step ->
if (step == 1) {
Timber.d("setStepChangeCallback${step}")
updateCurrentFood(null)
bottomSheetDialog?.dismiss()
}
}
presentation.show()
presentation!!.show()
} else {
ToastUtils.showToast("获取副屏失败")
}
}
fun clearFoodList() {
adapter.updateData(listOf())
adapter.notifyDataSetChanged()
}
override fun onDestroy() {
SensorScaleUtils.closeScale()
presentation.dismiss()
presentation?.dismiss()
super.onDestroy()
shutdownCamera()
}
}
@@ -0,0 +1,34 @@
package com.sw.dualscreen.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.dualscreen.R
import com.sw.dualscreen.databinding.ListItemCollectedDataBinding
import com.sw.dualscreen.databinding.ListItemFoodCollectionBinding
import com.sw.dualscreen.objbox.CollectedFoodBean
import com.sw.dualscreen.objbox.FoodCollectionBean
class CollectedFoodAdapter (var list: MutableList<CollectedFoodBean>) :
BaseQuickAdapter<CollectedFoodBean, CollectedFoodAdapter.VH>(list) {
inner class VH(var binding: ListItemCollectedDataBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemCollectedDataBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CollectedFoodBean?) {
holder.binding.tvFoodName.text = item?.foodName
holder.binding.divider.run {
visibility = if (position == list.size - 1) View.GONE else View.VISIBLE
}
}
}
@@ -0,0 +1,43 @@
package com.sw.dualscreen.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.dualscreen.R
import com.sw.dualscreen.databinding.ListItemFoodCollectionBinding
import com.sw.dualscreen.objbox.FoodCollectionBean
class FoodCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
BaseQuickAdapter<FoodCollectionBean, FoodCollectionAdapter.VH>(list) {
inner class VH(var binding: ListItemFoodCollectionBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemFoodCollectionBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodCollectionBean?) {
val binding = holder.binding
item?.let {
binding.ivFinish.visibility = if (it.isFinish) View.VISIBLE else View.GONE
binding.ivDelete.visibility = if (it.isShowCamera) View.GONE else View.VISIBLE
binding.imageView.run {
if (it.isShowCamera) {
scaleType = ImageView.ScaleType.CENTER
setImageResource(R.drawable.ic_camera256)
} else {
scaleType = ImageView.ScaleType.FIT_CENTER
//setImageURI(it.imageUri)
setImageBitmap(it.bitmap)
}
}
}
}
}
@@ -0,0 +1,48 @@
package com.sw.dualscreen.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.View
import com.sw.dualscreen.R
import com.sw.dualscreen.ext.dp
import com.sw.dualscreen.ext.hideKeyboard
abstract class BaseDialog(
context: Context,
var defWidth: Int = 400.dp,
var defHeight: Int = 260.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()
// fun getReceiptActivity(): GoodsListActivity? {
// var ctx = context
// while (ctx is ContextWrapper) {
// ctx = ctx.baseContext
// if (ctx is GoodsListActivity) {
// return ctx
// }
// }
// return null
// }
}
@@ -0,0 +1,35 @@
package com.sw.dualscreen.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import com.sw.dualscreen.databinding.DialogWarnBinding
class WarnDialog(
context: Context,
var content:String,
var cancelBlock: () -> Unit = {},
var confirmBlock: () -> Unit = {},
) : BaseDialog(context = context) {
private lateinit var binding: DialogWarnBinding
override fun getRootView(): View {
val inflater = LayoutInflater.from(context)
binding = DialogWarnBinding.inflate(inflater)
return binding.root
}
override fun initView() {
binding.tvWarnContent.text = content
binding.btnCancel.setOnClickListener {
cancelBlock()
dismiss()
}
binding.btnConfirm.setOnClickListener {
confirmBlock()
dismiss()
}
}
}
@@ -1,5 +1,6 @@
package com.sw.dualscreen.ext
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -7,8 +8,17 @@ import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.YuvImage
import android.util.TypedValue
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
import androidx.annotation.Dimension
import androidx.camera.core.ImageProxy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
/**
@@ -56,6 +66,7 @@ val Float.sp: Float
)
fun Double.format2String(): String = "%.1f".format(this)
fun Double.format2String(num:Int): String = "%.${num}f".format(this)
// 添加扩展函数
fun ImageProxy.toSafeBitmap(): Bitmap {
@@ -81,4 +92,31 @@ fun ImageProxy.toSafeBitmap(): Bitmap {
yuvImage.compressToJpeg(Rect(0, 0, this.width, this.height), 100, out)
val imageBytes = out.toByteArray()
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
}
fun View.hideKeyboard() {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(this.windowToken, 0)
this.clearFocus() // 清除焦点避免键盘再次弹出
}
fun EditText.addOnActionSearchListener(searchCallback: () -> Unit) {
setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
// 处理搜索逻辑
searchCallback()
return@setOnEditorActionListener true // 阻止事件继续传递
}
return@setOnEditorActionListener false
}
}
fun View.clickWithDebounce(delay: Long = 500, action: () -> Unit) {
var job: Job? = null
setOnClickListener {
job?.cancel()
job = CoroutineScope(Dispatchers.Main).launch {
delay(delay)
action()
}
}
}
@@ -6,7 +6,7 @@ package com.sw.dualscreen.ext
* 多于2个字:张*某
*/
fun String?.maskName(): String {
if (this == null || this.isEmpty()) return ""
if (this == null || this.isEmpty()) return "-"
return when {
length == 2 -> "${this[0]}*"
@@ -34,4 +34,13 @@ fun String?.maskPhone(): String {
sb.setCharAt(i, '*')
}
return sb.toString()
}
fun String?.toSafeDouble(): Double {
if (this == null) return 0.0
return try {
toDouble()
} catch (e: Exception) {
0.0
}
}
@@ -0,0 +1,24 @@
package com.sw.dualscreen.model;
import androidx.activity.result.ActivityResult;
import java.util.Map;
public interface IActivityResult {
interface LaunchActivityCallback {
void callback(ActivityResult result);
}
interface LaunchTackPictureCallback {
void callback(boolean result);
}
interface RequestPermissionCallback {
void callback(boolean result);
}
interface RequestMultiplePermissionsCallback {
void callback(Map<String,Boolean> result);
}
}
@@ -14,5 +14,5 @@ data class UserNutritionParam(
val faceEndTime: Long,
val eatWeight: Double,
val foodWeight: Double,
val deviceId: String = ""
val deviceId: String = GlobalData.deviceId
) : Parcelable
@@ -1,6 +1,7 @@
package com.sw.dualscreen.model.response
import android.net.Uri
import android.os.Parcelable
import android.text.TextUtils
import com.google.gson.annotations.SerializedName
@@ -23,7 +24,11 @@ data class FoodInfo(
@SerializedName("id")
val id: String? = "",
@SerializedName("imgUrl")
val imgUrl: String? = "",
var imgUrl: String? = "",
/**
* 拍照结果
*/
var photoUri: Uri? = null,
@SerializedName("stFoodInfoMaterial")
val stFoodInfoMaterial: StFoodInfoMaterial? = StFoodInfoMaterial(),
@SerializedName("stFoodInfoPagoda")
@@ -33,7 +38,8 @@ data class FoodInfo(
@SerializedName("stFoodInfoSetting")
val stFoodInfoSetting: StFoodInfoSetting? = StFoodInfoSetting(),
@SerializedName("stFoodInfoSpecificationList")
val stFoodInfoSpecificationList: List<StFoodInfoSpecification?>? = listOf()
val stFoodInfoSpecificationList: List<StFoodInfoSpecification?>? = listOf(),
var score: Int = 0
) : Parcelable {
@Parcelize
data class FoodTypeAndRealIntakeVo(
@@ -35,7 +35,7 @@ data class UserNutritionData(
* 食物类型和实际摄入量清单
*/
@SerializedName("foodTypeAndRealIntakeVoList")
val foodTypeAndRealIntakeVoList: List<String?>? = listOf(),
val foodTypeAndRealIntakeVoList: List<FoodTypeAndRealIntakeVo?>? = listOf(),
@SerializedName("foodWeight")
val foodWeight: String? = "",
@SerializedName("message")
@@ -66,7 +66,7 @@ data class UserNutritionData(
@SerializedName("stFoodInfoPagoda")
val stFoodInfoPagoda: StFoodInfoPagoda? = StFoodInfoPagoda(),
@SerializedName("stUserFoodInfoList")
val stUserFoodInfoList: List<String?>? = listOf(),
val stUserFoodInfoList: List<StUserFoodInfo?>? = listOf(),
/**
* 每日总热量
*/
@@ -104,6 +104,26 @@ data class UserNutritionData(
if (TextUtils.isEmpty(totalEnergyCalculateScore)) 0.0
else totalEnergyCalculateScore!!.toDouble()
@Parcelize
data class FoodTypeAndRealIntakeVo(
@SerializedName("childMaterClassName")
val childMaterClassName: String? = "",
@SerializedName("foodId")
val foodId: String? = "",
@SerializedName("goodsId")
val goodsId: String? = "",
@SerializedName("goodsName")
val goodsName: String? = "",
@SerializedName("materClassName")
val materClassName: String? = "",
@SerializedName("materId")
val materId: String? = "",
@SerializedName("materialType")
val materialType: String? = "",
@SerializedName("realityIntake")
val realityIntake: String? = ""
) : Parcelable
@Parcelize
data class StFoodInfoPagoda(
/**
@@ -178,4 +198,56 @@ data class UserNutritionData(
else meat!!.toDouble()
}
@Parcelize
data class StUserFoodInfo(
@SerializedName("canteenId")
val canteenId: String? = "",
@SerializedName("createBy")
val createBy: String? = "",
@SerializedName("createTime")
val createTime: String? = "",
@SerializedName("dataSource")
val dataSource: String? = "",
@SerializedName("dataType")
val dataType: String? = "",
@SerializedName("delFlag")
val delFlag: String? = "",
@SerializedName("deviceId")
val deviceId: String? = "",
@SerializedName("dinnerType")
val dinnerType: String? = "",
@SerializedName("eatDay")
val eatDay: String? = "",
@SerializedName("eatNum")
val eatNum: String? = "",
@SerializedName("eatWeight")
val eatWeight: Double? = 0.0,
@SerializedName("foodId")
val foodId: String? = "",
@SerializedName("foodImg")
val foodImg: String? = "",
@SerializedName("foodMaterialId")
val foodMaterialId: String? = "",
@SerializedName("foodName")
val foodName: String? = "",
@SerializedName("foodWeight")
val foodWeight: String? = "",
@SerializedName("id")
val id: String? = "",
@SerializedName("isSync")
val isSync: String? = "",
@SerializedName("isSyncCopy")
val isSyncCopy: String? = "",
@SerializedName("specId")
val specId: String? = "",
@SerializedName("stBasicDiningInformationUserESVo")
val stBasicDiningInformationUserESVo: String? = "",
@SerializedName("stallType")
val stallType: String? = "",
@SerializedName("type")
val type: String? = "",
@SerializedName("userId")
val userId: String? = ""
) : Parcelable
}
@@ -8,10 +8,15 @@ import com.sw.dualscreen.model.response.EquipmentInfo
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.model.response.UserFaceInfo
import com.sw.dualscreen.model.response.UserNutritionData
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.PartMap
import retrofit2.http.Query
import retrofit2.http.Url
@@ -97,6 +102,29 @@ interface ApiService {
@POST
suspend fun postUserNutritionData(
@Url url: String = "${GlobalData.appBaseUrl}/zhstapi/zhst/userEatFood/doubleBalance/face/stall",
@Body param: UserNutritionParam
@Body param: List<UserNutritionParam>
): ApiResponse<String>
/**
* 获取菜品信息
*/
@GET
suspend fun getFoodInfo(
@Url url: String = "${GlobalData.appBaseUrl}/zhstapi/zhst/getRestInfoFoodsByType/stall/v2",
@Query("restId") restId: String,
@Query("foodNames") foodName: String,
): ApiResponse<List<FoodInfo>>
/**
* 提交采集图片数据
*/
@Multipart
@POST
suspend fun postImageData(
@Url url: String = "http://192.168.1.201:14801/terminal/neglect/dishCollectionVectorData/add",
@PartMap params: Map<String, @JvmSuppressWildcards RequestBody>,
@Part image: MultipartBody.Part?
): ApiResponse<String>
}
@@ -2,7 +2,7 @@ package com.sw.dualscreen.network.interceptor
import android.text.TextUtils
import com.sw.dualscreen.GlobalKey
import com.sw.inbound.utils.SPUtil
import com.sw.dualscreen.utils.SPUtil
import com.sw.plate.App
import okhttp3.Interceptor
import okhttp3.Request
@@ -0,0 +1,29 @@
package com.sw.dualscreen.objbox
import android.graphics.Bitmap
import android.net.Uri
data class FoodClassInfo(
var class_names: List<String>,
var class_to_idx: Map<String, Int>,
var idx_to_class: Map<String, String>
)
//data class FoodQueryResult(
// var id: Long,
// var name: String?,
// var foodIdx: Int,
// var score: Double
//)
data class FoodCollectionBean(
var imageUri: Uri? = null,
var bitmap: Bitmap? = null,
var imageVector: FloatArray? = null,
var isShowCamera: Boolean = false,
var isFinish:Boolean = false
)
data class CollectedFoodBean(
var foodName: String?,
)
@@ -0,0 +1,37 @@
package com.sw.dualscreen.objbox
import io.objectbox.annotation.Entity
import io.objectbox.annotation.HnswIndex
import io.objectbox.annotation.Id
import io.objectbox.annotation.VectorDistanceType
@Entity
data class Food(
@Id var id: Long = 0,
var name: String? = null,
var foodIdx: Int = 0,
@HnswIndex(dimensions = 512, distanceType = VectorDistanceType.DOT_PRODUCT)
var foodVector: FloatArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Food
if (id != other.id) return false
if (foodIdx != other.foodIdx) return false
if (name != other.name) return false
if (!foodVector.contentEquals(other.foodVector)) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + foodIdx
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (foodVector?.contentHashCode() ?: 0)
return result
}
}
@@ -0,0 +1,241 @@
package com.sw.dualscreen.objbox
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.SparseLongArray
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.sw.dualscreen.MyApp
import com.sw.dualscreen.utils.AssetsTool
import com.sw.dualscreen.utils.GsonUtils
import com.sw.dualscreen.utils.ImageUtil
import io.objectbox.Box
import io.objectbox.kotlin.boxFor
import io.objectbox.query.Query
import org.pytorch.IValue
import org.pytorch.Module
import org.pytorch.torchvision.TensorImageUtils
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
object FoodModule {
private const val THRESHOLD = 0.8
private lateinit var module_mobile: Module
private lateinit var box: Box<Food>
private lateinit var embeddingsList: List<List<Float>>
private lateinit var labelsList: IntArray
private lateinit var classInfo: FoodClassInfo
val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
val DEFAULT_FOOD_INDEX = -1
fun init(context: Context) {
Thread {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
box = ObjectBox.boxStore.boxFor(Food::class)
//if (box.all.isNotEmpty()) {
// box.removeAll()
//}
//if (box.all.isEmpty()) {
// initDefFoodData(context)
//}
}.start()
}
fun uri2FloatArray(uri: Uri): FloatArray? {
return MyApp.instance?.let { context ->
ImageUtil.uriToBitmap(context, uri)?.let {
bitmap2FloatArray(it)
}
}
}
fun bitmap2FloatArray(bitmap: Bitmap): FloatArray {
val inputTensor = TensorImageUtils.bitmapToFloat32Tensor(
bitmap,
NO_MEAN_RGB, // [0.485, 0.456, 0.406] TORCHVISION_NORM_MEAN_RGB
NO_STD_RGB // [0.229, 0.224, 0.225] TORCHVISION_NORM_STD_RGB
)
val outputTensor = module_mobile.forward(IValue.from(inputTensor)).toTensor()
return outputTensor.dataAsFloatArray
}
fun queryFood(uri: Uri, queryCount: Int = 15): List<String>? {
return uri2FloatArray(uri)?.let {
queryFood(it, queryCount)
}
}
/**
* 返回识别物品名称列表
*/
fun queryFood(bitmap: Bitmap, queryCount: Int = 15): List<String> {
val floatArray = bitmap2FloatArray(bitmap)
return queryFood(floatArray, queryCount)
}
/**
* 返回识别物品IdNameScore对象列表
*/
fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = 15): List<IdNameScore> {
val floatArray = bitmap2FloatArray(bitmap)
return queryFoodNameScore(floatArray, queryCount)
}
fun queryFoodNameScore(floatArray: FloatArray, queryCount: Int = 15): List<IdNameScore> {
val query: Query<Food> =
box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build()
//查询比较分数
// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" }
val idScoreList = query.findIdsWithScores()
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
nameScoreList.add(IdNameScore(id = it.id, name = box.get(it.id).name?:"", score = it.score))
}
Timber.tag("FoodModule").d("registerDataChange,queryFood数据:${GsonUtils.toJson(nameScoreList)}")
return nameScoreList
}
fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = 15): List<IdNameScore> {
val floatArray = bitmap2FloatArray(bitmap)
val nameScoreList = queryFoodNameScore(floatArray, queryCount)
val maxScoreList = nameScoreList
.filter { it.score < 1 - THRESHOLD }
.groupBy { it.name }
.map { (_, value) -> value.minByOrNull { it.score }!! }
.toMutableList()
val map = mutableMapOf<String, Int>()
nameScoreList.forEach {
val key = it.name
val count = map[key] ?: 0
map[key] = count + 1
}
val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList()
val firstFood = nameScoreList[0].name
orderList.remove(firstFood)
orderList.add(0, firstFood)
val sortedScoreList = maxScoreList.sortedWith(compareBy {
orderList.indexOf(it.name)
})
//Timber.tag("FoodModule").d("getFoodScoreList数据:${sortedScoreList.toJsonString()}")
return sortedScoreList
}
fun queryFood(floatArray: FloatArray, queryCount: Int = 15): List<String> {
val query: Query<Food> =
box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build()
//查询比较分数
// val tempList = query.findWithScores().sortedBy { it.score }.map { "${it.get().name}|${it.get().foodIdx}|${it.score}" }
val map = mutableMapOf<String, Int>()
query.findIdsWithScores().forEach {
val food = box.get(it.id)
Timber.d("${food.name}|${food.foodIdx}|${it.score}")
if (1 - it.score >= THRESHOLD) {
//FoodQueryResult(id = it.id, name = food.name, foodIdx = food.foodIdx, score = it.score)
food.name?.let { key ->
val count = map[key] ?: 0
map.put(key, count + 1)
}
}
}
val list = map.entries.sortedByDescending { it.value }.map { it.key }
return list
// val map = mutableMapOf<String, Int>()
// val nameScoreList = queryFoodNameScore(floatArray, queryCount)
// nameScoreList.filter { it.score < 0.05 }.forEach {
// val count = map[it.name] ?: 0
// map[it.name] = count + 1
// }
// val list = map.entries.sortedByDescending { it.value }.map { it.key }
// return list
}
data class IdNameScore(
val id:Long,
var name:String,
val score: Double
)
fun initDefFoodData(context: Context, action:()-> Unit={}) {
val count = box.all.count { it.foodIdx == DEFAULT_FOOD_INDEX }
if (count > 0) {
return
}
val embeddingsJson = AssetsTool.readAssetsFile(context, "data/embeddings.json")
val labelsJson = AssetsTool.readAssetsFile(context, "data/labels.json")
val classInfoJson = AssetsTool.readAssetsFile(context, "data/class_info.json")
embeddingsList =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.type)
labelsList = Gson().fromJson(labelsJson, IntArray::class.java)
classInfo =
Gson().fromJson(classInfoJson, FoodClassInfo::class.java)
val foodMap = classInfo.idx_to_class
embeddingsList.forEachIndexed { index, floatList ->
val classIdx = labelsList[index]
val foodName = foodMap["$classIdx"]
val array = floatList.toFloatArray()
box.put(Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX))
}
action()
}
/**
* ,此方法的主要目的是:从assets 拷贝到 app的cache目录
* @param context
* @param fileName
* @return 例如是这样:/data/user/0/com.frizzle.pluginhookandroid9/cache/plugin-debug.apk
*
* 不可能反正SD
*/
fun copyAssetToCache(context: Context, fileName: String): String? {
// 此app的缓存目录 --> 会默认在 cache目录...,可以自己去看看哦
val cacheDir = context.getCacheDir()
if (!cacheDir.exists()) {
cacheDir.mkdirs() // TODO 如果没有缓存目录,就创建
}
val outPath = File(cacheDir, fileName) // TODO 创建输出的文件位置
if (outPath.exists()) {
outPath.delete() // TODO 如果该文件已经存在,就删掉
}
var `is`: InputStream? = null // 读取
var fos: FileOutputStream? = null // 写入
try {
// 创建文件,如果创建成功,就返回true
val res = outPath.createNewFile()
if (res) {
`is` = context.getAssets().open(fileName) // 拿到main/assets目录的输入流,用于读取字节
fos = FileOutputStream(outPath) // 读取出来的字节最终写到outPath
val buf = ByteArray(`is`.available()) // 缓存区
var byteCount: Int
// 开始循环读取
while ((`is`.read(buf).also { byteCount = it }) != -1) {
fos.write(buf, 0, byteCount)
}
return outPath.getAbsolutePath()
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
try {
// TODO 一定要记得关闭资源,为了不去性能的磨损
fos!!.flush()
`is`!!.close()
fos.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
return null
}
}
@@ -0,0 +1,112 @@
/*
* Copyright 2024 ObjectBox Ltd. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.sw.dualscreen.objbox
import android.content.Context
import android.util.Log
import io.objectbox.BoxStore
import io.objectbox.BoxStoreBuilder
import io.objectbox.android.Admin
import io.objectbox.android.ObjectBoxLiveData
import io.objectbox.exception.DbException
import io.objectbox.exception.FileCorruptException
import io.objectbox.sync.Sync
import java.io.File
import java.util.Date
import java.util.zip.GZIPOutputStream
import kotlin.also
import kotlin.io.copyTo
import kotlin.io.inputStream
import kotlin.io.outputStream
import kotlin.io.use
import kotlin.jvm.java
import kotlin.jvm.javaClass
/**
* Singleton to keep BoxStore reference and provide current list of Notes Objects.
* Inserts demo data if no Objects are stored.
*/
object ObjectBox {
// 确保所有设备/版本使用相同的objectbox-models/default.json
// 避免直接修改自动生成的MyObjectBox类
// 跨版本升级时使用boxStore.runInTx执行数据迁移
private const val TAG = "ObjectBox"
lateinit var boxStore: BoxStore
private set
/**
* If building the [boxStore] failed, contains the thrown error message.
*/
var dbExceptionMessage: String? = null
private set
fun init(context: Context) {
// On Android make sure to pass a Context when building the Store.
boxStore = try {
MyObjectBox.builder()
.androidContext(context.applicationContext)
.build()
} catch (e: DbException) {
if (e.javaClass.equals(DbException::class.java) || e is FileCorruptException) {
// Failed to build BoxStore due to database file issue, store message;
// checked in NoteListActivity to notify user.
dbExceptionMessage = e.toString()
return
} else {
// Failed to build BoxStore due to developer error.
throw e
}
}
// if (BuildConfig.DEBUG) {
var syncAvailable = if (Sync.isAvailable()) "available" else "unavailable"
Log.d(TAG,"Using ObjectBox ${BoxStore.getVersion()} (${BoxStore.getVersionNative()}, sync $syncAvailable)")
// Enable ObjectBox Admin on debug builds.
// https://docs.objectbox.io/data-browser
Admin(boxStore).start(context.applicationContext)
// }
}
/**
* If the database file is not in use, compresses (GZIP) and copies it to the given [target].
*/
fun copyAndGzipDatabaseFileTo(target: File, context: Context): Boolean {
if (BoxStore.isDatabaseOpen(context, null)) {
// Do not copy if database file is still in use.
// If it would be open, the copy will likely get corrupted
// as BoxStore may currently write data to the file.
Log.e(TAG, "Database file is still in use, can not copy.")
return false
}
// If a name was given when building BoxStore use that instead of the default below.
val dbName = BoxStoreBuilder.DEFAULT_NAME
File(context.filesDir, "objectbox/$dbName/data.mdb").inputStream().use { input ->
target.parentFile?.mkdirs()
GZIPOutputStream(target.outputStream()).use { output ->
input.copyTo(output, DEFAULT_BUFFER_SIZE)
}
}
return true
}
}
@@ -8,18 +8,14 @@ import android.graphics.Bitmap
import android.graphics.Outline
import android.graphics.Point
import android.hardware.Camera
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.os.Build
import android.os.Bundle
import android.util.DisplayMetrics
import android.util.Log
import android.view.Display
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
@@ -32,13 +28,14 @@ import com.sw.dualscreen.databinding.PresentationSecondaryScreenBinding
import com.sw.dualscreen.ext.dp
import com.sw.dualscreen.ext.format2String
import com.sw.dualscreen.ext.maskName
import com.sw.dualscreen.model.request.UserNutritionParam
import com.sw.dualscreen.model.response.DinnerTypeInfo
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.model.response.UserNutritionData
import com.sw.dualscreen.sdk.SensorScaleUtils
import com.sw.dualscreen.utils.Debouncer
import com.sw.dualscreen.utils.GlideUtils
import com.sw.dualscreen.viewmodel.UserViewModel
import com.sw.plate.utils.LightManager
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.ConfigUtil
import com.sw.plate.utils.arcface.ErrorCodeUtil
@@ -49,9 +46,11 @@ import com.sw.plate.utils.arcface.PreviewConfig
import com.sw.plate.utils.arcface.camera.CameraListener
import com.sw.plate.utils.arcface.camera.DualCameraHelper
import com.sw.plate.utils.arcface.face.constants.LivenessType
import com.sw.plate.utils.arcface.face.model.CompareResult
import com.sw.plate.utils.arcface.face.model.FacePreviewInfo
import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -67,22 +66,28 @@ class SecondaryScreenPresentation(
val recognizeViewModel: RecognizeViewModel
) : Presentation(context, display), ViewTreeObserver.OnGlobalLayoutListener {
private lateinit var binding: PresentationSecondaryScreenBinding
private var cameraDevice: CameraDevice? = null
// private var cameraCaptureSession: CameraCaptureSession? = null
private lateinit var cameraManager: CameraManager
private var cameraIdList: Array<String> = arrayOf()
// 当前食物信息
private var currentFood: FoodInfo? = null
private var userNutritionData: UserNutritionData? = null
private var dinnerType: DinnerTypeInfo? = null
private var dinnerTypeInfo: DinnerTypeInfo? = null
private lateinit var itemUserNutritionBinding: ItemUserNutritionBinding
private lateinit var stepChangeCallback: (Int) -> Unit
private var detectWeight = 0.0 //识别菜品时的重量
private var lastWeight = 0.0 // 上一次的计算热量结果
private val debouncer = Debouncer(500)
private var recognitionTime: Long = 0L // 人脸识别时的时间
private var recognitionWeight: Double = 0.0 // 人脸识别时的重量
private var lastFaceTrackId: Int = -1 // 上一次的人脸信息
var mealPickupMode: Int = 0 // 取餐模式 0 即放即取 1 余量取餐
// 当前步骤
var currentStep: Int = 0
private var isGoStep1 = false;//防止持续调用
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -90,25 +95,35 @@ class SecondaryScreenPresentation(
setContentView(binding.root)
window?.setBackgroundDrawableResource(android.R.color.transparent)
initView()
// binding.includeUserHeat.ivHeat.showText("1070千卡")
setupArcCamera()
registerDataChange()
}
private fun registerDataChange() {
activity.lifecycleScope.launch {
viewModel.loadFaceResult.collect { needUpdate ->
if (needUpdate) {
recognizeViewModel.refreshFaceList()
}
}
}
activity.lifecycleScope.launch {
viewModel.nutritionData.collect {
Timber.d("registerDataChange nutritionData = $it")
if (it == null) return@collect
userNutritionData = it
if (it == null) return@collect
step3()
itemUserNutritionBinding.tvUserName.text = it.userName.maskName()
itemUserNutritionBinding.tvRecommendHeat.text =
"推荐热量:${it.recommendMin}-${it.recommendMax}"
updateWeight(lastWeight / 1000)
}
}
activity.lifecycleScope.launch {
viewModel.dinnerTypeInfo.collect {
dinnerType = it
viewModel.dinnerTypeInfo.drop(1).collect {
Timber.d("registerDataChange dinnerTypeInfo = $it")
dinnerTypeInfo = it
}
}
}
@@ -138,22 +153,9 @@ class SecondaryScreenPresentation(
* 启用虹软人脸失败
*/
private fun setupArcCamera() {
cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
// 获取摄像头列表
cameraIdList = cameraManager.cameraIdList
if (cameraIdList.isEmpty()) {
Toast.makeText(context, "没有可用的摄像头", Toast.LENGTH_SHORT).show()
return
}
cameraIdList.forEach {
Timber.d("摄像头id列表 = ${it}")
}
initArcViewModel()
initArcView()
openRectInfoDraw = true
openCamera(cameraIdList.last())
}
/**
@@ -162,49 +164,69 @@ class SecondaryScreenPresentation(
fun step1() {
Timber.d("step1")
currentStep = 1
LightManager.closeGreenLight()
LightManager.closeRedLight()
//回复上一次的设置
// lastWeight = 0.0
dinnerTypeInfo = null
currentFood = null
userNutritionData = null
recognitionWeight = 0.0
stepChangeCallback(currentStep)
binding.tvFoodInfo.text = "菜品识别中"
binding.tvFoodName.text = "菜品识别中"
binding.ivFaceBg.visibility = View.GONE
binding.flPreview.visibility = View.VISIBLE
binding.ivImg.visibility = View.VISIBLE
binding.flFace.visibility = View.GONE
binding.flFace.visibility = View.INVISIBLE
binding.tvFaceTip.visibility = View.GONE
pauseCamera()
binding.tvBottomTip.visibility = View.VISIBLE
itemUserNutritionBinding.clNutritionData.visibility = View.GONE
stopSensorScale()
viewModel.getDinnerType()
activity.clearFoodList()
}
/**
* 显示人脸识别
*/
fun step2(foodInfo: FoodInfo) {
isGoStep1 = true
Timber.d("step2")
currentStep = 2
LightManager.closeRedLight()
// LightManager.openGreenLight()
dinnerTypeInfo = null
userNutritionData = null
recognitionWeight = 0.0
stepChangeCallback(currentStep)
currentFood = foodInfo
viewModel.getDinnerType()
resumeCamera()
binding.tvFoodInfo.text = foodInfo.foodName
binding.tvFoodName.text = foodInfo.foodName
binding.ivImg.visibility = View.GONE
binding.ivFaceBg.visibility = View.VISIBLE
binding.flPreview.visibility = View.GONE
binding.flFace.visibility = View.VISIBLE
// binding.flFace.visibility = View.VISIBLE
binding.flFace.post {
binding.flFace.visibility = View.VISIBLE
}
binding.ivFaceBg.visibility = View.VISIBLE
binding.tvFaceTip.visibility = View.VISIBLE
setupArcCamera()
binding.tvBottomTip.visibility = View.VISIBLE
itemUserNutritionBinding.clNutritionData.visibility = View.GONE
}
fun updateFood(foodInfo: FoodInfo?) {
Timber.d("step2 updateFood")
Timber.d("updateFood")
currentFood = foodInfo
if (foodInfo != null) {
if (currentStep != 2) {
if (currentStep == 1) {
step2(foodInfo)
} else {
binding.tvFoodInfo.text = foodInfo.foodName
updateFoodInfo(foodInfo)
}
}
}
@@ -214,141 +236,218 @@ class SecondaryScreenPresentation(
*/
fun step3() {
Timber.d("step3")
LightManager.closeRedLight()
LightManager.openGreenLight()
currentStep = 3
stepChangeCallback(currentStep)
if (currentFood == null) {
ToastUtils.showToast("选中物品为空3")
return
}
binding.tvFoodInfo.text = currentFood!!.foodName
startSensorScale()
GlideUtils.loadRoundCornerImage(
context,
url = currentFood!!.imgUrl,
imageView = binding.ivImg,
radius = 12
)
updateFoodInfo(currentFood!!)
binding.flPreview.visibility = View.VISIBLE
// binding.previewView.visibility = View.GONE
binding.ivImg.visibility = View.VISIBLE
binding.flFace.visibility = View.GONE
binding.flFace.visibility = View.INVISIBLE
binding.ivFaceBg.visibility = View.GONE
binding.tvFaceTip.visibility = View.GONE
binding.tvBottomTip.visibility = View.GONE
itemUserNutritionBinding.clNutritionData.visibility = View.VISIBLE
viewModel.getUserNutritionData(
userId = userNutritionData!!.userId!!,
foodId = currentFood!!.id!!
)
}
private fun startSensorScale() {
var lastWeight = 0.0
val debouncer = Debouncer(1000)
SensorScaleUtils.startScale { weight ->
if (weight == lastWeight) return@startScale
Timber.d("registerDataChange weight = $weight")
lastWeight = weight
if (currentFood == null) return@startScale
if (userNutritionData == null) return@startScale
if (dinnerType == null) return@startScale
debouncer.debounce {
val calcResultInfo = UserNutritionUtils.calculateNutrition(
currentFood!!,
userNutritionData!!,
weight,
dinnerType = dinnerType!!.dinnerType!!.dinnerType!!
private fun updateFoodInfo(foodInfo: FoodInfo) {
binding.tvFoodName.text = foodInfo.foodName
if (foodInfo.photoUri != null) {
GlideUtils.loadRoundCornerImage(
context,
url = foodInfo.photoUri,
imageView = binding.ivImg,
radius = 12
)
} else {
GlideUtils.loadRoundCornerImage(
context,
url = foodInfo.imgUrl,
imageView = binding.ivImg,
radius = 12
)
}
}
fun updateWeight(weight: Double) {
lastWeight = weight * 1000 // 将千克转成克
Timber.d("updateWeight lastWeight = $weight")
if (currentStep == 2 &&
lastWeight <= 5 &&
isGoStep1
) {//人脸识别过程中,秤上物品拿走
isGoStep1 = false
activity.runOnUiThread {
Timber.e("切回step1")
step1()
}
}
if (currentFood == null) {
Timber.e("updateWeight currentFood is null")
return
}
if (userNutritionData == null) {
Timber.e("updateWeight userNutritionData is null")
return
}
if (dinnerTypeInfo == null) {
Timber.e("updateWeight dinnerType is null")
return
}
calculateNutrition(recognitionWeight - lastWeight)
}
fun updateDetectWeight(weight: Double) {
detectWeight = weight * 1000 // 将千克转成克
}
/**
* 余量取餐
*/
fun updateMealPickupMode(mode: Int) {
Timber.d("updateMealPickupMode mode = $mode")
mealPickupMode = mode
step1()
}
private fun calculateNutrition(weight1: Double) {
var weight = weight1
if (weight < 0) weight = 0.0
debouncer.debounce {
Timber.d("calculateNutrition weight = $weight, recognitionWeight = $recognitionWeight")
val dinnerType = dinnerTypeInfo!!.dinnerType!!.dinnerType!!
Timber.d("calculateNutrition foodName = ${currentFood!!.foodName}, dinnerType = $dinnerType, userId = ${userNutritionData!!.userId}")
val calcResultInfo = UserNutritionUtils.calculateNutrition(
currentFood!!,
userNutritionData!!,
weight,
dinnerType = dinnerType
)
Timber.d("calculateNutrition calcResultInfo = $calcResultInfo")
val columnMax = UserNutritionUtils.getMaxInt(calcResultInfo)
activity.runOnUiThread {
val totalKcal = calcResultInfo.totalKcal
val grain = calcResultInfo.grain
val fruits = calcResultInfo.fruits
val meat = calcResultInfo.meat
// 设置热量
itemUserNutritionBinding.customKcalColumn.setImageDrawable(
true,
totalKcal.max < totalKcal.current
)
Timber.d("calcResultInfo = $calcResultInfo")
activity.runOnUiThread {
val totalKcal = calcResultInfo.totalKcal
val grain = calcResultInfo.grain
val fruits = calcResultInfo.fruits
val meat = calcResultInfo.meat
itemUserNutritionBinding.customKcalColumn.setImageDrawable(
true,
totalKcal.first < totalKcal.second
)
itemUserNutritionBinding.totalKcalTv.text = totalKcal.second.format2String()
itemUserNutritionBinding.customKcalColumn.setCustomHeightPercent(
(totalKcal.second / 100).toFloat(),
true
)
itemUserNutritionBinding.customFoodColumn.setImageDrawable(
false,
grain.first < grain.second
)
itemUserNutritionBinding.totalFoodTv.text = grain.second.format2String()
itemUserNutritionBinding.customFoodColumn.setCustomHeightPercent(
(grain.second / 100).toFloat(),
true
)
itemUserNutritionBinding.customVegetableColumn.setImageDrawable(
false,
fruits.first < fruits.second
)
itemUserNutritionBinding.totalVegetableTv.text = fruits.second.format2String()
itemUserNutritionBinding.customVegetableColumn.setCustomHeightPercent(
(fruits.second / 100).toFloat(),
true
)
itemUserNutritionBinding.customMeatColumn.setImageDrawable(
false,
meat.first < meat.second
)
itemUserNutritionBinding.totalMeatTv.text = meat.second.format2String()
itemUserNutritionBinding.customMeatColumn.setCustomHeightPercent(
(meat.second / 100).toFloat(),
true
)
var maxKcal = totalKcal.max
Timber.d("maxKcal = $maxKcal")
// 避免0作为被除数
if (maxKcal <= 0.0) {
maxKcal = 1.0
}
Timber.d("maxKcal 1 = $maxKcal")
itemUserNutritionBinding.totalKcalTv.text = totalKcal.current.format2String()
var totalKcalHeight = (totalKcal.current / maxKcal).toFloat()
if (totalKcalHeight == 0F) {
itemUserNutritionBinding.divKcalView.visibility = View.VISIBLE
} else {
itemUserNutritionBinding.divKcalView.visibility = View.GONE
totalKcalHeight += 0.1F
}
itemUserNutritionBinding.customKcalColumn.setCustomHeightPercent(
totalKcalHeight,
true
)
// 设置主食
itemUserNutritionBinding.customFoodColumn.setImageDrawable(
false,
grain.max < grain.current
)
itemUserNutritionBinding.totalFoodTv.text = grain.current.format2String()
var grainHeight = (grain.current / columnMax).toFloat()
if (grainHeight == 0F) {
itemUserNutritionBinding.divTotalFoodView.visibility = View.VISIBLE
} else {
itemUserNutritionBinding.divTotalFoodView.visibility = View.GONE
grainHeight += 0.1F
}
itemUserNutritionBinding.customFoodColumn.setCustomHeightPercent(
grainHeight,
true
)
// 设置果蔬
itemUserNutritionBinding.customVegetableColumn.setImageDrawable(
false,
fruits.max < fruits.current
)
itemUserNutritionBinding.totalVegetableTv.text = fruits.current.format2String()
var fruitsHeight = (fruits.current / columnMax).toFloat()
if (fruitsHeight == 0F) {
itemUserNutritionBinding.divVegetableView.visibility = View.VISIBLE
} else {
itemUserNutritionBinding.divVegetableView.visibility = View.GONE
fruitsHeight += 0.1F
}
itemUserNutritionBinding.customVegetableColumn.setCustomHeightPercent(
fruitsHeight,
true
)
// 设置肉蛋
itemUserNutritionBinding.customMeatColumn.setImageDrawable(
false,
meat.max < meat.current
)
itemUserNutritionBinding.totalMeatTv.text = meat.current.format2String()
var meatHeight = (meat.current / columnMax).toFloat()
if (meatHeight == 0F) {
itemUserNutritionBinding.divMeatView.visibility = View.VISIBLE
} else {
itemUserNutritionBinding.divMeatView.visibility = View.GONE
meatHeight += 0.1F
}
itemUserNutritionBinding.customMeatColumn.setCustomHeightPercent(
meatHeight,
true
)
}
}
}
private fun stopSensorScale() {
SensorScaleUtils.closeScale()
}
private fun openCamera(cameraId: String) {
private fun openCamera() {
Timber.d("openCamera")
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) !=
PackageManager.PERMISSION_GRANTED
) {
ToastUtils.showToast("无摄像头权限")
return
}
try {
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
cameraDevice = device
// createCameraPreviewSession()
recognizeViewModel.init(
PreviewConfig(
2, 4, 0, 0
)
)
initRgbCamera()
resumeCamera()
}
override fun onDisconnected(device: CameraDevice) {
device.close()
cameraDevice = null
}
override fun onError(device: CameraDevice, error: Int) {
device.close()
cameraDevice = null
}
}, null)
val cameraCount = Camera.getNumberOfCameras()
if (cameraCount < 3) {
ToastUtils.showToast("摄像头数量异常")
return
}
recognizeViewModel.init(
PreviewConfig(
2, 1, 90, 90
)
)
initRgbCamera()
if (DualCameraHelper.hasDualCamera() && livenessType === LivenessType.IR) {
initIrCamera()
}
} catch (e: Exception) {
Log.e("Camera2", "打开摄像头失败", e)
Timber.e(e, "打开摄像头失败")
}
}
@@ -360,9 +459,12 @@ class SecondaryScreenPresentation(
private var isRecognition = false
private var rgbCameraHelper: DualCameraHelper? = null
private var rgbFaceRectTransformer: FaceRectTransformer? = null
private val livenessType = LivenessType.RGB
private val livenessType = LivenessType.IR
private var openRectInfoDraw = false
private var irCameraHelper: DualCameraHelper? = null
private var irFaceRectTransformer: FaceRectTransformer? = null
private fun initArcViewModel() {
recognizeViewModel.setLiveType(livenessType)
@@ -404,16 +506,23 @@ class SecondaryScreenPresentation(
})
recognizeViewModel.recognizeNotice.observe(activity, Observer { notice: String? ->
Timber.i("recognizeNotice observe notice = $notice")
if (currentStep == 3 && notice == "leave") {
// step1()
}
})
recognizeViewModel.recognizeUserId.observe(activity, Observer { userId: String? ->
Timber.i("recognizeUserId observe userId = $userId")
if (userId == null) return@Observer
viewModel.getUserNutritionData(userId = userId, foodId = currentFood!!.id!!)
})
recognizeViewModel.recognizeUserId.observe(
activity,
Observer { compareResult: CompareResult ->
Timber.i("recognizeUserId observe compareResult = ${compareResult.trackId}, userId = ${compareResult.faceEntity.userName}")
recognitionTime = System.currentTimeMillis()
recognitionWeight = lastWeight
lastFaceTrackId = compareResult.trackId
val faceEntity = compareResult.faceEntity
val userId = faceEntity.userName
if (userId == null) return@Observer
viewModel.getUserNutritionData(
userId = userId,
foodId = currentFood!!.id!!
)
})
recognizeViewModel.drawRectInfoText.observe(activity, Observer { info ->
Timber.i("drawRectInfoText observe info = $info")
@@ -427,11 +536,11 @@ class SecondaryScreenPresentation(
}
/**
* 调整View的宽高,使预览显示正常且采集框固定为380x380
* 调整View的宽高,使预览显示正常且采集框固定为
*
* @param rgbPreview RGB预览View
* @param previewView 显示预览数据的view
* @param faceRectView 画框的view (380x380)
* @param faceRectView 画框的view
* @param previewSize 预览大小
* @param displayOrientation 相机旋转角度
* @param scale 缩放比例
@@ -495,25 +604,38 @@ class SecondaryScreenPresentation(
displayOrientation: Int,
isMirror: Boolean
) {
Timber.d("initRgbCamera Rgb onCameraOpened: cameraId = $cameraId, displayOrientation = $displayOrientation")
activity.runOnUiThread({
val previewSizeRgb = camera.getParameters().getPreviewSize()
val layoutParams = adjustPreviewViewSize(
binding.dualCameraTexturePreviewRgb,
binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView,
previewSizeRgb, 90, 1f
previewSizeRgb, displayOrientation, 0.6F
)
Timber.d("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}")
Timber.d("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}")
Timber.d(
"initRgbCamera isMirror = ${isMirror}, isDrawRgbRectHorizontalMirror = ${
ConfigUtil.isDrawRgbRectHorizontalMirror(
context
)
}, isDrawRgbRectVerticalMirror = ${
ConfigUtil.isDrawRgbRectVerticalMirror(
context
)
}"
)
// 调整识别窗口位置
rgbFaceRectTransformer = FaceRectTransformer(
previewSizeRgb.width,
previewSizeRgb.height,
layoutParams.width,
layoutParams.height,
displayOrientation,
90,
cameraId,
isMirror,
ConfigUtil.isDrawRgbRectHorizontalMirror(context),
ConfigUtil.isDrawRgbRectVerticalMirror(context)
true,
true
)
recognizeViewModel.onRgbCameraOpened(camera)
@@ -536,20 +658,20 @@ class SecondaryScreenPresentation(
}
override fun onCameraClosed() {
Timber.i("onCameraClosed: ")
Timber.i("initRgbCamera onCameraClosed: ")
}
override fun onCameraError(e: java.lang.Exception) {
Timber.i("onCameraError: %s", e.message)
Timber.i("initRgbCamera onCameraError: %s", e.message)
e.printStackTrace()
}
override fun onCameraConfigurationChanged(cameraID: Int, displayOrientation: Int) {
Timber.i("onCameraConfigurationChanged: threadName = ${Thread.currentThread().name}")
Timber.i("initRgbCamera onCameraConfigurationChanged: threadName = ${Thread.currentThread().name}")
if (rgbFaceRectTransformer != null) {
rgbFaceRectTransformer!!.cameraDisplayOrientation = displayOrientation
}
Timber.i("onCameraConfigurationChanged: $cameraID $displayOrientation")
Timber.i("initRgbCamera onCameraConfigurationChanged: $cameraID $displayOrientation")
}
}
@@ -562,7 +684,7 @@ class SecondaryScreenPresentation(
)
)
.rotation(activity.windowManager.defaultDisplay.rotation)
.additionalRotation(90) // 角度
.additionalRotation(previewConfig.rgbAdditionalDisplayOrientation) // 角度
.previewSize(recognizeViewModel.loadPreviewSize())
.specificCameraId(previewConfig.rgbCameraId)
.isMirror(true)
@@ -573,12 +695,96 @@ class SecondaryScreenPresentation(
rgbCameraHelper!!.start()
}
/**
* 初始化红外相机,若活体检测类型是可见光活体检测或不启用活体,则不需要启用
*/
private fun initIrCamera() {
Timber.d("initIrCamera: livenessType = $livenessType")
if (livenessType === LivenessType.RGB) {
return
}
val irCameraListener: CameraListener = object : CameraListener {
override fun onCameraOpened(
camera: Camera,
cameraId: Int,
displayOrientation: Int,
isMirror: Boolean
) {
Timber.d("initIrCamera IR onCameraOpened: cameraId = $cameraId, displayOrientation = $displayOrientation")
val previewSizeIr = camera.getParameters().getPreviewSize()
val layoutParams = adjustPreviewViewSize(
binding.dualCameraTexturePreviewRgb,
binding.dualCameraTexturePreviewIr, binding.dualCameraFaceRectViewIr,
previewSizeIr, displayOrientation, 0.25f
)
irFaceRectTransformer = FaceRectTransformer(
previewSizeIr.width, previewSizeIr.height,
layoutParams.width, layoutParams.height, displayOrientation, cameraId, isMirror,
ConfigUtil.isDrawIrRectHorizontalMirror(context),
ConfigUtil.isDrawIrRectVerticalMirror(context)
)
recognizeViewModel.onIrCameraOpened(camera)
recognizeViewModel.setIrFaceRectTransformer(irFaceRectTransformer)
}
override fun onPreview(nv21: ByteArray?, camera: Camera?) {
recognizeViewModel.refreshIrPreviewData(nv21)
}
override fun onCameraClosed() {
Timber.i("initIrCamera onCameraClosed: ")
}
override fun onCameraError(e: java.lang.Exception) {
Timber.i("initIrCamera onCameraError: ${e.message}")
e.printStackTrace()
}
override fun onCameraConfigurationChanged(
cameraID: Int,
displayOrientation: Int
) {
if (irFaceRectTransformer != null) {
irFaceRectTransformer!!.cameraDisplayOrientation = displayOrientation
}
Timber.i("initIrCamera onCameraConfigurationChanged: cameraID = $cameraID, displayOrientation = $displayOrientation")
}
}
val previewConfig = recognizeViewModel.previewConfig
irCameraHelper = DualCameraHelper.Builder()
.previewViewSize(
Point(
binding.dualCameraTexturePreviewIr.measuredWidth,
binding.dualCameraTexturePreviewIr.measuredHeight
)
)
.rotation(activity.windowManager.defaultDisplay.rotation)
.specificCameraId(previewConfig.irCameraId)
.previewOn(binding.dualCameraTexturePreviewIr)
.cameraListener(irCameraListener)
.isMirror(true)
.previewSize(recognizeViewModel.loadPreviewSize()) //相机预览大小设置,RGB与IR需使用相同大小
.additionalRotation(previewConfig.irAdditionalDisplayOrientation) //额外旋转角度
.build()
irCameraHelper!!.init()
try {
irCameraHelper!!.start()
} catch (e: RuntimeException) {
ToastUtils.showToast(e.message + context.getString(R.string.camera_error_notice))
}
}
/**
* 绘制RGB、IR画面的实时人脸信息
*
* @param facePreviewInfoList RGB画面的实时人脸信息
*/
private fun drawPreviewInfo(facePreviewInfoList: MutableList<FacePreviewInfo?>) {
// Timber.d("drawPreviewInfo facePreviewInfoList = ${facePreviewInfoList.size}, rgbFaceRectTransformer = ${rgbFaceRectTransformer != null}")
if (rgbFaceRectTransformer != null) {
val rgbDrawInfoList: MutableList<DrawInfo?>? = recognizeViewModel.getDrawInfo(
facePreviewInfoList,
@@ -588,6 +794,42 @@ class SecondaryScreenPresentation(
// 识别成功
binding.dualCameraFaceRectView.drawRealtimeFaceInfo(rgbDrawInfoList)
}
if (facePreviewInfoList.isEmpty() || (lastFaceTrackId != facePreviewInfoList[0]!!.trackId)) {
if (lastFaceTrackId != -1) {
Timber.i("$lastFaceTrackId 用户离开")
lastFaceTrackId = -1
postUserData()
if (mealPickupMode == 0) {
step1()
} else {
step2(currentFood!!)
}
}
}
}
fun postUserData() {
Timber.d("postUserData")
if (userNutritionData == null || currentFood == null) {
Timber.d("postUserData userNutritionData = ${userNutritionData == null}, currentFood = ${currentFood == null}")
return
}
var eatWeight = 0.0
if (mealPickupMode == 0) {//即放即取
eatWeight = recognitionWeight - 0
} else {
eatWeight = recognitionWeight - lastWeight
}
val userNutritionParam = UserNutritionParam(
userId = userNutritionData?.userId!!,
foodId = currentFood?.id!!,
faceTime = recognitionTime,
faceEndTime = System.currentTimeMillis(),
eatWeight = eatWeight,//lastWeight
foodWeight = lastWeight
)
viewModel.postUserNutritionData(listOf<UserNutritionParam>(userNutritionParam))
}
fun openRectInfoDraw(view: View?) {
@@ -599,6 +841,7 @@ class SecondaryScreenPresentation(
override fun onGlobalLayout() {
Timber.d("onGlobalLayout")
binding.dualCameraTexturePreviewRgb.getViewTreeObserver().removeOnGlobalLayoutListener(this)
openCamera()
}
@@ -618,13 +861,16 @@ class SecondaryScreenPresentation(
rgbCameraHelper!!.release()
rgbCameraHelper = null
}
SensorScaleUtils.closeScale()
if (irCameraHelper != null) {
irCameraHelper!!.release()
irCameraHelper = null
}
recognizeViewModel.destroy()
super.onStop()
}
fun resumeCamera() {
Timber.d("resumeCamera")
Timber.d("resumeCamera isRecognition = $isRecognition")
isRecognition = true
if (rgbCameraHelper != null && rgbCameraHelper!!.isStopped) {
rgbCameraHelper!!.start()
@@ -632,7 +878,7 @@ class SecondaryScreenPresentation(
}
fun pauseCamera() {
Timber.d("pauseCamera")
Timber.d("pauseCamera isRecognition = $isRecognition")
isRecognition = false
recognizeViewModel.onPreviewFrame(ByteArray(1382400), true)
@@ -2,9 +2,15 @@ package com.sw.dualscreen.presentation
import android.text.TextUtils
import com.sw.dualscreen.ext.toSafeDouble
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.model.response.UserNutritionData
import timber.log.Timber
import kotlin.math.max
/**
* 用户营养信息计算工具
*/
object UserNutritionUtils {
/**
@@ -16,46 +22,68 @@ object UserNutritionUtils {
weight: Double,
dinnerType: String
): CalcResultInfo {
// 1. 初始化变量
// 初始化变量
var foodKcal = 0.0
var totalKcal = 0.0
var (vegetable, meat, fruits, grain) = List(4) { 0.0 }
// 2. 处理食物信息
// 处理食物信息
foodKcal = calculateValue(foodInfo.stFoodInfoMaterial?.energyKcal, weight)
Timber.d("calculateNutrition foodKcal = ${foodKcal}, energyKcal = ${foodInfo.stFoodInfoMaterial?.energyKcal}, weight = $weight")
grain = calculateValue(foodInfo.stFoodInfoPagodaAPPVO?.grainValue(), weight)
fruits = calculateValue(foodInfo.stFoodInfoPagodaAPPVO?.fruitsValue(), weight)
vegetable = calculateValue(foodInfo.stFoodInfoPagodaAPPVO?.vegetableValue(), weight)
meat = calculateValue(foodInfo.stFoodInfoPagodaAPPVO?.meatValue(), weight)
// 3. 合并用户数据
// 合并用户数据
grain += userModel.stFoodInfoPagoda?.grainValue() ?: 0.0
fruits += userModel.stFoodInfoPagoda?.fruitsValue() ?: 0.0
vegetable = vegetable.plus(userModel.stFoodInfoPagoda?.vegetableValue() ?: 0.0)
meat += userModel.stFoodInfoPagoda?.meatValue() ?: 0.0
totalKcal = foodKcal + userModel.energyValue()
val fruitsAndVeg = fruits + vegetable
// 4. 判断当餐最大热量
Timber.d("calculateNutrition totalKcal = ${totalKcal}, energyValue = ${userModel.energyValue()}")
if (totalKcal < 0){
totalKcal = 0.0
}
// 判断当餐最大热量
val maxKcal =
userModel.totalEnergyCalculateScoreValue() * calculateDinnerTypeRatio(dinnerType) / 10
Timber.d("calculateNutrition maxKcal = ${maxKcal}, CalculateScoreValue = ${userModel.totalEnergyCalculateScoreValue()}")
val pagoda = userModel.stFoodInfoPagoda
val fruitsInfo = parseRecommend(pagoda?.fruitsRecommend, fruits)
val vegetableInfo = parseRecommend(pagoda?.vegetableRecommend, vegetable)
val calcResult = CalcResultInfo(
totalKcal = maxKcal to totalKcal,
grain = parseMax(pagoda?.grainRecommend) to grain,
fruits = parseMax(pagoda?.fruitsRecommend) + parseMax(pagoda?.vegetableRecommend) to fruitsAndVeg,
meat = parseMax(pagoda?.meatRecommend) to meat
totalKcal = CalcInfo(
max = maxKcal,
min = 0.0,
current = totalKcal
),
grain = parseRecommend(pagoda?.grainRecommend, grain),
fruits = CalcInfo( // 果蔬 = 水果+蔬菜
max = fruitsInfo.max + vegetableInfo.max,
min = fruitsInfo.min + vegetableInfo.min,
current = fruitsInfo.current + vegetableInfo.current
),
meat = parseRecommend(pagoda?.meatRecommend, meat)
)
return calcResult
}
fun parseMax(recommend: String?): Double {
if (TextUtils.isEmpty(recommend) || !recommend!!.contains("-")) return 0.0
fun parseRecommend(recommend: String?, current: Double): CalcInfo {
var newCurrent = if (current < 0) 0.0 else current
if (TextUtils.isEmpty(recommend) || !recommend!!.contains("-")) {
Timber.e("parseRecommend recommend 格式错误")
return CalcInfo(
newCurrent,
0.0,
newCurrent
)
}
val (min, max) = recommend.split("-")
return max.toDouble()
return CalcInfo(max = max.toSafeDouble(), min = min.toSafeDouble(), current = newCurrent)
}
private fun calculateDinnerTypeRatio(dinnerType: String): Int {
@@ -66,14 +94,28 @@ object UserNutritionUtils {
}
}
/**
* 计算每百克含量
*/
private fun calculateValue(nutrient: Double?, weight: Double): Double {
return weight * (nutrient ?: 1.0)
return weight * (nutrient ?: 1.0) / 100
}
fun getMaxInt(calcResultInfo: CalcResultInfo): Int {
val max1 = max(calcResultInfo.grain.max, calcResultInfo.fruits.max)
return max(max1, calcResultInfo.meat.max).toInt()
}
data class CalcInfo(
var max: Double,
var min: Double,
var current: Double,
)
data class CalcResultInfo(
var totalKcal: Pair<Double, Double>,
var grain: Pair<Double, Double>,
var fruits: Pair<Double, Double>,
var meat: Pair<Double, Double>,
var totalKcal: CalcInfo,
var grain: CalcInfo,
var fruits: CalcInfo,
var meat: CalcInfo,
)
}
@@ -0,0 +1,19 @@
package com.sw.dualscreen.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.sw.dualscreen.activity.InitActivity
import timber.log.Timber
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
Timber.d("设备启动完成,开始执行自启动逻辑")
val intent = Intent(context, InitActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}
}
@@ -1,5 +1,7 @@
package com.sw.dualscreen.repository
import android.content.Context
import android.net.Uri
import com.sw.dualscreen.GlobalData
import com.sw.dualscreen.model.request.UserNutritionParam
import com.sw.dualscreen.model.response.ApiResponse
@@ -9,6 +11,9 @@ import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.model.response.UserFaceInfo
import com.sw.dualscreen.model.response.UserNutritionData
import com.sw.dualscreen.network.api.ApiService
import com.sw.dualscreen.utils.ImageUtil
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
/**
* 远程数据处理
@@ -99,8 +104,43 @@ class RemoteRepository constructor(
* 提交就餐数据
*/
suspend fun postUserNutritionData(
param: UserNutritionParam
param: List<UserNutritionParam>
): ApiResponse<String> {
return safeApiCall { apiService.postUserNutritionData(param = param) }
}
/**
* 获取菜品信息
* @param restId 从device服务获取的canteenId字段
* @param foodName 菜品名称,多个使用逗号拼接
*/
suspend fun getFoodInfo(
restId: String = GlobalData.restId,
foodName: String,
): ApiResponse<List<FoodInfo>> {
return safeApiCall { apiService.getFoodInfo(restId = restId, foodName = foodName) }
}
/**
* 获取菜品信息
* @param restId 从device服务获取的canteenId字段
* @param foodName 菜品名称,多个使用逗号拼接
*/
suspend fun postImageData(
context: Context,
restId: String = GlobalData.restId,
foodId: String,
foodName: String,
foodVector: String,
uri: Uri,
): ApiResponse<String> {
val params = HashMap<String, RequestBody>()
params["placeId"] = restId.toRequestBody()
params["foodId"] = foodId.toRequestBody()
params["foodName"] = foodName.toRequestBody()
params["foodVector"] = foodVector.toRequestBody()
val imagePart = ImageUtil.uriToMultipart(context, uri, "foodPic")
return safeApiCall { apiService.postImageData(params = params, image = imagePart) }
}
}
@@ -1,6 +1,5 @@
package com.sw.dualscreen.sdk
import android.util.Log
import com.sw.plate.utils.ToastUtils
import com.wabon.wbintelligenthardwaresdk.api.SensorScale
import com.wabon.wbintelligenthardwaresdk.api.SensorScale.OnScaleResult
@@ -19,7 +18,10 @@ object SensorScaleUtils {
const val baudRate = 115200
private var mSensorScale: SensorScale? = null
private var isOpened: Boolean = false
private var isRead: Boolean = false
var isZero = false
private var callback: Callback? = {}
private var lastWeight: Double? = null
private fun init() {
mSensorScale = SensorScale(object : OnScaleResult {
@@ -35,7 +37,8 @@ object SensorScaleUtils {
}
// Log.d(TAG, "readWeight state = ${stateStr}, weight = $value")
// 只使用稳定值
if (state == SensorScale.STATE_STABLE) {
if (state == SensorScale.STATE_STABLE && lastWeight != value) {
lastWeight = value
callback?.invoke(value)
}
}
@@ -44,11 +47,11 @@ object SensorScaleUtils {
* 读取鉴别率
*/
override fun readIdentify(rate: Int) {
Log.e(TAG, "readIdentify rate = $rate")
Timber.e("readIdentify rate = $rate")
}
override fun fail(errCode: Int) {
Log.e(TAG, "fail code = $errCode")
Timber.e("fail code = $errCode")
}
})
}
@@ -58,25 +61,27 @@ object SensorScaleUtils {
* @param autoScale 是否开启自动读取
*/
fun startScale(autoScale: Boolean = true, callback: Callback?) {
Timber.d("startScale isOpened = $isOpened")
if (isOpened) {
startContinuousRead(callback = callback)
return
}
this.callback = callback
init()
mSensorScale?.zero {
Timber.d("startScale: zero")
}
mSensorScale?.openScale(serialPort, baudRate) { open ->
isOpened = open
Timber.d("isOpened = $isOpened")
if (open) {
// zero()
if (autoScale) {
Thread({
Thread {
Thread.sleep(1000)
// 打开后需要等待后才能调用,否则会 1001 SDK未初始化
mSensorScale?.startContinuousRead()
}).start()
// Thread.sleep(1000)
zero()
}.start()
}
}
}
@@ -86,23 +91,24 @@ object SensorScaleUtils {
* 开启自动读取重量
*/
fun startContinuousRead(callback: Callback?) {
this.callback = callback
Log.d(TAG, "开启自动读取 = $isOpened")
if (!isOpened) {
Timber.d("开启自动读取 = $isRead")
if (isRead) {
return
}
this.callback = callback
mSensorScale?.startContinuousRead()
isRead = true
}
/**
* 手动读取重量
*/
fun readWeight(callback: Callback?) {
this.callback = callback
Log.d(TAG, "isOpened = $isOpened")
Timber.d("isOpened = $isOpened")
if (!isOpened) {
return
}
this.callback = callback
mSensorScale?.readWeight()
}
@@ -110,9 +116,11 @@ object SensorScaleUtils {
* 零位标定
*/
fun zero() {
Timber.d("zero isOpened = $isOpened, mSensorScale = $mSensorScale")
if (!isOpened) return
mSensorScale?.zero {
Log.d(TAG, "零位标定操作成功")
isZero = true
Timber.d("zero 零位标定操作成功")
ToastUtils.showToast("零位标定操作成功")
}
}
@@ -123,7 +131,7 @@ object SensorScaleUtils {
fun tare() {
if (!isOpened) return
mSensorScale?.tare {
Log.d(TAG, "去皮置零操作成功")
Timber.d("去皮置零操作成功")
ToastUtils.showToast("去皮置零操作成功")
}
}
@@ -132,18 +140,19 @@ object SensorScaleUtils {
* 停止自动读取重量
*/
fun stopContinuousRead() {
Log.d(TAG, "isOpened = $isOpened")
Timber.d("isOpened = $isOpened")
if (!isOpened) {
return
}
mSensorScale?.stopContinuousRead()
isRead = false
}
/**
* 关闭称重
*/
fun closeScale() {
Log.d(TAG, "closeScale")
Timber.d("closeScale")
isOpened = false
mSensorScale?.closeScale()
mSensorScale == null
@@ -0,0 +1,76 @@
package com.sw.dualscreen.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
object AssetsTool {
// fun readJson(context: Context, fileName: String): List<String> {
// val json = readAssetsFile(context, fileName)
// val json2 = json.replace("[[", "[").replaceAfterLast("]]", "]")
// val list = json2.split("],")
// val resultList = mutableListOf<String>()
// var count = 0
// val tempList = mutableListOf<String>()
// list.forEachIndexed { index, text ->
// if (count >= 500) {
// val tempJson = "[${tempList.joinToString (",")}]"
// resultList.add(tempJson)
// tempList.clear()
// count = 0
// }
// val newText = if (index == list.size - 1) text else "${text}]"
// tempList.add(newText)
// count++
// }
// if (count < 500) {
// val tempJson = "[${tempList.joinToString(",")}]"
// resultList.add(tempJson)
// }
// return resultList
// }
fun readAssetsFile(context: Context, fileName: String): String {
val stringBuilder = StringBuilder()
try {
val bf = BufferedReader(InputStreamReader(context.assets.open(fileName)))
bf.useLines { lines -> lines.forEach { stringBuilder.append(it) } }
} catch (e: IOException) {
e.printStackTrace()
}
return stringBuilder.toString()
}
fun loadImagesFromAssets(context: Context, subPath: String): MutableList<Bitmap> {
val bitmaps: MutableList<Bitmap> = mutableListOf()
val assetManager = context.assets
try {
val files = assetManager.list(subPath)
files?.forEach { file ->
assetManager.open("$subPath/$file").use { `is` ->
val bitmap = BitmapFactory.decodeStream(`is`)
if (bitmap != null) {
bitmaps.add(bitmap)
}
}
}
} catch (e: IOException) {
e.printStackTrace()
}
return bitmaps
}
fun loadImageBitmapFromAssets(context: Context, imagePath:String, action:(bmp: Bitmap)-> Unit) {
context.assets.open(imagePath).use { `is` ->
val bitmap = BitmapFactory.decodeStream(`is`)
action(bitmap)
}
}
}
@@ -0,0 +1,38 @@
package com.sw.dualscreen.utils
import android.graphics.Bitmap
object BitmapCropper {
/**
* 裁剪Bitmap中心区域为指定尺寸
* @param original 原始Bitmap
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
* @return 裁剪后的Bitmap
*/
fun cropCenter(original: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap {
val originalWidth = original.width
val originalHeight = original.height
// 计算中心点坐标
var startX = (originalWidth - targetWidth) / 2
var startY = (originalHeight - targetHeight) / 2
// 边界检查
startX = startX.coerceAtLeast(0)
startY = startY.coerceAtLeast(0)
val actualWidth = minOf(targetWidth, originalWidth - startX)
val actualHeight = minOf(targetHeight, originalHeight - startY)
return Bitmap.createBitmap(original, startX, startY, actualWidth, actualHeight)
}
}
// 使用示例
fun main() {
// 假设这是从资源加载的Bitmap
val originalBitmap = Bitmap.createBitmap(1000, 1000, Bitmap.Config.ARGB_8888)
// 裁剪中心500x500区域
val croppedBitmap = BitmapCropper.cropCenter(originalBitmap, 500, 500)
println("裁剪后尺寸:${croppedBitmap.width}x${croppedBitmap.height}")
}
@@ -0,0 +1,57 @@
package com.sw.dualscreen.utils
import android.content.Context
import android.graphics.Bitmap
import android.os.Environment
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object BitmapSaver {
// 保存到公共目录(需WRITE_EXTERNAL_STORAGE权限)
fun saveToPublicDirectory(
bitmap: Bitmap,
folderName: String = Environment.DIRECTORY_PICTURES,
fileName: String,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.PNG,
quality: Int = 100
): File? {
val dir = Environment.getExternalStoragePublicDirectory(folderName)
if (!dir.exists()) dir.mkdirs()
return saveBitmap(bitmap, File(dir, fileName), format, quality)
}
// 保存到应用私有目录(无需权限)
fun saveToAppFilesDir(
bitmap: Bitmap,
context: Context,
fileName: String,
format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
quality: Int = 100
): File? {
//val dir = context.getExternalFilesDir(null)
val dir = File(context.cacheDir, "crop")
if (dir.exists().not()) {
dir.mkdirs()
}
return saveBitmap(bitmap, File(dir, fileName), format, quality)
}
private fun saveBitmap(
bitmap: Bitmap,
outputFile: File,
format: Bitmap.CompressFormat,
quality: Int
): File? {
return try {
FileOutputStream(outputFile).use { fos ->
bitmap.compress(format, quality, fos)
fos.flush()
}
outputFile
} catch (e: IOException) {
e.printStackTrace()
null
}
}
}
@@ -0,0 +1,68 @@
package com.sw.dualscreen.utils
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import java.io.File
import java.io.FileOutputStream
/**
* 简洁相机工具类
* 调用系统相机拍照并返回照片路径
*/
class CameraHelper(
private val context: Context,
caller: ActivityResultCaller,
private val authority: String,
private val onResult: (uri: Uri, filePath: String?) -> Unit
) {
private lateinit var imageUri: Uri
private val takePictureLauncher: ActivityResultLauncher<Uri>
init {
takePictureLauncher =
caller.registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
val path = getRealPathFromUri(context, imageUri)
onResult(imageUri, path)
} else {
Toast.makeText(context, "拍照取消", Toast.LENGTH_SHORT).show()
}
}
}
/** 打开系统相机 */
fun openCamera() {
// 1. 创建存储图片的文件
val photoFile = File(context.externalCacheDir, "IMG_${System.currentTimeMillis()}.jpg")
photoFile.parentFile?.mkdirs() // 确保目录存在
// 2. 生成 content:// Uri
imageUri = FileProvider.getUriForFile(context, authority, photoFile)
// 3. 启动相机
takePictureLauncher.launch(imageUri)
}
/** 可选:将 content:// 转为实际路径 */
private fun getRealPathFromUri(context: Context, uri: Uri): String? {
var filePath: String? = null
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
val fileName = cursor.getString(nameIndex)
val file = File(context.cacheDir, fileName)
context.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(file).use { output -> input.copyTo(output) }
}
filePath = file.absolutePath
}
return filePath
}
}
@@ -0,0 +1,81 @@
package com.sw.dualscreen.utils
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.camera.core.CameraSelector
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
class CameraUtils(private var activity: ComponentActivity) {
private var cameraController: LifecycleCameraController? = null
private var photoCaptureHelper: PhotoCaptureHelper? = null
// private var isCameraReady = false
fun takePhoto(callback: (Uri) -> Unit) {
cameraController?.let {
if (photoCaptureHelper == null) {
initCaptureHelper()
}
}
photoCaptureHelper?.let {
it.addSuccessCallback(callback)
it.bindCameraCallback {
bind()
}
it.takePhoto()
}
}
private fun initCaptureHelper() {
photoCaptureHelper = PhotoCaptureHelper(
context = activity,
cameraController = cameraController!!,
onSuccess = {},
onError = { msg ->
//toast(msg)
}
)
}
fun setPreviewController(previewView: PreviewView?) {
if (previewView?.controller == null) {
previewView?.controller = cameraController
}
}
fun initCamera() {
if (cameraController == null) {
cameraController = LifecycleCameraController(activity).apply {
// 必须设置有效的用例
setEnabledUseCases(
CameraController.IMAGE_CAPTURE
// or CameraController.VIDEO_CAPTURE
)
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
}
bind()
}
// if (isCameraReady.not()) {
// try {
// cameraController!!.initializationFuture.addListener({
// isCameraReady = true
// Timber.d("Camera initialized successfully")
// }, ContextCompat.getMainExecutor(this))
// } catch (e: Exception) {
// Timber.d("Camera initialized error = ${e.message}")
// }
// }
}
fun bind() {
cameraController?.bindToLifecycle(activity)
}
fun unbind() {
cameraController?.unbind()
}
}
@@ -0,0 +1,246 @@
package com.sw.dualscreen.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 com.sw.dualscreen.activity.MainActivity
import timber.log.Timber
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.apply
import kotlin.collections.forEach
import kotlin.io.bufferedReader
import kotlin.io.readLines
import kotlin.io.use
import kotlin.jvm.java
import kotlin.run
import kotlin.system.exitProcess
import kotlin.text.endsWith
import kotlin.text.toByteArray
/**
* 崩溃处理
*/
class CrashHandler private constructor(private val context: Context) :
Thread.UncaughtExceptionHandler {
companion object {
private const val TAG = "CrashHandler"
private const val CRASH_REPORTS_DIR = "crash_reports"
private const val LOG_LINES = 500 // 收集最近500行日志
@Volatile
private var instance: CrashHandler? = null
fun init(context: Context) {
if (instance == null) {
synchronized(CrashHandler::class.java) {
if (instance == null) {
instance = CrashHandler(context.applicationContext)
}
}
}
}
fun getCrashReportFiles(context: Context): Array<File> {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
return if (crashDir.exists() && crashDir.isDirectory) {
crashDir.listFiles { _, name -> name.endsWith(".log") } ?: emptyArray()
} else {
emptyArray()
}
}
fun clearCrashReports(context: Context) {
getCrashReportFiles(context).forEach { it.delete() }
}
}
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
init {
Thread.setDefaultUncaughtExceptionHandler(this)
}
override fun uncaughtException(thread: Thread, ex: Throwable) {
handleException(thread, ex)
// 如果系统提供了默认的异常处理器,则交给系统去结束程序
// 否则自己结束程序
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) {
Timber.e(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 = kotlin.comparisons.maxOf(0, logLines.size - LOG_LINES)
logLines.subList(start, logLines.size).forEach {
append(it).append("\n")
}
} catch (e: IOException) {
Timber.e(e, "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 = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() && !crashDir.mkdirs()) {
Timber.tag(TAG).e("Failed to create crash report directory")
return
}
val crashFile = File(crashDir, fileName)
FileOutputStream(crashFile).use { it.write(crashInfo.toByteArray()) }
Timber.tag(TAG).d("Crash info saved to: ${crashFile.absolutePath}")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error saving crash info to file")
}
}
/**
* 清理旧的崩溃日志
*/
fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) {
file.delete()
}
}
}
}
@@ -1,6 +1,9 @@
package com.sw.dualscreen.utils
class Debouncer(private val delayMillis: Long) {
/**
* 防抖工具
*/
class Debouncer(private val delayMillis: Long = 2000) {
private var lastActionTime = 0L
/**
@@ -1,156 +0,0 @@
package com.sw.dualscreen.utils
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import timber.log.Timber
import java.io.File
object FileUtils {
/**
* 从Uri获取File
* example: file:///data/user/0/com.sw.inbound/cache/IMG_17515262353556856678814444882273.jpg
*/
private fun getFileFromUri(context: Context, uri: Uri): File? {
Timber.d("getFileFromUri uri = ${uri.scheme}")
return when (uri.scheme) {
"file" -> File(uri.path ?: return null)
"content" -> {
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val cacheDir = context.cacheDir
val file = File.createTempFile(
"upload_${System.currentTimeMillis()}",
".jpg",
cacheDir
)
file.outputStream().use { output ->
inputStream.copyTo(output)
}
file
} catch (e: Exception) {
Timber.e(e)
null
}
}
else -> null
}
}
/**
* 通过uri生成http请求体
*/
fun genRequestPart(context: Context, imageUri: Uri): MultipartBody.Part? {
Timber.d("genRequestPart imageUri = $imageUri")
// 1. 从Uri获取文件
val file = getFileFromUri(context, imageUri)
if (file == null) {
Timber.e("getFileFromUri file is null")
return null
}
// 2. 创建请求体
val requestFile = file
.asRequestBody("application/octet-stream".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"file",
file.name,
requestFile
)
return imagePart
}
/**
* 通过Uri删除文件
* @param context 上下文
* @param uri 文件Uri
* @return Boolean 是否删除成功
*/
fun deleteFileWithUri(context: Context, uri: Uri): Boolean {
Timber.d("deleteFileWithUri uri = ${uri.scheme}")
return when {
// 1. 处理 content:// 类型的Uri (MediaStore)
uri.scheme.equals("content", ignoreCase = true) -> {
deleteContentUriFile(context, uri)
}
// 2. 处理 file:// 类型的Uri
uri.scheme.equals("file", ignoreCase = true) -> {
deleteFileUriFile(uri)
}
// 3. 其他情况尝试直接解析路径
else -> {
deleteFileFromPath(uri.path ?: return false)
}
}
}
// 删除Content Uri文件
private fun deleteContentUriFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteContentUriFile uri = ${uri.scheme}")
return try {
context.contentResolver.delete(uri, null, null) > 0
} catch (e: SecurityException) {
// Android 10+需要特殊处理
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
deleteMediaStoreFile(context, uri)
} else {
false
}
} catch (e: Exception) {
Timber.e(e)
false
}
}
// Android 10+删除MediaStore文件
@RequiresApi(Build.VERSION_CODES.Q)
private fun deleteMediaStoreFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteMediaStoreFile uri = ${uri.scheme}")
val contentResolver = context.contentResolver
val projection = arrayOf(MediaStore.MediaColumns._ID)
return try {
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id =
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val contentUri = ContentUris.withAppendedId(uri, id)
contentResolver.delete(contentUri, null, null) > 0
} else {
false
}
} ?: false
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 删除File Uri文件
private fun deleteFileUriFile(uri: Uri): Boolean {
Timber.d("deleteFileUriFile uri = $uri")
return try {
File(uri.path ?: return false).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 直接通过路径删除文件
private fun deleteFileFromPath(path: String): Boolean {
Timber.d("deleteFileFromPath path = $path")
return try {
File(path).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
}
@@ -0,0 +1,47 @@
package com.example.utils
import android.util.Base64
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* 工具类:float[] 与 Base64 字符串互转
* 无需外部依赖,适用于 Android 原生项目。
*/
object FloatBase64Utils {
/**
* float数组 → Base64字符串
* @param values FloatArray
* @return Base64字符串(无换行)
*/
fun floatArrayToBase64(values: FloatArray): String {
if (values.isEmpty()) return ""
val buffer = ByteBuffer.allocate(values.size * 4)
buffer.order(ByteOrder.BIG_ENDIAN) // 默认大端序,可改 LITTLE_ENDIAN
values.forEach { buffer.putFloat(it) }
val bytes = buffer.array()
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
/**
* Base64字符串 → float数组
* @param base64 Base64字符串
* @return FloatArray
*/
fun base64ToFloatArray(base64: String?): FloatArray {
if (base64.isNullOrEmpty()) return FloatArray(0)
val bytes = Base64.decode(base64, Base64.NO_WRAP)
val buffer = ByteBuffer.wrap(bytes)
buffer.order(ByteOrder.BIG_ENDIAN)
val result = FloatArray(bytes.size / 4)
for (i in result.indices) {
result[i] = buffer.getFloat()
}
return result
}
}
@@ -0,0 +1,47 @@
package com.sw.dualscreen.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
object ImageUtil {
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
return try {
context.contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream)
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun uriToMultipart(context: Context, uri: Uri, partName: String): MultipartBody.Part? {
try {
val contentResolver = context.contentResolver
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
// 临时文件(避免直接访问SAF文件)
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir)
val outputStream = FileOutputStream(tempFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
val requestFile = RequestBody.create("image/*".toMediaTypeOrNull(), tempFile)
return MultipartBody.Part.createFormData(partName, tempFile.name, requestFile)
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}
@@ -0,0 +1,59 @@
package com.sw.dualscreen.utils
import android.app.Activity
import android.content.Context
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
/**
* 键盘工具类
*/
object KeyboardUtils {
/**
* 显示键盘
*/
fun showKeyboard(editText: EditText) {
editText.requestFocus()
val imm = editText.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
/**
* 隐藏键盘
*/
fun hideKeyboard(activity: Activity) {
val view = activity.currentFocus ?: activity.window.decorView
hideKeyboard(view)
}
/**
* 隐藏键盘
*/
fun hideKeyboard(view: View) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
/**
* 为EditText设置EditorAction监听并隐藏键盘
*/
fun setupEditorAction(editText: EditText, onAction: (() -> Unit)? = null) {
editText.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE ||
actionId == EditorInfo.IME_ACTION_SEARCH ||
actionId == EditorInfo.IME_ACTION_GO ||
actionId == EditorInfo.IME_ACTION_SEND ||
actionId == EditorInfo.IME_ACTION_NEXT) {
hideKeyboard(editText)
onAction?.invoke()
true
} else {
false
}
}
}
}
@@ -0,0 +1,92 @@
package com.sw.dualscreen.utils
import android.content.Context
import android.net.Uri
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.view.CameraController
import androidx.core.content.ContextCompat
import timber.log.Timber
import java.io.File
/**
* 拍照工具类
* @param context Context 上下文
* @param cameraController CameraController 相机控制器
* @param onSuccess (Uri) -> Unit 拍照成功回调
* @param onError (String) -> Unit 拍照失败回调
*/
class PhotoCaptureHelper(
private val context: Context,
private val cameraController: CameraController,
private val onSuccess: (Uri) -> Unit = {},
private val onError: (String) -> Unit = {}
) {
private val callbackList: MutableList<(Uri) -> Unit> = mutableListOf()
fun addSuccessCallback(callback:(Uri) -> Unit) {
if (callbackList.contains(callback).not()) {
callbackList.add(callback)
}
}
private var bindCamera:(()->Unit)?=null
fun bindCameraCallback(callback:()->Unit) {
this.bindCamera = callback
}
/**
* 拍照方法
* @param fileNamePrefix 文件名前缀,默认为"IMG_"
* @param fileExtension 文件扩展名,默认为".jpg"
*/
fun takePhoto(
fileNamePrefix: String = "IMG_",
fileExtension: String = ".jpg"
) {
Timber.d("开始拍照采集")
try {
val executor = ContextCompat.getMainExecutor(context)
val cacheDir = context.cacheDir
val photoFile = File.createTempFile(
"${fileNamePrefix}${System.currentTimeMillis()}",
fileExtension,
cacheDir
)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
cameraController.takePicture(
outputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
val photoUri = outputFileResults.savedUri ?: Uri.fromFile(photoFile)
Timber.d("照片保存成功: $photoUri")
onSuccess(photoUri)
callbackList.forEach {
it(photoUri)
}
}
override fun onError(exception: ImageCaptureException) {
val errorMsg = "拍照失败: ${exception.message}"
if (errorMsg.contains("Not bound to a valid Camera")) {
if (bindCamera != null) {
bindCamera?.invoke()
//takePhoto()
}
}
Timber.e(exception, errorMsg)
onError(errorMsg)
}
}
)
} catch (e: Exception) {
val errorMsg = "创建临时文件失败: ${e.message}"
Timber.e(e, errorMsg)
onError(errorMsg)
}
}
}
@@ -0,0 +1,99 @@
package com.sw.dualscreen.utils
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
/**
* 二维码生成工具类
*/
object QRCodeUtil {
/**
* 生成二维码(默认大小)
* @param content 二维码内容
* @return 生成的二维码Bitmap
*/
@JvmOverloads
fun generateQRCode(content: String, size: Int = 500): Bitmap? {
return generateQRCode(content, size, Color.BLACK, Color.WHITE)
}
/**
* 生成二维码(自定义颜色)
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param colorCode 二维码颜色
* @param backgroundColor 背景颜色
* @return 生成的二维码Bitmap
*/
fun generateQRCode(
content: String,
size: Int,
colorCode: Int,
backgroundColor: Int
): Bitmap? {
if (content.isEmpty()) {
return null
}
return try {
val hints = mutableMapOf<EncodeHintType, Any>().apply {
put(EncodeHintType.CHARACTER_SET, "UTF-8")
put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H) // 纠错级别
put(EncodeHintType.MARGIN, 1) // 边距
}
val bitMatrix = QRCodeWriter().encode(
content,
BarcodeFormat.QR_CODE,
size,
size,
hints
)
val pixels = IntArray(size * size).apply {
for (y in 0 until size) {
for (x in 0 until size) {
this[y * size + x] = if (bitMatrix.get(x, y)) colorCode else backgroundColor
}
}
}
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, size, 0, 0, size, size)
}
} catch (e: WriterException) {
e.printStackTrace()
null
}
}
/**
* 生成带Logo的二维码
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param logo Logo Bitmap
* @return 带Logo的二维码Bitmap
*/
fun generateQRCodeWithLogo(content: String, size: Int, logo: Bitmap?): Bitmap? {
val qrCode = generateQRCode(content, size) ?: return null
logo ?: return qrCode
val logoSize = size / 5 // Logo大小约为二维码的1/5
val scaledLogo = Bitmap.createScaledBitmap(logo, logoSize, logoSize, false)
val offset = (size - logoSize) / 2
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
val canvas = android.graphics.Canvas(this)
canvas.drawBitmap(qrCode, 0f, 0f, null)
canvas.drawBitmap(scaledLogo, offset.toFloat(), offset.toFloat(), null)
}
}
}
@@ -1,4 +1,4 @@
package com.sw.inbound.utils
package com.sw.dualscreen.utils
import android.content.Context
import androidx.core.content.edit
@@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* 持久化工具
*/
class SPUtil private constructor(context: Context, private val spName: String) {
companion object {
@@ -0,0 +1,34 @@
package com.sw.dualscreen.utils
import android.Manifest
import com.sw.dualscreen.activity.BaseActivity
object StorageUtils {
fun requestPermission(activity: BaseActivity<*>, callback: () -> Unit) {
activity.requestSinglePermissionResult(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
callback()
}
// activity.requestSinglePermissionResult(Manifest.permission.CAMERA) {
//// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
//// if (Environment.isExternalStorageManager()) {
//// callback()
//// } else {
//// val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
////// activity.startActivityForResult(intent, MANAGE_REQUEST_CODE)
//// activity.launchActivityResult(intent) {
//// callback()
//// }
//// }
//// } else {
//
//// }
// }
}
fun requestCameraPermission(activity: BaseActivity<*>, callback: () -> Unit) {
activity.requestSinglePermissionResult(Manifest.permission.CAMERA) {
callback()
}
}
}
@@ -1,5 +1,6 @@
package com.sw.dualscreen.view
import android.content.DialogInterface
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
@@ -18,10 +19,14 @@ import com.sw.dualscreen.databinding.BottomSheetDialogBinding
import com.sw.dualscreen.databinding.ItemSearchFoodInfoBinding
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.utils.Debouncer
import com.sw.dualscreen.utils.KeyboardUtils
import com.sw.dualscreen.viewmodel.UserViewModel
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* 底部搜索结果弹窗
*/
class CustomBottomSheetDialog(
val viewModel: UserViewModel,
val itemClickCallback: (FoodInfo) -> Unit
@@ -29,6 +34,7 @@ class CustomBottomSheetDialog(
private lateinit var binding: BottomSheetDialogBinding
private lateinit var adapter: GenericItemAdapter<FoodInfo, ItemSearchFoodInfoBinding>
private var checkedItem: FoodInfo? = null
private val debouncer = Debouncer(2000)
override fun onCreateView(
inflater: LayoutInflater,
@@ -41,13 +47,8 @@ class CustomBottomSheetDialog(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val debouncer = Debouncer(3000)
initView()
binding.ivSearch.setOnClickListener {
debouncer.debounce {
viewModel.searchByFoodName(binding.etSearch.text.toString())
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -82,6 +83,7 @@ class CustomBottomSheetDialog(
}
private fun initView() {
binding.viewClose.setOnClickListener { dismiss() }
adapter = createAdapter()
binding.recyclerview.layoutManager = GridLayoutManager(context, 2)
// 添加间距装饰(12dp)
@@ -93,9 +95,21 @@ class CustomBottomSheetDialog(
)
)
binding.recyclerview.adapter = adapter
binding.ivSearch.setOnClickListener {
searchInfo()
}
KeyboardUtils.setupEditorAction(binding.etSearch) {
searchInfo()
}
registerDataChange()
}
private fun searchInfo(){
debouncer.debounce {
viewModel.searchByFoodName(binding.etSearch.text.toString())
}
}
private fun registerDataChange() {
lifecycleScope.launch {
viewModel.searchFoodInfoList.collect {
@@ -131,7 +145,14 @@ class CustomBottomSheetDialog(
)
}
override fun onCancel(dialog: DialogInterface) {
Timber.d("onCancel")
viewModel.cleanSearchFoodInfoList()
super.onCancel(dialog)
}
override fun dismiss() {
Timber.d("dismiss")
viewModel.cleanSearchFoodInfoList()
super.dismiss()
}
@@ -0,0 +1,91 @@
package com.sw.dualscreen.view;
import android.app.Dialog;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
/**
* 加载进度条弹窗
*/
public class CustomDialog extends Dialog {
/**
* 宽高由布局文件中指定(但是最底层的宽度无效,可以多嵌套一层解决)
*/
public CustomDialog(Context context, View layout, int style) {
super(context, style);
setContentView(layout);
Window window = getWindow();
WindowManager.LayoutParams params = window.getAttributes();
params.gravity = Gravity.CENTER;
window.setAttributes(params);
}
/**
* 宽高由该方法的参数设置
*/
public CustomDialog(Context context, int width, int height, View layout,
int style) {
super(context, style);
// 设置内容
setContentView(layout);
// 设置窗口属性
Window window = getWindow();
WindowManager.LayoutParams params = window.getAttributes();
// 设置宽度、高度、密度、对齐方式
float density = getDensity(context);
params.width = (int) (width * density);
params.height = (int) (height * density);
params.gravity = Gravity.CENTER;
window.setAttributes(params);
}
/**
* 获取显示密度
*
* @param context
* @return
*/
public float getDensity(Context context) {
Resources res = context.getResources();
DisplayMetrics dm = res.getDisplayMetrics();
return dm.density;
}
private void fullScreenImmersive(View view) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
int uiOptions = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_FULLSCREEN;
view.setSystemUiVisibility(uiOptions);
}
}
@Override
public void show() {
this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
super.show();
// fullScreenImmersive(getWindow().getDecorView());
// this.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
}
}
@@ -9,12 +9,18 @@ import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AlphaAnimation;
import com.sw.dualscreen.R;
import com.sw.plate.utils.AppUtil;
import timber.log.Timber;
/**
* 自定义柱状图
*/
public class CustomImageView extends androidx.appcompat.widget.AppCompatImageView {
private AlphaAnimation alphaAnimation;
private String textToDraw;
@@ -95,11 +101,21 @@ public class CustomImageView extends androidx.appcompat.widget.AppCompatImageVie
}
public void setCustomHeightPercent(float percent, boolean isAnimation) {
setCustomHeight((int) (percent * MAX_HEIGHT), isAnimation);
Timber.d("setCustomHeightPercent percent = %s", percent);
if (percent<= 0){
percent = 0f;
}
setCustomHeight((int) (percent * MAX_HEIGHT), false);
}
// 用于设置自定义高度的方法
public void setCustomHeight(int height, boolean isAnimation) {
Timber.d("setCustomHeight height = %s", height);
setVisibility(height == 0 ? View.GONE : VISIBLE);
if (height > MAX_HEIGHT){
height = MAX_HEIGHT;
}
ViewGroup.LayoutParams layoutParams = getLayoutParams();
int height1 = layoutParams.height;
if (height1 != height) {
@@ -15,6 +15,9 @@ import androidx.appcompat.widget.AppCompatSpinner
import androidx.core.content.ContextCompat
import com.sw.dualscreen.R
/**
* spinner控件
*/
class CustomSpinner @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -1,56 +0,0 @@
package com.sw.dualscreen.view
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
class CylinderDrawView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 圆柱属性(默认红色)
var cylinderColor: Int = Color.RED
set(value) {
field = value
invalidate()
}
var cylinderWidth: Int = 120 // 默认宽度(px
set(value) {
field = value
requestLayout()
}
var cylinderHeight: Int = 360 // 默认高度(px
set(value) {
field = value
requestLayout()
}
private val paint = Paint().apply {
isAntiAlias = true // 抗锯齿
style = Paint.Style.FILL // 填充模式
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val measuredWidth = resolveSize(cylinderWidth, widthMeasureSpec)
val measuredHeight = resolveSize(cylinderHeight, heightMeasureSpec)
setMeasuredDimension(measuredWidth, measuredHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
paint.color = cylinderColor
// 绘制圆角矩形(半径=宽度的一半,形成圆柱效果)
val radius = cylinderWidth / 2f
canvas.drawRoundRect(
0f, 0f,
cylinderWidth.toFloat(), cylinderHeight.toFloat(),
radius, radius,
paint
)
}
}
@@ -1,104 +0,0 @@
package com.sw.dualscreen.view
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.sw.dualscreen.R
class CylinderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val valueText: TextView
private val unitText: TextView
private val cylinderDrawView: CylinderDrawView
private val topImage: ImageView
private val bottomImage: ImageView
private val bottomText: TextView
init {
// 加载内部布局
LayoutInflater.from(context).inflate(R.layout.view_cylinder, this, true)
// 绑定控件
valueText = findViewById(R.id.valueText)
unitText = findViewById(R.id.unitText)
cylinderDrawView = findViewById(R.id.cylinderDrawView)
topImage = findViewById(R.id.topImage)
bottomImage = findViewById(R.id.bottomImage)
bottomText = findViewById(R.id.bottomText)
// 解析自定义属性
context.obtainStyledAttributes(attrs, R.styleable.CylinderView).apply {
// 顶部文字:数值+单位合并
val value = getString(R.styleable.CylinderView_valueText) ?: ""
val unit = getString(R.styleable.CylinderView_unitText) ?: ""
valueText.text = value
valueText.textSize = getDimension(R.styleable.CylinderView_valueTextSize, 20f)
valueText.setTextColor(
getColor(
R.styleable.CylinderView_valueTextColor,
0xFFFFFFFF.toInt()
)
)
unitText.text = unit
unitText.textSize = getDimension(R.styleable.CylinderView_valueTextSize, 10f)
unitText.setTextColor(
getColor(
R.styleable.CylinderView_valueTextColor,
0xFFFFFFFF.toInt()
)
)
// 圆柱属性
cylinderDrawView.cylinderColor =
getColor(R.styleable.CylinderView_cylinderColor, Color.RED)
cylinderDrawView.cylinderWidth =
getDimensionPixelSize(R.styleable.CylinderView_cylinderWidth, 60)
cylinderDrawView.cylinderHeight =
getDimensionPixelSize(R.styleable.CylinderView_cylinderHeight, 180)
getResourceId(R.styleable.CylinderView_topImageSrc, -1).takeIf { it != -1 }?.let {
topImage.setImageResource(it)
// val layoutParams = cylinderDrawView.layoutParams
// layoutParams.height = (layoutParams.width / 2.45).toInt()
// topImage.layoutParams = layoutParams
}
// 底部图片
getResourceId(R.styleable.CylinderView_bottomImageSrc, -1).takeIf { it != -1 }?.let {
bottomImage.setImageResource(it)
// val layoutParams = cylinderDrawView.layoutParams
// layoutParams.height = 44
// bottomImage.layoutParams = layoutParams
}
// 底部文字
bottomText.text = getString(R.styleable.CylinderView_bottomText) ?: "热量"
bottomText.textSize = getDimension(R.styleable.CylinderView_bottomTextSize, 16f)
bottomText.setTextColor(
getColor(
R.styleable.CylinderView_bottomTextColor,
0xFFFFFFFF.toInt()
)
)
recycle() // 回收属性
}
}
fun setToImageSize() {
// topImage.layoutParams = LayoutParams.
}
fun setBottomSize() {
}
}
@@ -52,7 +52,10 @@ abstract class BaseViewModel() : ViewModel() {
}
protected open fun parseResponse(response: ApiResponse<*>): Boolean {
if (response.isSuccess()) {
if (response.isSuccess()
|| response.code == 200
|| response.code == 0
) {
return true
}
Timber.d("msg = ${response.message}, code = ${response.code}")
@@ -62,13 +65,13 @@ abstract class BaseViewModel() : ViewModel() {
fun parseEquipmentInfo(equipmentInfo: EquipmentInfo) {
GlobalData.appBaseUrl = equipmentInfo.appPackageUrl!!
GlobalData.appBaseUrl="http://192.168.1.210/gateway/local"
GlobalData.sdkKey = equipmentInfo.arcsoftSdkKey!!
GlobalData.appId = equipmentInfo.arcsoftAppId!!
// GlobalData.activeKey = equipmentInfo.arcsoftActiveKey!!
GlobalData.restId = equipmentInfo.canteenId!!
}
fun handleError(exception: Exception) {
Timber.d("handleError ${exception.message}")
}
@@ -1,9 +1,10 @@
package com.sw.dualscreen.viewmodel
import com.sw.dualscreen.GlobalData
import com.sw.dualscreen.GlobalKey
import com.sw.dualscreen.model.response.EquipmentInfo
import com.sw.dualscreen.utils.GsonUtils
import com.sw.inbound.utils.SPUtil
import com.sw.dualscreen.utils.SPUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
@@ -18,7 +19,7 @@ class DeviceViewModel : BaseViewModel() {
/**
* 获取token
*/
fun getDeviceToken(deviceId: String = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e") {
fun getDeviceToken(deviceId: String = GlobalData.deviceId) {
launchWithLoading {
val response = repository.getDeviceToken(deviceId)
if (parseResponse(response)) {
@@ -1,5 +1,7 @@
package com.sw.dualscreen.viewmodel
import android.content.Context
import android.net.Uri
import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo
import com.sw.dualscreen.GlobalData
@@ -9,19 +11,19 @@ import com.sw.dualscreen.model.response.DinnerTypeInfo
import com.sw.dualscreen.model.response.FoodInfo
import com.sw.dualscreen.model.response.UserFaceModel
import com.sw.dualscreen.model.response.UserNutritionData
import com.sw.inbound.utils.SPUtil
import com.sw.dualscreen.utils.SPUtil
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.math.atan
class UserViewModel : BaseViewModel() {
@@ -40,16 +42,23 @@ class UserViewModel : BaseViewModel() {
val nutritionData: StateFlow<UserNutritionData?> = _nutritionData
/**
* 饭点类型
* 饭点类型 早餐/午餐/晚餐
*/
private val _dinnerTypeInfo = MutableStateFlow<DinnerTypeInfo?>(null)
val dinnerTypeInfo: StateFlow<DinnerTypeInfo?> = _dinnerTypeInfo
/**
* 人脸加载完成
*/
private val _loadFaceResult = MutableStateFlow<Boolean>(false)
val loadFaceResult: StateFlow<Boolean> = _loadFaceResult
private val _identifiedFoodInfoList2 = MutableStateFlow<List<FoodInfo>>(emptyList())
val identifiedFoodInfoList2: StateFlow<List<FoodInfo>> = _identifiedFoodInfoList2
/**
* 获取token
*/
fun getEquipmentToken(qrcodeId: String = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e") {
fun getEquipmentToken(qrcodeId: String = GlobalData.deviceId) {
Timber.d("getEquipmentToken")
launchWithLoading {
val response = repository.getEquipmentToken(qrcodeId)
@@ -70,6 +79,7 @@ class UserViewModel : BaseViewModel() {
fun getUserFaceCache(index: Int = 0) {
Timber.d("getUserFaceCache index = $index")
launch {
_loadFaceResult.value = false
val response = repository.getUserFaceCache(index)
if (parseResponse(response)) {
// 获取成功一次后缓存状态
@@ -80,11 +90,12 @@ class UserViewModel : BaseViewModel() {
FaceEntity(it.userId, null, Base64.decode(it.faceFeatureString))
}
faceApi.updateFaceData(index, faceEntity)
activeEngine()
}
val nextPageIndex = response.result?.nextPageIndex ?: -1
if (nextPageIndex > 0) {
getUserFaceCache(nextPageIndex)
} else {
_loadFaceResult.value = true
}
}
}
@@ -129,25 +140,13 @@ class UserViewModel : BaseViewModel() {
})
}
/**
* 重置用户信息
*/
fun resetUserInfo() {
Timber.d("resetUserInfo")
}
fun getIdentifiedFoodList() {
Timber.d("identifiedFoodList")
launchWithLoading {
delay(5000)
_identifiedFoodInfoList.value = List(6) {
FoodInfo(
id = "185225109358109491${it}",
foodName = "豆芽炒粉条$it",
imgUrl = "http://101.201.149.156:9002/temp/20230912/f54b2d76-45e1-4c6b-a7a0-5fd1eace99d1_1694484601879.png"
)
val response = repository.getRestInfoFoodsByType(foodName = "")
if (parseResponse(response)) {
_identifiedFoodInfoList.value = response.result ?: emptyList()
}
// _currentFoodInfo.value = _identifiedFoodInfoList.value[0]
}
}
@@ -179,7 +178,8 @@ class UserViewModel : BaseViewModel() {
*/
fun getUserNutritionData(userId: String, foodId: String) {
Timber.d("getUserNutritionData userId = ${userId}, foodId = $foodId")
launchWithLoading {
_nutritionData.value = null
launch {
val response = repository.getUserNutritionData(userId = userId, foodId = foodId)
if (parseResponse(response)) {
_nutritionData.value = response.result
@@ -189,7 +189,8 @@ class UserViewModel : BaseViewModel() {
fun getDinnerType() {
Timber.d("getDinnerType")
launchWithLoading {
_dinnerTypeInfo.value = null
launch {
val response = repository.getDinnerType()
if (parseResponse(response)) {
_dinnerTypeInfo.value = response.result
@@ -200,13 +201,44 @@ class UserViewModel : BaseViewModel() {
/**
* 提交就餐数据
*/
fun postUserNutritionData(param: UserNutritionParam) {
fun postUserNutritionData(param: List<UserNutritionParam>) {
Timber.d("postUserNutritionData param = $param")
launchWithLoading {
launch {
val response = repository.postUserNutritionData(param)
if (parseResponse(response)) {
}
}
}
fun getFoodInfo(foodName: String, action:(List<FoodInfo>)-> Unit = {}) {
Timber.d("getFoodInfo")
launchWithLoading {
val response = repository.getFoodInfo(foodName = foodName)
if (parseResponse(response)) {
val list = response.result ?: emptyList()
_identifiedFoodInfoList2.value = list
action(list)
}
}
}
fun postImageData(
context: Context,
foodId: String,
foodName: String,
foodVector: String,
uri: Uri
) {
Timber.d("postImageData")
launchWithLoading {
val response = repository.postImageData(
context, foodId = foodId, foodName = foodName, foodVector = foodVector,
uri = uri
)
if (parseResponse(response)) {
Timber.tag("mzf").d("code=${response.code}")
}
}
}
}
+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="#FF3232" android:state_checked="true"/>
<item android:color="#5E7585" android:state_checked="false"/>
</selector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,10 @@
<?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="#FF0032C8" />
<corners android:radius="10dp" />
</shape>
</item>
</ripple>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 172 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ffffff"/>
<stroke android:color="#cdcdcd" android:width="1dp"/>
<corners android:radius="5dp"/>
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#ff08C6DC" />
<corners android:radius="8dp" />
</shape>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false">
<shape android:shape="rectangle">
<corners android:radius="8dp"/>
<solid android:color="#F4FAFF"/>
<stroke android:color="@color/white" android:width="2dp"/>
</shape>
</item>
<item android:state_checked="true">
<layer-list>
<item>
<shape android:shape="rectangle">
<corners android:radius="8dp"/>
<solid android:color="#FFF0F0"/>
<stroke android:color="#FF3232" android:width="2dp"/>
</shape>
</item>
<item android:width="32dp" android:height="32dp" android:gravity="bottom|end">
<bitmap android:src="@drawable/check_red"/>
</item>
</layer-list>
</item>
</selector>
+11
View File
@@ -0,0 +1,11 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:bottomLeftRadius="12dp"
android:bottomRightRadius="12dp"
android:topLeftRadius="12dp"
android:topRightRadius="12dp" />
<stroke
android:color="#ffff9900"
android:width="2dp" />
</shape>
@@ -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,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/white"/>
<stroke android:color="#FF0032C8" android:width="1dp"/>
<corners android:radius="10dp"/>
</shape>
</item>
</ripple>
@@ -2,6 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/white" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp" />
android:topLeftRadius="60dp"
android:topRightRadius="60dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="5dp" />
<solid android:color="@color/black" />
</shape>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" >
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_cash_normal" />
</item>
</layer-list>
</item>
<item android:state_checked="true">
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_cash_checked" />
</item>
</layer-list>
</item>
</selector>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" >
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_face_normal" />
</item>
</layer-list>
</item>
<item android:state_checked="true">
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_face_checked" />
</item>
</layer-list>
</item>
</selector>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false" >
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_number_normal" />
</item>
</layer-list>
</item>
<item android:state_checked="true">
<layer-list>
<item android:width="60dp" android:height="60dp">
<bitmap android:src="@drawable/pay_number_checked" />
</item>
</layer-list>
</item>
</selector>

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