Author SHA1 Message Date
lvmeng 05c5fe7932 fix(utils): 解决数字转换异常问题
- 修改DecimalAdapter中字符串转double的逻辑,使用安全转换避免崩溃
- 更新PrepareCookActivity中重量验证逻辑,使用安全转换处理无效输入
- 防止因无效数字格式导致的应用崩溃问题
2026-05-25 16:08:12 +08:00
mazengfei b08a77fc9d 提交shape文件 2026-05-25 10:25:45 +08:00
mazengfei 82a9bd15d9 解决秤地址10开始无法清零的问题 2026-05-22 17:59:00 +08:00
mazengfei 8aa5158b41 增加unInit回调 2026-05-22 17:31:38 +08:00
mazengfei 82960b26a0 优化秤重量显示,量程溢出时增加红色背景提示 2026-05-22 17:29:36 +08:00
mazengfei 863cd70118 优化配置页面调料格式显示 2026-05-22 16:34:06 +08:00
mazengfei e8037b12b7 优化调料格子 2026-05-22 10:32:07 +08:00
mazengfei 13390f59dc 优化 2026-05-22 09:50:22 +08:00
mazengfei 335b56eaea 布局优化 2026-05-22 09:47:40 +08:00
lvmeng 2d4f7619aa feat(build): 添加 Flexbox 库依赖
- 集成 Google Android Flexbox 库版本 3.0.0
- 为项目提供灵活的布局管理功能
2026-05-22 09:02:23 +08:00
lvmeng e82fffb40a refactor(app): 优化设备ID管理和秤SDK兼容性
- 移除BaseApp中的deviceId全局变量
- 添加对x86_64架构支持以用于Windows模拟器调试
- 增加BuildConfig字段IS_TEST_DEVICE控制测试设备逻辑
- 启用buildConfig功能支持编译时配置
- 实现测试设备使用固定DEVICE_ID_2替代真实UDID
- 将基础URL从测试环境切换到生产环境
- 重构WeightUtil支持多架构并添加错误处理
- 添加秤SDK可用性检查避免非ARM设备崩溃
2026-05-22 08:59:19 +08:00
lvmeng 30f6c74a99 旧版配比秤迁移新版接口与数据库逻辑
- 新增 net 包:ApiService、RemoteRepository、NetViewModel、UiState、ErrorType 等,替换旧 http 包
- 新增 model 包:CookFoodDTO、GoodsItem、FoodRecord 等 UI 层数据模型
- 重构 db 层:新增 DbRepository、DbViewModel,更新 Room Entity 和 DAO,升级数据库版本至13
- 删除旧 entity 包、http 包、viewmodel/factory 包
- 适配器(DishPartAdapter、GoodsInfoSearchAdapter、SeasoningSearchAdapter)改用 model 类,不再依赖数据库实体
- BaseActivity 统一通过 by viewModels() 提供 appViewModel/netViewModel/dbViewModel,移除各 Activity 工厂模式初始化
- 新增 GlobalData、ResetReasoningRecord;ErrorType 补充 getApiException 函数;build.gradle 添加 Retrofit2 依赖
2026-05-21 18:21:05 +08:00
247 changed files with 6099 additions and 15497 deletions
+6 -6
View File
@@ -1,15 +1,15 @@
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/ /.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store .DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
.claude/
app/debug/
app/release/
# 临时忽略 assets 下的 .pt 模型文件,正式文件就绪后取消此行
app/src/main/assets/*.pt
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-09-10T07:28:30.961571900Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.1.21:5555;connection=bc7af9a5" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<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">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
-4
View File
@@ -1,4 +0,0 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready
+34 -63
View File
@@ -1,14 +1,8 @@
plugins { plugins {
// alias(libs.plugins.android.application) alias(libs.plugins.android.application)
// alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
// id("com.google.devtools.ksp") version "2.0.21-1.0.27" id("com.google.devtools.ksp") version "2.0.21-1.0.27"
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 { android {
@@ -24,13 +18,12 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk { ndk {
//设置支持的SO库架构 //设置支持的SO库架构x86_64 仅用于在 Windows 模拟器上调试(秤功能不可用)
abiFilters.addAll(listOf("armeabi-v7a")) abiFilters.addAll(listOf("armeabi-v7a", "x86_64"))
//, 'x86', 'armeabi-v7a', 'x86_64', 'arm64-v8a'
} }
// 设置输出APK文件名格式 // 设置输出APK文件名格式
setProperty("archivesBaseName", "pbc_v${versionName}") setProperty("archivesBaseName", "菜品配比终端v${versionName}")
} }
signingConfigs { signingConfigs {
create("debug_507") { create("debug_507") {
@@ -48,12 +41,13 @@ android {
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName("debug_507") signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "false") buildConfigField("Boolean", "IS_TEST_DEVICE", "false")
} }
debug { debug {
//507扫码秤主板在android.buildTypes{}内增加 //507扫码秤主板在android.buildTypes{}内增加
signingConfig = signingConfigs.getByName("debug_507") signingConfig = signingConfigs.getByName("debug_507")
// buildConfigField("boolean", "DEBUG", "true") // 调试构建视为测试设备,使用固定的 DEVICE_ID_1 代替真实 UDID
buildConfigField("Boolean", "IS_TEST_DEVICE", "false")
} }
} }
compileOptions { compileOptions {
@@ -69,7 +63,7 @@ android {
} }
packagingOptions { packagingOptions {
pickFirst("lib/armeabi-v7a/libserial_port.so") pickFirst("lib/armeabi-v7a/libserial_port.so")
pickFirst("lib/arme64-v8a/libserial_port.so") pickFirst("lib/arm64-v8a/libserial_port.so")
} }
sourceSets { sourceSets {
named("main") { named("main") {
@@ -83,9 +77,7 @@ android {
} }
// li // li
} }
val isDebug = gradle.startParameter.taskNames.any {
it.contains("debug", ignoreCase = true)
}
dependencies { dependencies {
implementation(fileTree("libs") { include("*.jar", "*.aar") }) implementation(fileTree("libs") { include("*.jar", "*.aar") })
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -97,56 +89,35 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
implementation(libs.androidx.activity.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.gson) implementation("com.squareup.okhttp3:okhttp:4.9.1")
implementation(libs.okhttp) implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
implementation(libs.logging.interceptor) implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation(libs.retrofit) implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation(libs.java.websocket) implementation("com.google.code.gson:gson:2.10.1")
implementation(libs.converter.gson) // implementation("com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.30")
implementation(libs.flexbox)
implementation(libs.baserecyclerviewadapterhelper4) implementation("io.github.cymchad:BaseRecyclerViewAdapterHelper4:4.1.2")
// room implementation("androidx.room:room-runtime:2.5.2")
implementation(libs.androidx.room.runtime) ksp("androidx.room:room-compiler:2.5.2")
ksp(libs.androidx.room.compiler) implementation("androidx.room:room-ktx:2.5.2") // Kotlin扩展
implementation(libs.androidx.room.ktx) implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.activity:activity-ktx:1.8.0")
// implementation("biz.laenger.android:vpbs:0.0.6") // implementation("biz.laenger.android:vpbs:0.0.6")
// refresh-layout //必须 1.0.0 以上
implementation(libs.refresh.layout.kernel) implementation("io.github.scwang90:refresh-layout-kernel:3.0.0-alpha")
implementation(libs.refresh.header.classics) //核心必须依赖
implementation(libs.refresh.header.radar) implementation("io.github.scwang90:refresh-header-classics:3.0.0-alpha")
implementation(libs.refresh.footer.classics) //经典刷新头
implementation("io.github.scwang90:refresh-header-radar:3.0.0-alpha")
implementation("io.github.scwang90:refresh-footer-classics:3.0.0-alpha")
//经典加载
//implementation("com.geyifeng.immersionbar:immersionbar:3.2.2") //implementation("com.geyifeng.immersionbar:immersionbar:3.2.2")
// glide implementation("com.google.android.flexbox:flexbox:3.0.0")
implementation(libs.glide) }
annotationProcessor(libs.compiler)
// camerax
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
//pytorch
implementation (libs.pytorch.android)
implementation (libs.pytorch.android.torchvision)
// objectbox
if (isDebug) {
debugImplementation(libs.objectbox.android.objectbrowser)
} else {
releaseImplementation(libs.objectbox.android)
}
}
apply(plugin = "io.objectbox")
-49
View File
@@ -1,49 +0,0 @@
{
"_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:6465153867413579585",
"lastPropertyId": "4:3660727688686055762",
"name": "Food",
"properties": [
{
"id": "1:3688350375714699356",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:6503828433160216329",
"name": "name",
"type": 9
},
{
"id": "3:1726525909323356793",
"name": "foodIdx",
"type": 5
},
{
"id": "4:3660727688686055762",
"name": "foodVector",
"indexId": "1:5497952887792054610",
"type": 28,
"flags": 8
}
],
"relations": []
}
],
"lastEntityId": "1:6465153867413579585",
"lastIndexId": "1:5497952887792054610",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [],
"retiredRelationUids": [],
"version": 1
}
Binary file not shown.
+13 -53
View File
@@ -1,15 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
<!-- android:sharedUserId="android.uid.system" --> android:sharedUserId="android.uid.system">
<uses-feature android:name="android.hardware.usb.host" /> <uses-feature android:name="android.hardware.usb.host" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
@@ -45,6 +41,7 @@
<activity <activity
android:name=".ui.InitActivity" android:name=".ui.InitActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"> tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -53,29 +50,24 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".ui.HomeActivity" <activity android:name=".ui.HomeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.CookingModeActivity" android:name=".ui.SelectDishActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity <activity
android:name=".ui.PrepareFoodActivity" android:name=".ui.PrepareCookActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<!-- <activity-->
<!-- android:name=".ui.DishSamplingActivity"-->
<!-- android:theme="@style/Theme.DishMatch.NoSplash"-->
<!-- android:screenOrientation="portrait"-->
<!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />-->
<activity <activity
android:name=".ui.SamplingModeActivity" android:name=".ui.DishSamplingActivity"
android:theme="@style/Theme.DishMatch.NoSplash" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name=".ui.SamplingListActivity"
android:launchMode="singleTask" android:launchMode="singleTask"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
@@ -90,42 +82,10 @@
<!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />--> <!-- tools:ignore="DiscouragedApi,LockedOrientationActivity" />-->
<activity <activity
android:name=".ui.FoodSearchActivity" android:name=".ui.FoodSearchActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<!-- <activity android:name="com.shuwei.dish.match.ui.DeviceConfigActivity"--> <activity android:name="com.shuwei.dish.match.ui.DeviceConfigActivity" />
<!-- android:theme="@style/Theme.DishMatch.NoSplash" />--> <activity android:name="com.shuwei.dish.match.ui.SubmitFoodActivity" />
<activity android:name="com.shuwei.dish.match.ui.SettingActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity android:name="com.shuwei.dish.match.ui.SubmitFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash" />
<activity
android:name="com.shuwei.dish.match.ui.MasterScaleActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.SingleFragmentActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.FoodRecognizeActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.CollectedFoodActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<activity
android:name="com.shuwei.dish.match.ui.SlaveActivity"
android:theme="@style/Theme.DishMatch.NoSplash"
android:launchMode="singleTask"
android:screenOrientation="landscape"
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<receiver <receiver
android:name=".utils.BootReceiver" android:name=".utils.BootReceiver"
-272
View File
@@ -1,272 +0,0 @@
{
"class_names": [
"三黄鸡",
"上海青WP2026020200041",
"冰草WP2026020900054",
"刀豆",
"北豆腐WP2025111900007",
"味精WP2026013000034",
"圆生菜WP2026020900053",
"圆白菜[卷心菜,包菜]WP2025111000063",
"基围虾WP2025110700019",
"多宝鱼WP2025112600011",
"大白菜(代表值)WP2026022800638",
"大葱WP2026013000025",
"姜[黄姜]WP2026022800956",
"娃娃菜WP2026020200005",
"小白菜WP2026022800031",
"小米辣椒WP2026013000027",
"小葱WP2026013000029",
"山药(带皮)WP2026020200008",
"巨人笋",
"广东菜心WP2025120100014",
"木耳(干)[黑木耳,云耳]WP2026022801182",
"杏鲍菇WP2026022700030",
"松花蛋",
"松茸菌WP2025120100016",
"沙葱",
"海带(浸)[江白菜,昆布]WP2026022800035",
"牛腩WP2025110700043",
"猪五花肉WP2026020300048",
"猪腿肉WP2025110700040",
"玉米WP2025120100017",
"玉米糁(黄)WP2026022800038",
"番茄[西红柿]WP2026021000028",
"白吉饼WP2026013000024",
"笋",
"粉丝",
"紫甘蓝",
"红心萝卜[心里美]WP2026022800368",
"红提子WP2025111900006",
"红椒WP2026030300055",
"红洋葱WP2026030600054",
"红美人椒WP2026020300049",
"红薯",
"红薯粉条(袋)",
"羊排WP2025120300021",
"羊血WP2026013000038",
"胡萝卜WP2025110600008",
"芋头",
"花卷",
"芹菜WP2025111000065",
"苦菊",
"茄子(紫皮,长)WP2025110700024",
"茼蒿WP2026020200045",
"莲藕(带皮)",
"莴笋(鲜)[莴苣]WP2026020200009",
"菜花(白色)[花椰菜]",
"菠菜(鲜)[赤根菜]WP2026022800700",
"蒜WP2025111400003",
"蒜苗WP2026013000028",
"蒜薹(圆)",
"蒸鱼豉油WP2026013000032",
"薏米[薏仁米,苡米]WP2026022800037",
"西兰花[绿菜花]WP2025110700029",
"西葫芦",
"豆腐皮WP2025111000059",
"豆角",
"辣椒(红,小)WP2025111000066",
"辣椒(青,尖)WP2026022800504",
"醪糟",
"金针菇[(鲜)[智力菇]WP2026030600051",
"青线椒WP2026013000031",
"韭菜WP2026020200044",
"韭黄",
"馒头(代表值)WP2026030600056",
"香椿[香椿芽]WP2026022801086",
"香菇(鲜)[香蕈,冬菇]WP2026030600050",
"香菜WP2026013000030",
"马铃薯[土豆、洋芋]WP2026012900001",
"鸡全腿(带骨)",
"鸡翅(带骨)",
"鸡腿肉WP2025110700048",
"鸡腿(带骨)",
"鸡蛋WP2026013000039",
"麦芹",
"黄瓜(鲜)[胡瓜]WP2025110700031",
"黄豆芽WP2026020200042",
"黑袋子",
"黑鱼(整条鱼)WP2025110600012"
],
"class_to_idx": {
"三黄鸡": 0,
"上海青WP2026020200041": 1,
"冰草WP2026020900054": 2,
"刀豆": 3,
"北豆腐WP2025111900007": 4,
"味精WP2026013000034": 5,
"圆生菜WP2026020900053": 6,
"圆白菜[卷心菜,包菜]WP2025111000063": 7,
"基围虾WP2025110700019": 8,
"多宝鱼WP2025112600011": 9,
"大白菜(代表值)WP2026022800638": 10,
"大葱WP2026013000025": 11,
"姜[黄姜]WP2026022800956": 12,
"娃娃菜WP2026020200005": 13,
"小白菜WP2026022800031": 14,
"小米辣椒WP2026013000027": 15,
"小葱WP2026013000029": 16,
"山药(带皮)WP2026020200008": 17,
"巨人笋": 18,
"广东菜心WP2025120100014": 19,
"木耳(干)[黑木耳,云耳]WP2026022801182": 20,
"杏鲍菇WP2026022700030": 21,
"松花蛋": 22,
"松茸菌WP2025120100016": 23,
"沙葱": 24,
"海带(浸)[江白菜,昆布]WP2026022800035": 25,
"牛腩WP2025110700043": 26,
"猪五花肉WP2026020300048": 27,
"猪腿肉WP2025110700040": 28,
"玉米WP2025120100017": 29,
"玉米糁(黄)WP2026022800038": 30,
"番茄[西红柿]WP2026021000028": 31,
"白吉饼WP2026013000024": 32,
"笋": 33,
"粉丝": 34,
"紫甘蓝": 35,
"红心萝卜[心里美]WP2026022800368": 36,
"红提子WP2025111900006": 37,
"红椒WP2026030300055": 38,
"红洋葱WP2026030600054": 39,
"红美人椒WP2026020300049": 40,
"红薯": 41,
"红薯粉条(袋)": 42,
"羊排WP2025120300021": 43,
"羊血WP2026013000038": 44,
"胡萝卜WP2025110600008": 45,
"芋头": 46,
"花卷": 47,
"芹菜WP2025111000065": 48,
"苦菊": 49,
"茄子(紫皮,长)WP2025110700024": 50,
"茼蒿WP2026020200045": 51,
"莲藕(带皮)": 52,
"莴笋(鲜)[莴苣]WP2026020200009": 53,
"菜花(白色)[花椰菜]": 54,
"菠菜(鲜)[赤根菜]WP2026022800700": 55,
"蒜WP2025111400003": 56,
"蒜苗WP2026013000028": 57,
"蒜薹(圆)": 58,
"蒸鱼豉油WP2026013000032": 59,
"薏米[薏仁米,苡米]WP2026022800037": 60,
"西兰花[绿菜花]WP2025110700029": 61,
"西葫芦": 62,
"豆腐皮WP2025111000059": 63,
"豆角": 64,
"辣椒(红,小)WP2025111000066": 65,
"辣椒(青,尖)WP2026022800504": 66,
"醪糟": 67,
"金针菇[(鲜)[智力菇]WP2026030600051": 68,
"青线椒WP2026013000031": 69,
"韭菜WP2026020200044": 70,
"韭黄": 71,
"馒头(代表值)WP2026030600056": 72,
"香椿[香椿芽]WP2026022801086": 73,
"香菇(鲜)[香蕈,冬菇]WP2026030600050": 74,
"香菜WP2026013000030": 75,
"马铃薯[土豆、洋芋]WP2026012900001": 76,
"鸡全腿(带骨)": 77,
"鸡翅(带骨)": 78,
"鸡腿肉WP2025110700048": 79,
"鸡腿(带骨)": 80,
"鸡蛋WP2026013000039": 81,
"麦芹": 82,
"黄瓜(鲜)[胡瓜]WP2025110700031": 83,
"黄豆芽WP2026020200042": 84,
"黑袋子": 85,
"黑鱼(整条鱼)WP2025110600012": 86
},
"idx_to_class": {
"0": "三黄鸡",
"1": "上海青WP2026020200041",
"2": "冰草WP2026020900054",
"3": "刀豆",
"4": "北豆腐WP2025111900007",
"5": "味精WP2026013000034",
"6": "圆生菜WP2026020900053",
"7": "圆白菜[卷心菜,包菜]WP2025111000063",
"8": "基围虾WP2025110700019",
"9": "多宝鱼WP2025112600011",
"10": "大白菜(代表值)WP2026022800638",
"11": "大葱WP2026013000025",
"12": "姜[黄姜]WP2026022800956",
"13": "娃娃菜WP2026020200005",
"14": "小白菜WP2026022800031",
"15": "小米辣椒WP2026013000027",
"16": "小葱WP2026013000029",
"17": "山药(带皮)WP2026020200008",
"18": "巨人笋",
"19": "广东菜心WP2025120100014",
"20": "木耳(干)[黑木耳,云耳]WP2026022801182",
"21": "杏鲍菇WP2026022700030",
"22": "松花蛋",
"23": "松茸菌WP2025120100016",
"24": "沙葱",
"25": "海带(浸)[江白菜,昆布]WP2026022800035",
"26": "牛腩WP2025110700043",
"27": "猪五花肉WP2026020300048",
"28": "猪腿肉WP2025110700040",
"29": "玉米WP2025120100017",
"30": "玉米糁(黄)WP2026022800038",
"31": "番茄[西红柿]WP2026021000028",
"32": "白吉饼WP2026013000024",
"33": "笋",
"34": "粉丝",
"35": "紫甘蓝",
"36": "红心萝卜[心里美]WP2026022800368",
"37": "红提子WP2025111900006",
"38": "红椒WP2026030300055",
"39": "红洋葱WP2026030600054",
"40": "红美人椒WP2026020300049",
"41": "红薯",
"42": "红薯粉条(袋)",
"43": "羊排WP2025120300021",
"44": "羊血WP2026013000038",
"45": "胡萝卜WP2025110600008",
"46": "芋头",
"47": "花卷",
"48": "芹菜WP2025111000065",
"49": "苦菊",
"50": "茄子(紫皮,长)WP2025110700024",
"51": "茼蒿WP2026020200045",
"52": "莲藕(带皮)",
"53": "莴笋(鲜)[莴苣]WP2026020200009",
"54": "菜花(白色)[花椰菜]",
"55": "菠菜(鲜)[赤根菜]WP2026022800700",
"56": "蒜WP2025111400003",
"57": "蒜苗WP2026013000028",
"58": "蒜薹(圆)",
"59": "蒸鱼豉油WP2026013000032",
"60": "薏米[薏仁米,苡米]WP2026022800037",
"61": "西兰花[绿菜花]WP2025110700029",
"62": "西葫芦",
"63": "豆腐皮WP2025111000059",
"64": "豆角",
"65": "辣椒(红,小)WP2025111000066",
"66": "辣椒(青,尖)WP2026022800504",
"67": "醪糟",
"68": "金针菇[(鲜)[智力菇]WP2026030600051",
"69": "青线椒WP2026013000031",
"70": "韭菜WP2026020200044",
"71": "韭黄",
"72": "馒头(代表值)WP2026030600056",
"73": "香椿[香椿芽]WP2026022801086",
"74": "香菇(鲜)[香蕈,冬菇]WP2026030600050",
"75": "香菜WP2026013000030",
"76": "马铃薯[土豆、洋芋]WP2026012900001",
"77": "鸡全腿(带骨)",
"78": "鸡翅(带骨)",
"79": "鸡腿肉WP2025110700048",
"80": "鸡腿(带骨)",
"81": "鸡蛋WP2026013000039",
"82": "麦芹",
"83": "黄瓜(鲜)[胡瓜]WP2025110700031",
"84": "黄豆芽WP2026020200042",
"85": "黑袋子",
"86": "黑鱼(整条鱼)WP2025110600012"
},
"embedding_dim": 512,
"total_images": 2093,
"build_time": "2026-03-10T10:33:47.104848"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,31 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemCollectedFoodBinding
import com.shuwei.dish.match.entity.CollectedFoodItem
/**
* 已采集食材列表适配器
* 展示食材名称和已采集数量,支持删除操作
*/
class CollectedFoodAdapter(list: MutableList<CollectedFoodItem>) :
BaseQuickAdapter<CollectedFoodItem, CollectedFoodAdapter.VH>(list) {
/** ViewHolder,持有列表项 ViewBinding */
inner class VH(val binding: ListItemCollectedFoodBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemCollectedFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: CollectedFoodItem?) {
item ?: return
holder.binding.tvFoodName.text = item.foodName.split("WP").first()
holder.binding.tvCollectCount.text = "已采集${item.collectCount}"
}
}
@@ -8,25 +8,11 @@ import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemDishCookBinding import com.shuwei.dish.match.databinding.ListItemDishCookBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
import com.shuwei.dish.match.view.swipereveallayout.ViewBinderHelper
class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) : class DishPartAdapter(list: MutableList<GoodsItem>) :
BaseQuickAdapter<CookFoodGoodsEntity, DishPartAdapter.VH>(list) { BaseQuickAdapter<GoodsItem, DishPartAdapter.VH>(list) {
var onDeleteClick: ((position: Int) -> Unit)? = null
var onItemClick: ((position: Int) -> Unit)? = null
private val viewBinderHelper = ViewBinderHelper().apply {
setOpenOnlyOne(true)
}
fun closeAll() {
viewBinderHelper.closeAll()
}
inner class VH(var binding: ListItemDishCookBinding) : QuickViewHolder(binding.root) inner class VH(var binding: ListItemDishCookBinding) : QuickViewHolder(binding.root)
@@ -36,52 +22,30 @@ class DishPartAdapter(list: MutableList<CookFoodGoodsEntity>) :
return VH(binding) return VH(binding)
} }
override fun onBindViewHolder(holder: VH, position: Int, item: CookFoodGoodsEntity?) { override fun onBindViewHolder(holder: VH, position: Int, item: GoodsItem?) {
holder.binding.run { holder.binding.run {
val data = item ?: return tvDishName.text = item!!.goodsName
val itemId = data.goodsId ?: "${position}_${data.goodsName ?: ""}" tvDishType.text = if (item.materialType == 1) "主辅材:主材" else if (item.materialType == 2) "主辅材:辅材" else ""
viewBinderHelper.bind(swipeRevealLayout, itemId)
// 暂时禁用侧滑能力,后续需要放开时改回 unlockSwipe + layoutDelete.visible()
viewBinderHelper.lockSwipe(itemId)
swipeRevealLayout.close(false)
layoutDelete.gone()
layoutDelete.setOnClickListener {
onDeleteClick?.invoke(holder.bindingAdapterPosition)
}
clBlock.setOnClickListener {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
tvDishName.text = data.goodsName
tvDishType.text = if (data.materialType == 1) "主辅材:主材" else if (data.materialType == 2) "主辅材:辅材" else ""
tvDishWeight.text = tvDishWeight.text =
if (data.useWeight == null || data.useWeight == 0.toDouble()) "" else "${data.useWeight!!.roundedOneDecimalPlace()}" if (item.useWeight == null || item.useWeight == 0.toDouble()) "" else "${item.useWeight!!.roundedOneDecimalPlace()}"
tvDishWeight.setTextColor( tvDishWeight.setTextColor(
ContextCompat.getColor( ContextCompat.getColor(
context, context,
if (data.isSamplingPage) R.color.black999 else R.color.dish_green if (item.isSamplingPage) R.color.black999 else R.color.dish_green
) )
) )
ivOperateIcon.run {
if (data.isSamplingPage) gone() else visible()
}
ivClearIcon.visible()
ivClearIcon.setImageResource(
if (data.isOriginalData) R.drawable.ic_dish_clear
else R.drawable.ic_delete
)
ivOperateIcon.setImageResource( ivOperateIcon.setImageResource(
if (data.isSetFinished) R.drawable.ic_dish_selected if (item.isSamplingPage)
else R.drawable.ic_dish_unselected R.drawable.ic_delete
else
if (item.isSetFinished) R.drawable.ic_dish_selected
else R.drawable.ic_dish_unselected
)
root.setBackgroundResource(
if (item.isClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
) )
clBlock.run {
setBackgroundResource(
if (data.isItemClicked) R.drawable.shape_item_cook_dish
else R.drawable.shape_white_fb_15_corners
)
}
} }
} }
}
}
@@ -0,0 +1,62 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class DishShowAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, DishShowAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.toDouble()
tvDishName.text = item!!.foodName
if (isCooking) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else if (totalWeight > 0.toDouble()) {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.dish_green)
)
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text =
"累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format((item.foodWeight ?: 0.toDouble()) / 1000.0F)}kg"
} else {
tvDishName.setTextColor(
ContextCompat.getColor(holder.itemView.context, R.color.black999)
)
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -1,142 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemFoodListBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.view.swipereveallayout.ViewBinderHelper
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
/**
* 菜品列表通用 Adapter,合并了 DishShowAdapter 与 SamplingAdapter 的功能。
*
* @param list 数据列表
* @param mode 显示模式:
* - [DisplayMode.COOKING_MODE]:制作模式,支持空状态(虚线边框、隐藏统计行、灰色文字)
* - [DisplayMode.SAMPLING_MODE]:采样模式,始终显示统计行,重量为零时显示 `-`
*/
class FoodListAdapter(
list: MutableList<FoodRecord>,
private val mode: DisplayMode = DisplayMode.COOKING_MODE
) : BaseQuickAdapter<FoodRecord, FoodListAdapter.VH>(list) {
/** 显示模式枚举 */
enum class DisplayMode {
/** 制作模式(原 DishShowAdapter 逻辑) */
COOKING_MODE,
/** 采样模式(原 SamplingAdapter 逻辑) */
SAMPLING_MODE
}
/** 删除按钮点击回调,由外部(Activity/Fragment)设置 */
var onDeleteClick: ((position: Int) -> Unit)? = null
/**
* item 主体点击回调,绑定在 mainViewclBlock)上,绕过 SwipeRevealLayout 的触摸拦截。
* 外部使用此回调替代 setOnDebouncedItemClick。
*/
var onItemClick: ((position: Int) -> Unit)? = null
/**
* ViewBinderHelper 负责:
* - 同一时间只允许一个 item 展开(setOpenOnlyOne
* - RecyclerView 复用时恢复开合状态
*/
private val viewBinderHelper = ViewBinderHelper().apply {
setOpenOnlyOne(true)
}
/** 关闭所有已展开的侧滑 item,供外部在点击空白区域时调用 */
fun closeAll() {
viewBinderHelper.closeAll()
}
inner class VH(var binding: ListItemFoodListBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFoodListBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
/**
* - 烹饪中:黑色文字、实线背景、显示"烹饪中"
* - 有累计重量:绿色文字、实线背景、显示累计统计
* - 空状态:灰色文字、虚线背景、隐藏统计行
*/
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
val totalWeight = item.totalWeight ?: 0.0
val foodWeight = item.foodWeight ?: 0.0
// 用 foodId 作为唯一 key 恢复开合状态;不可滑动时锁定
val itemId = item.foodId ?: position.toString()
viewBinderHelper.bind(swipeRevealLayout, itemId)
if (isCooking) {
viewBinderHelper.unlockSwipe(itemId)
layoutDelete.visible()
} else {
viewBinderHelper.lockSwipe(itemId)
// 非烹饪中不允许侧滑,关闭可能的复用残留状态并隐藏删除按钮
swipeRevealLayout.close(false)
layoutDelete.gone()
}
// 绑定删除按钮回调
layoutDelete.setOnClickListener { onDeleteClick?.invoke(holder.bindingAdapterPosition) }
// 点击主内容区域:
// 1) 非烹饪中:先收起所有侧滑,再继续触发点击
// 2) 烹饪中:当前 item 若已展开则只收起,不触发点击
clBlock.setOnClickListener {
if (!isCooking) {
viewBinderHelper.closeAll()
onItemClick?.invoke(holder.bindingAdapterPosition)
} else if (!swipeRevealLayout.isClosed) {
viewBinderHelper.closeAll()
} else {
onItemClick?.invoke(holder.bindingAdapterPosition)
}
}
tvDishName.text = item.foodName
//烹饪中
if (isCooking) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
return@run
}
//非烹饪中,制作模式
if (mode == DisplayMode.COOKING_MODE) {
if (totalWeight > 0.0) {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.dish_green))
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
tvDishCount.visible()
tvDishCount.text = "累计统计:${df.format(totalWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format(foodWeight / 1000.0F)}kg"
} else {
tvDishName.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.black999))
clBlock.setBackgroundResource(R.drawable.shape_white_dash_15_corners)
tvDishCount.gone()
tvShowState.text = "-"
}
return@run
}
//非烹饪中,采样模式
val realTotalWeight = if (totalWeight > 0.0) totalWeight else foodWeight
val showTotalWeight = if (realTotalWeight == 0.0) "-" else df.format(realTotalWeight / 1000.0F)
tvDishCount.text = "累计统计:${showTotalWeight}kg(${item.count ?: "-"}次)"
val showFoodWeight = if (foodWeight == 0.0) "-" else df.format(foodWeight / 1000.0F)
tvShowState.text = "${showFoodWeight}kg"
}
}
private val df = DecimalFormat("0.000")
}
@@ -6,7 +6,7 @@ import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemDishRecordBinding import com.shuwei.dish.match.databinding.ListItemDishRecordBinding
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.model.FoodRecord
class FoodRecordAdapter(list: MutableList<FoodRecord>) : class FoodRecordAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, FoodRecordAdapter.VH>(list) { BaseQuickAdapter<FoodRecord, FoodRecordAdapter.VH>(list) {
@@ -1,43 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchFoodBinding
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class FoodSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) :
BaseQuickAdapter<CookFoodGoodsEntity, FoodSearchAdapter.VH>(list) {
override fun onBindViewHolder(
holder: VH,
position: Int,
item: CookFoodGoodsEntity?
) {
item ?: return
holder.binding.tvFoodName.run {
text = item.goodsName
isChecked = item.isClicked
}
}
override fun onCreateViewHolder(
context: Context,
parent: ViewGroup,
viewType: Int
): VH {
val binding = ListItemSearchFoodBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
inner class VH(var binding: ListItemSearchFoodBinding) : QuickViewHolder(binding.root)
}
@@ -8,42 +8,30 @@ import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.visible
class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>) : class GoodsInfoSearchAdapter(private var list: MutableList<GoodsItem>) :
BaseQuickAdapter<CookFoodGoodsEntity, GoodsInfoSearchAdapter.VH>(list) { BaseQuickAdapter<GoodsItem, GoodsInfoSearchAdapter.VH>(list) {
override fun onBindViewHolder( override fun onBindViewHolder(
holder: VH, holder: VH,
position: Int, position: Int,
item: CookFoodGoodsEntity? item: GoodsItem?
) { ) {
item ?: return
holder.binding.tvGoodsInfo.run { holder.binding.tvGoodsInfo.run {
text = item.goodsName text = item?.goodsName
setBackgroundResource( setBackgroundResource(
if (item.isClicked == true) R.drawable.shape_green_stroke if (item?.isClicked == true) R.drawable.shape_green_stroke
else R.drawable.shape_white_12_corners else R.drawable.shape_white_12_corners
) )
setTextColor( setTextColor(
ContextCompat.getColor( ContextCompat.getColor(
context, context,
if (item.isClicked == true) R.color.dish_green if (item?.isClicked == true) R.color.dish_green
else R.color.black666 else R.color.black666
) )
) )
} }
if (item.foodScore == -1){
holder.binding.tvFoodScore.gone()
} else {
holder.binding.tvFoodScore.let {
it.visible()
it.text = "${(item.foodScore / 100.0).roundedDecimalPlace(2)}%"
}
}
} }
override fun onCreateViewHolder( override fun onCreateViewHolder(
@@ -53,10 +41,9 @@ class GoodsInfoSearchAdapter(private var list: MutableList<CookFoodGoodsEntity>)
): VH { ): VH {
val binding = val binding =
ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false) ListItemSearchGoodsInfoBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding) return VH(binding)
} }
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root) inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
} }
@@ -8,7 +8,7 @@ import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemHomeModeBinding import com.shuwei.dish.match.databinding.ListItemHomeModeBinding
import com.shuwei.dish.match.entity.HomeModeBean import com.shuwei.dish.match.model.HomeModeBean
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
@@ -0,0 +1,54 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.databinding.ListItemDishBinding
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import java.text.DecimalFormat
class SamplingAdapter(list: MutableList<FoodRecord>) :
BaseQuickAdapter<FoodRecord, SamplingAdapter.VH>(list) {
inner class VH(var binding: ListItemDishBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemDishBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodRecord?) {
holder.binding.run {
val isCooking = item!!.isCooking
clBlock.setBackgroundResource(R.drawable.shape_white_fb_15_corners)
val totalWeight = item!!.totalWeight ?: 0.toDouble()
tvDishName.run {
text = item!!.foodName
setTextColor(
ContextCompat.getColor(
holder.itemView.context,
if (isCooking) R.color.black else R.color.dish_green
)
)
}
tvDishCount.visible()
if (isCooking) {
tvDishCount.text = "制作统计:-kg"
tvShowState.text = "烹饪中"
} else {
val useWeight = if (totalWeight > 0.toDouble()) totalWeight else (item.foodWeight ?: 0.toDouble())
tvDishCount.text = "累计统计:${df.format(useWeight / 1000.0F)}kg(${item.count}次)"
tvShowState.text = "${df.format((item.foodWeight ?: 0.toDouble()) / 1000.0F)}kg"
}
}
}
val df = DecimalFormat("0.000")
}
@@ -1,44 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ListItemScaleRowBinding
import com.shuwei.dish.match.utils.WeightUtil
/**
* 线性列表秤数据 Adapter
* 用于 SlaveActivity 非22/18格子设备的秤列表展示
*/
class ScaleRowAdapter : BaseQuickAdapter<ScaleRowAdapter.ScaleItem, ScaleRowAdapter.VH>() {
/**
* 秤数据条目
* @param address 秤地址
* @param weight 重量(克)
* @param state 状态码
* @param name 调料名称,未配置时为空
*/
data class ScaleItem(val address: Int, val weight: Double, val state: Int, val name: String = "")
inner class VH(val b: ListItemScaleRowBinding) : QuickViewHolder(b.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
return VH(ListItemScaleRowBinding.inflate(LayoutInflater.from(context), parent, false))
}
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleItem?) {
item ?: return
holder.b.tvScaleLabel.text = "${item.address}"
holder.b.tvName.text = item.name.ifEmpty { "-" }
holder.b.tvWeight.text = "${item.weight} g"
holder.b.tvState.text = when (item.state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> "${item.state}"
}
}
}
@@ -1,174 +0,0 @@
package com.shuwei.dish.match.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemScale22RowBinding
import com.shuwei.dish.match.scale.ScaleData
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
/**
* 18格网格 Adapter,同时服务于调料配置页和主设备秤总览页。
*
* @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState
* false = 秤总览模式,三行全部显示
* @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用
*/
class Seasoning18GridAdapter(
private val showNameOnly: Boolean = false,
private val showAddress: Boolean = false,
private val leftRightSpace: Int = 10.dp
) : BaseQuickAdapter<ScaleData, Seasoning18GridAdapter.VH>(mutableListOf()) {
/** 空位哨兵,address=0 表示无效 */
private val empty = ScaleData("", 0, 0.0, -1, 0)
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调 */
var onItemClick: ((ScaleData, Int) -> Unit)? = null
inner class VH(val b: ListItemScale22RowBinding) : QuickViewHolder(b.root)
/** 前6个位置为第1行(viewType=0margin大),其余为第2-3行(viewType=1margin小) */
override fun getItemViewType(position: Int, list: List<ScaleData>) =
if (position < 6) 0 else 1
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val b = ListItemScale22RowBinding.inflate(LayoutInflater.from(context), parent, false)
val margin = if (viewType == 0) 12.dp else 2.dp
val screenWidth = parent.resources.displayMetrics.widthPixels
val cellSize = (screenWidth - 6 * margin * 2 - leftRightSpace) / 6
b.root.updateLayoutParams<ViewGroup.MarginLayoutParams> {
width = cellSize
height = cellSize
if (viewType == 0) setMargins(margin, 0, margin, 2.dp)
else setMargins(margin, margin, margin, margin)
}
val baseSp = (cellSize / parent.resources.displayMetrics.density).toInt()
b.tvAddress.textSize = (baseSp * 0.14f).coerceIn(8f, 12f)
b.tvName.textSize = (baseSp * 0.18f).coerceIn(10f, 16f)
b.tvWeight.textSize = (baseSp * 0.22f).coerceIn(12f, 20f)
b.tvState.textSize = (baseSp * 0.14f).coerceIn(8f, 12f)
// 秤地址行:仅 MasterScaleActivity 显示
b.tvAddress.visibility = if (showAddress) View.VISIBLE else View.GONE
// 调料配置模式隐藏重量和状态行
if (showNameOnly) {
b.tvWeight.visibility = View.GONE
b.tvState.visibility = View.GONE
}
val vh = VH(b)
b.root.setOnClickListener {
val pos = vh.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.takeIf { it.address != 0 }?.let { item -> onItemClick?.invoke(item, pos) }
}
}
return vh
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) {
val valid = (item?.address ?: 0) != 0
if (!valid) {
holder.b.tvAddress.text = ""
holder.b.tvName.text = "-"
holder.b.tvWeight.text = ""
holder.b.tvState.text = ""
} else {
holder.b.tvAddress.text = "${item!!.address}"
holder.b.tvName.text = if (!item.name.isNullOrBlank()) item.name else "-"
holder.b.tvWeight.text = "${item.weight}g"
holder.b.tvState.text = stateStr(item.state)
}
if (position == highlightedPosition) {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
val white = ContextCompat.getColor(holder.b.root.context, R.color.white)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
*/
fun highlightPosition(position: Int) {
val prev = highlightedPosition
highlightedPosition = position
if (prev >= 0) notifyItemChanged(prev)
if (position >= 0) notifyItemChanged(position)
}
/**
* 清除所有高亮,恢复默认样式
*/
fun clearHighlight() {
highlightPosition(-1)
}
/**
* 更新指定位置的名称并刷新对应格子
*/
fun updateItemName(position: Int, name: String) {
items.getOrNull(position)?.let {
set(position, it.copy(name = name))
}
}
/**
* 按 SCALE_ORDER_18 的 sort 顺序更新数据,空位用哨兵填充
* 只更新显示字段有变化的格子,避免全量刷新导致 ViewHolder 重建影响点击监听
*/
fun update(list: List<ScaleData>) {
val byAddress = list.associateBy { it.address }
val filled = ScaleDeviceConfig.SCALE_ORDER_18.map { addr ->
byAddress[addr] ?: empty.copy(address = addr)
}
if (items.isEmpty()) {
submitList(filled)
} else {
filled.forEachIndexed { i, scale ->
val cur = items.getOrNull(i)
if (cur == null || cur.address != scale.address || cur.weight != scale.weight || cur.state != scale.state || cur.name != scale.name) {
set(i, scale)
}
}
}
}
/**
* 将 ScaleData 列表按 SCALE_ORDER_18 重排后调用 update
*/
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -1,180 +0,0 @@
package com.shuwei.dish.match.adapter
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemScale22RowBinding
import com.shuwei.dish.match.scale.ScaleData
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.utils.SizeTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.dp
/**
* 22格网格 Adapter,同时服务于调料配置页和主设备秤总览页。
*
* @param largeSize 前4个大格子的宽度(px)
* @param smallSize 其余小格子的宽度(px)
* @param cellAspectRatio 格子高宽比(height / width
* @param showNameOnly true = 调料配置模式,仅显示 tvName,隐藏 tvWeight/tvState
* false = 秤总览模式,三行全部显示
* @param showAddress true = 显示秤地址行(tvAddress),仅 MasterScaleActivity 使用
*/
class Seasoning22GridAdapter(
private val largeSize: Int,
private val smallSize: Int,
private val cellAspectRatio: Float = SizeTool.DEFAULT_CELL_ASPECT_RATIO,
private val showNameOnly: Boolean = false,
private val showAddress: Boolean = false
) : BaseQuickAdapter<ScaleData, Seasoning22GridAdapter.VH>(mutableListOf()) {
/** 空位哨兵,address=0 表示无效 */
private val empty = ScaleData("", 0, 0.0, -1, 0)
/** 当前高亮的格子位置,-1 表示无高亮 */
private var highlightedPosition: Int = -1
/** 点击回调 */
var onItemClick: ((ScaleData, Int) -> Unit)? = null
inner class VH(val b: ListItemScale22RowBinding) : QuickViewHolder(b.root)
/** 前4个位置为大格子(viewType=1),其余为小格子(viewType=0 */
override fun getItemViewType(position: Int, list: List<ScaleData>) =
if (position < 4) 1 else 0
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val b = ListItemScale22RowBinding.inflate(LayoutInflater.from(context), parent, false)
val cellWidth = if (viewType == 1) largeSize else smallSize
val cellHeight = (cellWidth * cellAspectRatio).toInt()
b.root.updateLayoutParams<FlexboxLayoutManager.LayoutParams> {
width = cellWidth
height = cellHeight
setMargins(2.dp, 2.dp, 2.dp, 2.dp)
}
val baseSp = (cellWidth / parent.resources.displayMetrics.density).toInt()
b.tvAddress.textSize = (baseSp * 0.14f).coerceIn(8f, 12f)
b.tvName.textSize = (baseSp * 0.18f).coerceIn(10f, 16f)
b.tvWeight.textSize = (baseSp * 0.22f).coerceIn(12f, 20f)
b.tvState.textSize = (baseSp * 0.14f).coerceIn(8f, 12f)
// 秤地址行:仅 MasterScaleActivity 显示
b.tvAddress.visibility = if (showAddress) View.VISIBLE else View.GONE
// 调料配置模式隐藏重量和状态行
if (showNameOnly) {
b.tvWeight.visibility = View.GONE
b.tvState.visibility = View.GONE
}
val vh = VH(b)
b.root.setOnClickListener {
val pos = vh.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
items.getOrNull(pos)?.takeIf { it.address != 0 }?.let { item -> onItemClick?.invoke(item, pos) }
}
}
return vh
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: VH, position: Int, item: ScaleData?) {
val valid = (item?.address ?: 0) != 0
if (!valid) {
holder.b.tvAddress.text = ""
holder.b.tvName.text = "-"
holder.b.tvWeight.text = ""
holder.b.tvState.text = ""
} else {
holder.b.tvAddress.text = "${item!!.address}"
holder.b.tvName.text = if (!item.name.isNullOrBlank()) item.name else "-"
holder.b.tvWeight.text = "${item.weight}g"
holder.b.tvState.text = stateStr(item.state)
}
// 高亮格子:红底白字;其余恢复默认
if (position == highlightedPosition) {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell_highlight)
val white = ContextCompat.getColor(holder.b.root.context, R.color.white)
holder.b.tvAddress.setTextColor(white)
holder.b.tvName.setTextColor(white)
holder.b.tvWeight.setTextColor(white)
holder.b.tvState.setTextColor(white)
} else {
holder.b.root.setBackgroundResource(R.drawable.shape_scale_cell)
val normal = ContextCompat.getColor(holder.b.root.context, R.color.home_title)
val sub = ContextCompat.getColor(holder.b.root.context, R.color.home_sub_title)
val black = ContextCompat.getColor(holder.b.root.context, R.color.black)
holder.b.tvAddress.setTextColor(sub)
holder.b.tvName.setTextColor(black)
holder.b.tvWeight.setTextColor(normal)
holder.b.tvState.setTextColor(sub)
}
}
/**
* 高亮指定位置的格子(红底白字),同时清除上一个高亮
*/
fun highlightPosition(position: Int) {
val prev = highlightedPosition
highlightedPosition = position
if (prev >= 0) notifyItemChanged(prev)
if (position >= 0) notifyItemChanged(position)
}
/**
* 清除所有高亮,恢复默认样式
*/
fun clearHighlight() {
highlightPosition(-1)
}
/**
* 更新指定位置的名称并刷新对应格子
*/
fun updateItemName(position: Int, name: String) {
items.getOrNull(position)?.let {
set(position, it.copy(name = name))
}
}
/**
* 按 SCALE_ORDER_22 的 sort 顺序更新数据,空位用哨兵填充
* 只更新显示字段有变化的格子,避免全量刷新导致 ViewHolder 重建影响点击监听
*/
fun update(list: List<ScaleData>) {
val byAddress = list.associateBy { it.address }
val filled = ScaleDeviceConfig.SCALE_ORDER_22.map { addr ->
byAddress[addr] ?: empty.copy(address = addr)
}
if (items.isEmpty()) {
submitList(filled)
} else {
filled.forEachIndexed { i, scale ->
val cur = items.getOrNull(i)
if (cur == null || cur.address != scale.address || cur.weight != scale.weight || cur.state != scale.state || cur.name != scale.name) {
set(i, scale)
}
}
}
}
/**
* 将 ScaleData 列表按 SCALE_ORDER_22 重排后调用 update
*/
fun updateByAddress(scales: List<ScaleData>) {
update(scales)
}
private fun stateStr(state: Int) = when (state) {
WeightUtil.STATE_STABLE -> "稳定"
WeightUtil.STATE_UNSTABLE -> "不稳定"
WeightUtil.STATE_OVER_WEIGHT -> "超量"
else -> ""
}
}
@@ -0,0 +1,38 @@
//package com.shuwei.dish.match.adapter
//
//import android.content.Context
//import android.view.LayoutInflater
//import android.view.ViewGroup
//import androidx.core.view.updateLayoutParams
//import com.chad.library.adapter4.BaseQuickAdapter
//import com.chad.library.adapter4.viewholder.QuickViewHolder
//import com.shuwei.dish.match.databinding.ListItemCellBinding
//import com.shuwei.dish.match.model.GoodsItem
//import com.shuwei.dish.match.utils.ext.dp
//
///**
// * 调料格子列表适配器,使用 list_item_cell 布局展示调料名称与用量
// */
//class SeasoningAdapter(list: MutableList<GoodsItem>) :
// BaseQuickAdapter<GoodsItem, SeasoningAdapter.VH>(list) {
//
// override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
// val binding = ListItemCellBinding.inflate(LayoutInflater.from(context), parent, false)
// return VH(binding)
// }
//
// override fun onBindViewHolder(holder: VH, position: Int, item: GoodsItem?) {
// item ?: return
// holder.binding.root.updateLayoutParams<ViewGroup.MarginLayoutParams> {
// width = 226.dp
// height = 120.dp
// leftMargin = 7.dp
// rightMargin = 8.dp
// topMargin = 7.dp
// bottomMargin = 8.dp
// }
// holder.binding.tvCell.text = TextCellAdapter.getTextSpan(item.toSeasoningEntity())
// }
//
// inner class VH(val binding: ListItemCellBinding) : QuickViewHolder(binding.root)
//}
@@ -0,0 +1,134 @@
//package com.shuwei.dish.match.adapter
//
//import android.annotation.SuppressLint
//import android.widget.TextView
//import androidx.recyclerview.widget.GridLayoutManager
//import androidx.recyclerview.widget.RecyclerView
//import com.shuwei.dish.match.base.BaseActivity
//import com.shuwei.dish.match.dialog.BottomDialog2
//import com.shuwei.dish.match.db.entity.SeasoningEntity
//import com.shuwei.dish.match.ui.DeviceSettingActivity
//import com.shuwei.dish.match.ui.SubmitDishActivity
//import com.shuwei.dish.match.utils.DataUtil
//
//object SeasoningCellTool {
// private val TAG = "SeasoningCellTool"
//
// private val list = mutableListOf<SeasoningEntity>()
// private lateinit var activity: BaseActivity
// private var pageType: Int = 0
//
// @SuppressLint("StaticFieldLeak")
// private lateinit var cellAdapter: TextCellAdapter
//
// @SuppressLint("NotifyDataSetChanged")
// fun loadData(
// activity: BaseActivity,
// initList: MutableList<SeasoningEntity>? = null,
// recyclerView: RecyclerView,
// tvCell: TextView,
// pageType: Int = 0
// ) {
// this.activity = activity
// this.pageType = pageType
// list.clear()
// val tempList = buildData(initList, pageType)
// list.addAll(tempList)
// recyclerView.run {
// if (adapter == null) {
// layoutManager = GridLayoutManager(
// activity,
// 3,
// GridLayoutManager.VERTICAL,
// false
// )
// cellAdapter = TextCellAdapter(list).apply {
// onCellClick = { position ->
// val currentAddress = DataUtil.getWeighAddressArray().get(position)
// BottomDialog2(
// weighIndex = position,
// weighAddress = currentAddress,
// clickName = list[position].goodsName
// ).show(activity) { item ->
// list[position].let { current ->
// syncItem(current, item)
// //3-调料
// current.materialType = 3
// current.sort = position
// }
// notifyItemChanged(position)
// if (position == 14) {
// syncItem(list[10], list[14])
// syncItem(list[11], list[14])
// syncItem(list[13], list[14])
// TextCellAdapter.loadCell(tvCell, list[14])
// notifyItemChanged(10)
// notifyItemChanged(11)
// notifyItemChanged(13)
// notifyItemChanged(14)
// }
// if (activity is DeviceSettingActivity) {
// //activity.list = list
// activity.saveSeasoning(list[position])
// }
// }
// }
// }
// adapter = cellAdapter
// } else {
// cellAdapter.notifyDataSetChanged()
// }
// if (activity is SubmitDishActivity) {
// activity.updateSeasoningList(list)
// }
// }
// if (pageType == 1) {
// tvCell.setOnClickListener {
// cellAdapter.onCellClick?.invoke(14)
// }
// }
// TextCellAdapter.loadCell(tvCell, list[list.size - 1])
// }
//
// private fun buildData(
// initList: MutableList<SeasoningEntity>?,
// pageType: Int = 0
// ): MutableList<SeasoningEntity> {
// val list = mutableListOf<SeasoningEntity>()
// repeat(15) {
// list.add(SeasoningEntity().apply { this.pageType = pageType })
// }
// if (initList.isNullOrEmpty().not()) {
// initList.forEach {
// if (it.sort < list.size) {
// list[it.sort] = it.apply { this.pageType = pageType }
// }
// }
// var lastItem = initList.firstOrNull { it.sort == 14 }
// lastItem = lastItem ?: list[list.size - 1]
// list[10] = lastItem
// list[11] = lastItem
// list[13] = lastItem
// }
// return list
// }
//
// private fun syncItem(item: SeasoningEntity, newItem: SeasoningEntity) {
// item.run {
// goodsId = newItem.goodsId
// goodsName = newItem.goodsName
// goodsOrRelationCode = newItem.goodsOrRelationCode
// relateionType = newItem.relateionType
// materialType = newItem.materialType
// allEdible = newItem.allEdible
// useWeight = newItem.useWeight
// popularName = newItem.popularName
// canteenId = newItem.canteenId
// relateionType_dictText = newItem.relateionType_dictText
//
// foodId = newItem.foodId
// sort = newItem.sort
// }
// }
//
//}
@@ -8,15 +8,15 @@ import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding import com.shuwei.dish.match.databinding.ListItemSearchGoodsInfoBinding
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.model.GoodsItem
class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) : class SeasoningSearchAdapter(private var list: MutableList<GoodsItem>) :
BaseQuickAdapter<SeasoningEntity, SeasoningSearchAdapter.VH>(list) { BaseQuickAdapter<GoodsItem, SeasoningSearchAdapter.VH>(list) {
override fun onBindViewHolder( override fun onBindViewHolder(
holder: VH, holder: VH,
position: Int, position: Int,
item: SeasoningEntity? item: GoodsItem?
) { ) {
holder.binding.tvGoodsInfo.run { holder.binding.tvGoodsInfo.run {
text = item?.goodsName text = item?.goodsName
@@ -46,4 +46,4 @@ class SeasoningSearchAdapter(private var list: MutableList<SeasoningEntity>) :
inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root) inner class VH(var binding: ListItemSearchGoodsInfoBinding) : QuickViewHolder(binding.root)
} }
@@ -1,60 +0,0 @@
package com.shuwei.dish.match.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.databinding.ItemSeasoningWeightBinding
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
/**
* 调料使用重量列表 Adapter
* 用于 SubmitFoodActivity 展示各调料槽位的实时用量
* 同名调料已在外部聚合,adapter 按 goodsName 作为唯一标识
*/
class SeasoningWeightAdapter : BaseQuickAdapter<SeasoningWeightAdapter.Item, SeasoningWeightAdapter.VH>() {
/** 调料展示数据项,key = goodsName(同名调料已聚合) */
data class Item(
val goodsId: String,
val goodsName: String,
/** 使用重量(克),同名调料已累加 */
val useWeight: Double
)
/**
* 更新单个 item,按 goodsName 匹配;不存在则追加
*/
fun updateItem(item: Item) {
val idx = items.indexOfFirst { it.goodsName == item.goodsName }
if (idx >= 0) {
set(idx, item)
} else {
add(item)
}
}
/**
* 移除指定 goodsName 的 item(聚合后用量不足阈值时调用)
*/
fun removeItem(goodsName: String) {
val idx = items.indexOfFirst { it.goodsName == goodsName }
if (idx >= 0) removeAt(idx)
}
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ItemSeasoningWeightBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: Item?) {
item ?: return
holder.binding.tvSeasoningName.text = item.goodsName
val showWeight = "${item.useWeight.roundedDecimalPlace(1)}g"
holder.binding.tvSeasoningWeight.text = showWeight
}
inner class VH(var binding: ItemSeasoningWeightBinding) : QuickViewHolder(binding.root)
}
@@ -1,136 +1,137 @@
package com.shuwei.dish.match.adapter //package com.shuwei.dish.match.adapter
//
import android.graphics.Typeface //import android.graphics.Typeface
import android.icu.text.DecimalFormat //import android.icu.text.DecimalFormat
import android.text.SpannableStringBuilder //import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan //import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan //import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan //import android.text.style.LineHeightSpan
import android.text.style.StyleSpan //import android.text.style.StyleSpan
import android.view.LayoutInflater //import android.view.LayoutInflater
import android.view.ViewGroup //import android.view.ViewGroup
import android.widget.FrameLayout //import android.widget.FrameLayout
import android.widget.TextView //import android.widget.TextView
import androidx.core.content.ContextCompat //import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt //import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.RecyclerView //import androidx.recyclerview.widget.RecyclerView
import com.chad.library.adapter4.viewholder.QuickViewHolder //import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.dish.match.R //import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemCellBinding //import com.shuwei.dish.match.databinding.ListItemCellBinding
import com.shuwei.dish.match.entity.SeasoningEntity //import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.utils.ext.appendText //import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.ext.buildSpannableString //import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.clickWithDebounce //import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp //import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.invisible //import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace //import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.visible //import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
//import com.shuwei.dish.match.utils.ext.visible
class TextCellAdapter(var list: MutableList<SeasoningEntity>) : //
RecyclerView.Adapter<TextCellAdapter.VH>() { //class TextCellAdapter(var list: MutableList<SeasoningEntity>) :
// RecyclerView.Adapter<TextCellAdapter.VH>() {
inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root) //
// inner class VH(var binding: ListItemCellBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { //
val inflater = LayoutInflater.from(parent.context) // override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val binding = ListItemCellBinding.inflate(inflater, parent, false) // val inflater = LayoutInflater.from(parent.context)
return VH(binding) // val binding = ListItemCellBinding.inflate(inflater, parent, false)
} // return VH(binding)
// }
override fun onBindViewHolder(holder: VH, position: Int) { //
val item = list[position] // override fun onBindViewHolder(holder: VH, position: Int) {
holder.binding.run { // val item = list[position]
if (position == 10 || position == 11 || position == 13 || position == 14) { // holder.binding.run {
root.invisible() // if (position == 10 || position == 11 || position == 13 || position == 14) {
} else { // root.invisible()
root.visible()
}
loadCell(tvCell, item)
//root.setBackgroundResource(R.color.dish_green)
(root.layoutParams as RecyclerView.LayoutParams).let {
// it.bottomMargin = 15.dp
it.width = 226.dp
it.height = 120.dp
// it.rightMargin = 0
// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
it.leftMargin = 7.dp
it.rightMargin = 8.dp
it.topMargin = 7.dp
it.bottomMargin = 8.dp
}
if (item.pageType == 1) {
root.clickWithDebounce { onCellClick?.invoke(position) }
}
}
}
override fun getItemCount() = list.size
var onCellClick: ((positon: Int) -> Unit)? = null
companion object {
fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
val child: TextView = frameLayout.getChildAt(0) as TextView
child.text = getTextSpan(item)
}
fun loadCell(tvCell: TextView, item: SeasoningEntity) {
tvCell.text = getTextSpan(item)
}
// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
// if (item.goodsName.isNullOrBlank()) {
// tvCell.run {
// text = buildSpannableString {
// appendText(
// "未配置",
// ForegroundColorSpan("#999999".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(28, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )}
// //setTextColor(ContextCompat.getColor(context, R.color.black999))
// }
// } else { // } else {
//// if (item.pageType == 1) { // root.visible()
//// tvCell.run { // }
//// text = item.goodsName // loadCell(tvCell, item)
//// setTextColor(ContextCompat.getColor(context, R.color.black)) // //root.setBackgroundResource(R.color.dish_green)
//// } // (root.layoutParams as RecyclerView.LayoutParams).let {
//// } else { //// it.bottomMargin = 15.dp
// tvCell.text = getTextSpan(item) // it.width = 226.dp
//// } // it.height = 120.dp
//// it.rightMargin = 0
//// it.leftMargin = if (position % 3 == 0) 0 else 15.dp
//
// it.leftMargin = 7.dp
// it.rightMargin = 8.dp
// it.topMargin = 7.dp
// it.bottomMargin = 8.dp
// }
// if (item.pageType == 1) {
// root.clickWithDebounce { onCellClick?.invoke(position) }
// } // }
// } // }
// }
fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder { //
val endIndex = if (item.sort == 9) 10 else 6 // override fun getItemCount() = list.size
var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!! //
if (name.length > endIndex) { // var onCellClick: ((positon: Int) -> Unit)? = null
name = "${name.substring(0, endIndex)}" //
} // companion object {
return buildSpannableString { // fun loadLayout(frameLayout: FrameLayout, item: SeasoningEntity) {
appendText( // val child: TextView = frameLayout.getChildAt(0) as TextView
name, // child.text = getTextSpan(item)
ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()), // }
StyleSpan(Typeface.BOLD), // fun loadCell(tvCell: TextView, item: SeasoningEntity) {
AbsoluteSizeSpan(28, true), // tvCell.text = getTextSpan(item)
LineHeightSpan { text, start, end, spanstartv, v, fm -> // }
fm.descent += 10.dp // 增加行间距 //// fun loadCell2(tvCell: TextView, item: SeasoningEntity) {
} //// if (item.goodsName.isNullOrBlank()) {
) //// tvCell.run {
append("\n") //// text = buildSpannableString {
val weight = item.useWeight ?: 0.toDouble() //// appendText(
val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71" //// "未配置",
appendText( //// ForegroundColorSpan("#999999".toColorInt()),
// "${DecimalFormat("#").format(weight)}g", //// StyleSpan(Typeface.BOLD),
"${weight.roundedOneDecimalPlace()}g", //// AbsoluteSizeSpan(28, true),
ForegroundColorSpan(weightColor.toColorInt()), //// LineHeightSpan { text, start, end, spanstartv, v, fm ->
AbsoluteSizeSpan(30, true) //// fm.descent += 10.dp // 增加行间距
) //// }
} //// )}
} //// //setTextColor(ContextCompat.getColor(context, R.color.black999))
} //// }
//// } else {
} ////// if (item.pageType == 1) {
////// tvCell.run {
////// text = item.goodsName
////// setTextColor(ContextCompat.getColor(context, R.color.black))
////// }
////// } else {
//// tvCell.text = getTextSpan(item)
////// }
//// }
//// }
//
// fun getTextSpan(item: SeasoningEntity): SpannableStringBuilder {
// val endIndex = if (item.sort == 9) 10 else 6
// var name = if (item.goodsName.isNullOrBlank()) "未配置" else item.goodsName!!
// if (name.length > endIndex) {
// name = "${name.substring(0, endIndex)}…"
// }
// return buildSpannableString {
// appendText(
// name,
// ForegroundColorSpan(if (item.goodsName.isNullOrBlank()) "#999999".toColorInt() else "#000000".toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(28, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )
// append("\n")
// val weight = item.useWeight ?: 0.toDouble()
// val weightColor = if (weight == 0.toDouble()) "#999999" else "#00BC71"
// appendText(
//// "${DecimalFormat("#").format(weight)}g",
// "${weight.roundedOneDecimalPlace()}g",
// ForegroundColorSpan(weightColor.toColorInt()),
// AbsoluteSizeSpan(30, true)
// )
// }
// }
// }
//
//}
@@ -1,58 +0,0 @@
package com.shuwei.dish.match.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.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ListItemVectorCollectionBinding
import com.shuwei.dish.match.entity.FoodCollectionBean
import com.shuwei.dish.match.utils.ext.load
class VectorCollectionAdapter (var list: MutableList<FoodCollectionBean>) :
BaseQuickAdapter<FoodCollectionBean, VectorCollectionAdapter.VH>(list) {
inner class VH(var binding: ListItemVectorCollectionBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemVectorCollectionBinding.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_camera_default)
} else {
//scaleType = ImageView.ScaleType.FIT_CENTER
scaleType = ImageView.ScaleType.CENTER_CROP
//setImageURI(it.imageUri)
//setImageBitmap(it.bitmap)
if (it.imageUri!=null) {
setImageURI(it.imageUri)
} else {
load(it.imageFile)
}
// it.bitmap?.let { bitmap ->
// GlideUtils.loadRoundCornerWitBitmap(
// context,
// url = bitmap,
// imageView = this,
// radius = 8.dp
// )
// }
}
}
}
}
}
@@ -1,86 +1,108 @@
package com.shuwei.dish.match.base package com.shuwei.dish.match.base
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.databinding.ActivityBaseBinding import com.shuwei.dish.match.databinding.ActivityBaseBinding
import com.shuwei.dish.match.db.DbViewModel
import com.shuwei.dish.match.dialog.Loading import com.shuwei.dish.match.dialog.Loading
import com.shuwei.dish.match.net.NetViewModel import com.shuwei.dish.match.net.NetViewModel
import com.shuwei.dish.match.db.DbViewModel import com.shuwei.dish.match.viewmodel.AppViewModel
import com.shuwei.dish.match.ui.HomeActivity.Companion.TAG
import com.shuwei.dish.match.ui.InitActivity import com.shuwei.dish.match.ui.InitActivity
import com.shuwei.dish.match.utils.ActivityManager import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.invisible import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.put
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlin.getValue
@Suppress("DEPRECATION")
open class BaseActivity : AppCompatActivity() { open class BaseActivity : AppCompatActivity() {
companion object { val netViewModel: NetViewModel by viewModels()
/** 日期时间格式化器,复用避免每秒重复创建 */ val dbViewModel: DbViewModel by viewModels()
private val DATE_FORMATTER = val appViewModel: AppViewModel by viewModels()
DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
}
private lateinit var binding: ActivityBaseBinding private lateinit var binding: ActivityBaseBinding
private var launcher: ActivityResultLauncher<Intent>? = null
private var launchCallback: ((Intent?) -> Unit)? = null
public fun launch(cls: Class<*>, launchCallback: (Intent?) -> Unit) {
this.launchCallback = launchCallback
launcher?.launch(Intent(this, cls))
}
public fun launch(intent: Intent, launchCallback: (Intent?) -> Unit) {
this.launchCallback = launchCallback
launcher?.launch(intent)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 提前隐藏系统栏,避免窗口创建时导航栏闪烁
hideSystemBars()
// 主设备竖屏,子设备横屏
requestedOrientation = if (GlobalData.deviceRole == DeviceRole.MASTER)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
ActivityManager.addActivity(this) ActivityManager.addActivity(this)
binding = ActivityBaseBinding.inflate(layoutInflater) binding = ActivityBaseBinding.inflate(layoutInflater)
if (this !is InitActivity || GlobalData.deviceRole == DeviceRole.SLAVE) { if (this !is InitActivity) {
binding.root.setBackgroundColor(ContextCompat.getColor(this, R.color.bg_color)) binding.root.setBackgroundColor(ContextCompat.getColor(this, R.color.bg_color))
} }
setContentView(binding.root) setContentView(binding.root)
statusBarDarkFont(enable = false) statusBarDarkFont(enable = false)
// 主设备垂直 padding 30dp,子设备 15dp launcher =
val verticalPadding = if (GlobalData.deviceRole == DeviceRole.MASTER) 30.dp else 10.dp registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
binding.tvLeftTime.setPadding(0, verticalPadding, 0, verticalPadding) if (result.resultCode == RESULT_OK) {
binding.tvRightTime.setPadding(0, verticalPadding, 0, verticalPadding) launchCallback?.invoke(result.data)
}
}
// window.setDecorFitsSystemWindows(false) // 启用 Edge-to-Edge
// window.insetsController?.apply {
// hide(WindowInsets.Type.statusBars()) // 隐藏状态栏
// systemBarsBehavior =
// WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // 滑动时临时显示
// }
binding.llTitleBar.gone() binding.llTitleBar.gone()
binding.ivBack.setOnClickListener { finish() } binding.ivBack.setOnClickListener { finish() }
} }
private var bgState = true
fun useBackground(enable: Boolean) {
bgState = enable
}
override fun onDestroy() { override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
dismissLoading() dismissLoading()
super.onDestroy() super.onDestroy()
ActivityManager.removeActivity(this) ActivityManager.removeActivity(this)
} }
fun hideStatusBar() { fun hideStatusBar() {
hideSystemBars() enableEdgeToEdge()
// val uiOptions = (View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
// or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) // 隐藏导航栏(可选)
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN
} }
fun statusBarDarkFont(enable: Boolean) { fun statusBarDarkFont(enable: Boolean) {
val color = ContextCompat.getColor( //window.decorView.systemUiVisibility = if (enable) View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else 0
this, if (enable) R.color.black else R.color.white val color = ContextCompat.getColor(this,
if (enable) R.color.black else R.color.white
) )
binding.tvLeftTime.setTextColor(color) binding.tvLeftTime.setTextColor(color)
binding.tvRightTime.setTextColor(color) binding.tvRightTime.setTextColor(color)
@@ -92,7 +114,6 @@ open class BaseActivity : AppCompatActivity() {
titleAction: ((TextView) -> Unit)? = null, titleAction: ((TextView) -> Unit)? = null,
rightIconActon: ((ImageView) -> Unit)? = null rightIconActon: ((ImageView) -> Unit)? = null
) { ) {
binding.llTitleBar.visible()
titleBarAction?.invoke(binding.llTitleBar) titleBarAction?.invoke(binding.llTitleBar)
backAction?.invoke(binding.ivBack) backAction?.invoke(binding.ivBack)
titleAction?.invoke(binding.tvTitle) titleAction?.invoke(binding.tvTitle)
@@ -105,15 +126,12 @@ open class BaseActivity : AppCompatActivity() {
} }
} }
fun setHeaderBackground(isHomePage: Boolean = false) { public fun setHeaderBackground(isHomePage: Boolean = false) {
binding.ivHeaderBg.setImageResource( binding.ivHeaderBg.setImageResource(
if (isHomePage) R.drawable.bg_home_page else R.drawable.bg_other_page if (isHomePage) R.drawable.bg_home_page else R.drawable.bg_other_page
) )
} }
/** 返回 Fragment 容器的 View ID,供子类加载 Fragment 使用 */
protected fun getFragmentContainerId(): Int = R.id.flContainer
override fun setContentView(view: View?) { override fun setContentView(view: View?) {
if (view == binding.root) { if (view == binding.root) {
super.setContentView(view) super.setContentView(view)
@@ -123,7 +141,8 @@ open class BaseActivity : AppCompatActivity() {
} }
private fun updateDateTime() { private fun updateDateTime() {
val dateTime = DATE_FORMATTER.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))) val formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 EEEE***HH:mm:ss", Locale.CHINA)
val dateTime = formatter.format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")))
val arr = dateTime.split("***") val arr = dateTime.split("***")
binding.tvRightTime.text = arr[0] binding.tvRightTime.text = arr[0]
binding.tvLeftTime.text = arr[1] binding.tvLeftTime.text = arr[1]
@@ -148,120 +167,20 @@ open class BaseActivity : AppCompatActivity() {
handler.removeCallbacks(updateTask) handler.removeCallbacks(updateTask)
} }
fun showLoading(msg: String = "加载中……") { fun showLoading() {
handler.post { Loading.show(this)
Loading.show(
context = this,
message = msg
)
}
} }
fun dismissLoading() { fun dismissLoading() {
handler.post { Loading.dismiss()
Loading.dismiss()
}
} }
fun delayDismissLoading() { fun delayDismissLoading() {
handler.postDelayed({ window.decorView.postDelayed({
Loading.dismiss() Loading.dismiss()
}, 500) },500)
}
/**
* 在主线程延迟执行任务,供子类使用
* @param delayMs 延迟毫秒数
* @param block 待执行的任务
*/
protected fun postDelayed(delayMs: Long, block: () -> Unit) {
handler.postDelayed(block, delayMs)
} }
val netViewModel: NetViewModel by viewModels()
val appViewModel: DbViewModel by viewModels()
private var singlePermissionCallback: ((isGranted: Boolean) -> Unit)? = null
private var multiplePermissionsCallback: ((isGranted: Boolean) -> Unit)? = null
private var activityCallback: ((intent: Intent?) -> Unit)? = null
fun requestMultiplePermissions(
permissions: Array<String>,
callback: (isGranted: Boolean) -> Unit
) {
this.multiplePermissionsCallback = callback
requestMultiplePermissionsLauncher.launch(permissions)
}
fun requestPermission(permission: String, callback: (isGranted: Boolean) -> Unit) {
this.singlePermissionCallback = callback
requestPermissionLauncher.launch(permission)
}
private val requestMultiplePermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
var isGranted = true
permissions.entries.forEach {
if (!it.value) {
isGranted = false
}
}
multiplePermissionsCallback?.invoke(isGranted)
}
// 权限请求回调
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
singlePermissionCallback?.invoke(isGranted)
}
fun startActivity(intent: Intent, callback: (Intent?) -> Unit) {
this.activityCallback = callback
startActivityLauncher.launch(intent)
}
// activity页面返回的回调
private val startActivityLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
activityCallback?.invoke(it.data)
}
/**
* 窗口焦点变化时回调
* 场景:弹出 Dialog 后焦点丢失,Dialog 消失后焦点恢复,此时需重新隐藏系统栏,
* 避免系统栏在 Dialog 关闭后意外"复活"
*/
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemBars()
}
}
/**
* 隐藏状态栏和导航栏
* 使用 WindowInsetsControllerCompatAndroidx 推荐方式,向下兼容性好)
* - setDecorFitsSystemWindows 只在首次调用时设置,避免反复触发布局重绘
* (重绘会导致 RecyclerView 滑动时抛出 "view is not a child, cannot hide" 异常)
* - 每次调用仍重新执行 hide(),保证系统栏在滑出后能被重新隐藏
*/
private var systemBarsConfigured = false
private fun hideSystemBars() {
if (!systemBarsConfigured) {
// 只执行一次:允许布局延伸到系统栏区域
WindowCompat.setDecorFitsSystemWindows(window, false)
systemBarsConfigured = true
}
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
} }
@@ -5,66 +5,30 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.db.DatabaseProvider import com.shuwei.dish.match.db.DatabaseProvider
import com.shuwei.dish.match.objbox.ObjectBox
import com.shuwei.dish.match.scale.ScaleDeviceConfig
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.BootReceiver import com.shuwei.dish.match.utils.BootReceiver
import com.shuwei.dish.match.utils.CrashHandler
import com.shuwei.dish.match.utils.Weigher2
/**
* BaseApp is the main application class that extends Android's Application class.
* It handles initialization of core components, device role determination,
* and service management for the weighing system.
*/
class BaseApp : Application() { class BaseApp : Application() {
// Lazy initialization of the database instance
val database by lazy { DatabaseProvider(this).instance } val database by lazy { DatabaseProvider(this).instance }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Set the application instance
instance = this instance = this
// Initialize crash handler for error tracking
CrashHandler.init(this)
// Get and store the unique device ID
val deviceId = AppUtil.getUDID(this)
Log.d(TAG, "onCreate: deviceId=$deviceId")
GlobalData.deviceId = deviceId
// 根据设备 ID 判断角色:指定 ID 为主设备,其余为子设备
// 主设备是/dev/ttyS7,子设备是/dev/ttyS4
if (deviceId == ScaleDeviceConfig.DEVICE_ID_2) {
GlobalData.deviceRole = DeviceRole.MASTER
Weigher2.setDevicePort("/dev/ttyS7")
} else {
GlobalData.deviceRole = DeviceRole.SLAVE
Weigher2.setDevicePort("/dev/ttyS4")
}
Log.d(TAG, "设备角色: ${GlobalData.deviceRole}")
// 启动秤服务(主设备多启动发现+聚合,子设备只启动服务端+注册)
ScaleServiceManager.start(this)
// GlobalData.deviceId = "39a7abdd06b3c7ab"
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED) val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter) registerReceiver(BootReceiver(), filter)
ObjectBox.init(this)
} }
companion object { companion object {
private val TAG = "BaseApp"
// const val canteenId = "1678234139391512577"
var canteenId = "0"
// var configUrl = ""
// var token: String? = null // const val canteenId = "1678234139391512577"
// var deviceId: String? = null var canteenId = "0"
var configUrl = ""
var token: String? = null
var appVersion: String = "1" var appVersion: String = "1"
@Volatile @Volatile
private var sharedPref: SharedPreferences? = null private var sharedPref: SharedPreferences? = null
@@ -18,16 +18,8 @@ abstract class BaseFragment<VB : ViewBinding> : Fragment() {
abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
abstract fun initialize()
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initialize()
}
} }
@@ -0,0 +1,10 @@
package com.shuwei.dish.match.base
class BaseReq<T> {
var code: Int? = null
var success: Boolean? = null
var data: T? = null
var message: String? = null
var result: T? = null
var msg: String? = null
}
@@ -14,8 +14,12 @@ object GlobalData {
* 具体业务 BaseUrl * 具体业务 BaseUrl
*/ */
const val TEST_BASE_URL = "http://192.168.1.201:14801" const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081" const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8083"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443" const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
//临时用于测试
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4"
/** /**
* 设备id * 设备id
*/ */
@@ -25,8 +29,6 @@ object GlobalData {
* 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE * 当前设备角色,启动时从 SpTool 读取,默认为 SLAVE
*/ */
var deviceRole: DeviceRole = DeviceRole.SLAVE var deviceRole: DeviceRole = DeviceRole.SLAVE
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
} }
/** /**
@@ -37,4 +39,5 @@ object GlobalKey {
const val KEY_TOKEN = "tokenKey" const val KEY_TOKEN = "tokenKey"
const val KEY_USER_INFO = "userInfoKey" const val KEY_USER_INFO = "userInfoKey"
const val KEY_USER_NAME = "userNameKey" const val KEY_USER_NAME = "userNameKey"
} const val KEY_BASE_URL = "baseUrl"
}
@@ -10,10 +10,10 @@ import com.shuwei.dish.match.db.dao.CookFoodDao
import com.shuwei.dish.match.db.dao.CookFoodGoodsDao import com.shuwei.dish.match.db.dao.CookFoodGoodsDao
import com.shuwei.dish.match.db.dao.SeasoningDao import com.shuwei.dish.match.db.dao.SeasoningDao
import com.shuwei.dish.match.db.dao.SeasoningSlotDao import com.shuwei.dish.match.db.dao.SeasoningSlotDao
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
// 步骤1:更新版本号 // 步骤1:更新版本号
@@ -25,7 +25,7 @@ import com.shuwei.dish.match.entity.SeasoningSlotEntity
CookFoodGoodsEntity::class, CookFoodGoodsEntity::class,
SeasoningSlotEntity::class, SeasoningSlotEntity::class,
], ],
version = 8, version = 13,
exportSchema = true exportSchema = true
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -214,6 +214,48 @@ val MIGRATION_6_7 = MigrationImpl(6, 7) {db ->
} }
} }
/**
* 版本8→9:为 dm_cook_food_goods 和 dm_seasoning 表新增 zjmCode、materId 字段
*/
val MIGRATION_8_9 = MigrationImpl(8, 9) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN materId TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN zjmCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN materId TEXT DEFAULT ''")
}
/**
* 版本9→10:删除 dm_cook_food 表 foodId 唯一索引
* 改为由应用层保证同一 foodId 且 isDel=0 只有一条记录,以支持逻辑删除
*/
val MIGRATION_9_10 = MigrationImpl(9, 10) { db ->
db.execSQL("DROP INDEX IF EXISTS dm_cook_food_foodId_IDX")
}
/**
* 版本10→11:为 dm_cook_food_goods 和 dm_seasoning 表新增 goodsCode(物料编码)字段
*/
val MIGRATION_10_11 = MigrationImpl(10, 11) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN goodsCode TEXT DEFAULT ''")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN goodsCode TEXT DEFAULT ''")
}
/**
* 版本11→12:为 dm_cook_food_goods 和 dm_seasoning 表新增 rawMaterialsType(净材种类)字段
*/
val MIGRATION_11_12 = MigrationImpl(11, 12) { db ->
db.execSQL("ALTER TABLE dm_cook_food_goods ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE dm_seasoning ADD COLUMN rawMaterialsType TEXT DEFAULT NULL")
}
/**
* 版本12→13:为 dm_cook_food 表新增 submitState(接口提交状态)字段
* 0-未提交(本地草稿/手动删除),1-接口提交成功后删除
*/
val MIGRATION_12_13 = MigrationImpl(12, 13) { db ->
db.execSQL("ALTER TABLE dm_cook_food ADD COLUMN submitState INTEGER NOT NULL DEFAULT 0")
}
class DatabaseProvider(private val context: Context) { class DatabaseProvider(private val context: Context) {
// 单例模式确保全局唯一实例‌ // 单例模式确保全局唯一实例‌
val instance: AppDatabase by lazy { val instance: AppDatabase by lazy {
@@ -233,6 +275,11 @@ class DatabaseProvider(private val context: Context) {
// .addMigrations(MIGRATION_5_6) // .addMigrations(MIGRATION_5_6)
// .addMigrations(MIGRATION_6_7) // .addMigrations(MIGRATION_6_7)
.addMigrations(MIGRATION_7_8) .addMigrations(MIGRATION_7_8)
.addMigrations(MIGRATION_8_9)
.addMigrations(MIGRATION_9_10)
.addMigrations(MIGRATION_10_11)
.addMigrations(MIGRATION_11_12)
.addMigrations(MIGRATION_12_13)
.build() .build()
} }
} }
@@ -1,10 +1,11 @@
package com.shuwei.dish.match.db package com.shuwei.dish.match.db
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -21,8 +22,8 @@ class DbRepository {
db.cookFoodDao().updateCookFood(item) db.cookFoodDao().updateCookFood(item)
} }
suspend fun getCookFoodById(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) { suspend fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode) db.cookFoodDao().getCookFoodById(BaseApp.canteenId, foodId, cookMode, dinnerType)
} }
suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) { suspend fun getCookFoodList(cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
@@ -33,13 +34,23 @@ class DbRepository {
db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode) db.cookFoodDao().countCookFood(BaseApp.canteenId, cookMode)
} }
suspend fun deleteCookFood(foodId: String, cookMode: Int) = withContext(Dispatchers.IO) { suspend fun deleteCookFood(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode) db.cookFoodDao().deleteCookFood(BaseApp.canteenId, foodId, cookMode, dinnerType)
}
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 */
suspend fun markSubmittedAndDelete(foodId: String, cookMode: Int, dinnerType: String) = withContext(Dispatchers.IO) {
db.cookFoodDao().markSubmittedAndDelete(BaseApp.canteenId, foodId, cookMode, dinnerType)
} }
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) { suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId) db.cookFoodGoodsDao().deleteCookFoodGoodsList(pid, foodId)
} }
/** 仅逻辑删除调料行(materialType = 3),主辅材不动 */
suspend fun deleteSeasoningOnly(pid: Long, foodId: String) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteSeasoningOnly(pid, foodId)
}
//-------------------------------------------------------------------------------------------- //--------------------------------------------------------------------------------------------
suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) = suspend fun insertGoodsList(items: MutableList<CookFoodGoodsEntity>) =
@@ -67,7 +78,7 @@ class DbRepository {
db.seasoningDao().getSeasoningBySort(sort) db.seasoningDao().getSeasoningBySort(sort)
} }
suspend fun getSeasoningByGoodsId(goodsId: Int) = withContext(Dispatchers.IO) { suspend fun getSeasoningByGoodsId(goodsId: String) = withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningByGoodsId(goodsId) db.seasoningDao().getSeasoningByGoodsId(goodsId)
} }
@@ -100,6 +111,20 @@ class DbRepository {
db.seasoningDao().insertSeasoning(item) db.seasoningDao().insertSeasoning(item)
} }
/**
* 按 goodsId 查重后 upsert:存在则更新,不存在则插入
* 确保同一个 goodsId 在 dm_seasoning 中只有一条记录
*/
suspend fun upsertSeasoningByGoodsId(item: SeasoningEntity) = withContext(Dispatchers.IO) {
val existing = db.seasoningDao().getSeasoningByGoodsId(item.goodsId)
if (existing != null) {
db.seasoningDao().updateSeasoning(item.also { it.id = existing.id })
} else {
item.id = 0
db.seasoningDao().insertSeasoning(item)
}
}
/** /**
* 查询指定设备是否已配置调料槽位 * 查询指定设备是否已配置调料槽位
* @param deviceId 目标设备 ID * @param deviceId 目标设备 ID
@@ -154,6 +179,105 @@ class DbRepository {
suspend fun deleteAllSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) { suspend fun deleteAllSlotsByDeviceId(deviceId: String) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().deleteAllByDeviceId(deviceId) db.seasoningSlotDao().deleteAllByDeviceId(deviceId)
} }
// ---- 调试用:查询各表全量数据(含已删除记录)----
/** 查询 dm_cook_food 全部记录 */
suspend fun getAllCookFood() = withContext(Dispatchers.IO) {
db.cookFoodDao().getAllCookFood()
}
/** 查询 dm_cook_food_goods 全部记录 */
suspend fun getAllCookFoodGoods() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getAllCookFoodGoods()
}
/** 查询 dm_seasoning 全部记录 */
suspend fun getAllSeasoning() = withContext(Dispatchers.IO) {
db.seasoningDao().getAllSeasoning()
}
// ---- 调试用:分页查询(含 isDel 过滤)----
suspend fun getCookFoodPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodDao().getCookFoodPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodDao().countCookFoodAll(if (showDel) 1 else 0)
}
suspend fun getCookFoodGoodsPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().getCookFoodGoodsPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countCookFoodGoodsAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countCookFoodGoodsAll(if (showDel) 1 else 0)
}
suspend fun getSeasoningPaged(showDel: Boolean, limit: Int, offset: Int) =
withContext(Dispatchers.IO) {
db.seasoningDao().getSeasoningPaged(if (showDel) 1 else 0, limit, offset)
}
suspend fun countSeasoningAll(showDel: Boolean) = withContext(Dispatchers.IO) {
db.seasoningDao().countSeasoningAll(if (showDel) 1 else 0)
}
suspend fun getSlotPaged(limit: Int, offset: Int) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().getSlotPaged(limit, offset)
}
suspend fun countSlotAll() = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countSlotAll()
}
// ---- 调试用:动态条件过滤分页查询 ----
suspend fun getCookFoodPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().queryPagedFiltered(query)
}
suspend fun countCookFoodFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodDao().countFiltered(query)
}
suspend fun getCookFoodGoodsPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().queryPagedFiltered(query)
}
suspend fun countCookFoodGoodsFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().countFiltered(query)
}
suspend fun getSeasoningPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().queryPagedFiltered(query)
}
suspend fun countSeasoningFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningDao().countFiltered(query)
}
suspend fun getSlotPagedFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().queryPagedFiltered(query)
}
suspend fun countSlotFiltered(query: SupportSQLiteQuery) = withContext(Dispatchers.IO) {
db.seasoningSlotDao().countFiltered(query)
}
/**
* 物理清空全部 4 张表的测试数据
* 清除顺序:先删子表(食材),再删主表(菜品),最后删配置表(调料、槽位)
*/
suspend fun clearAllData() = withContext(Dispatchers.IO) {
db.cookFoodGoodsDao().deleteAll()
db.cookFoodDao().deleteAll()
db.seasoningDao().deleteAll()
db.seasoningSlotDao().deleteAll()
}
} }
// //
//class SeasoningRepository(val seasoningDao: SeasoningDao) { //class SeasoningRepository(val seasoningDao: SeasoningDao) {
@@ -2,12 +2,12 @@ package com.shuwei.dish.match.db
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.entity.CollectedFoodItem import androidx.sqlite.db.SimpleSQLiteQuery
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.model.CollectedFoodItem
import com.shuwei.dish.match.objbox.ObjectBox import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.db.entity.SeasoningEntity
import com.shuwei.dish.match.entity.SeasoningSlotEntity import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -28,9 +28,9 @@ class DbViewModel : ViewModel() {
private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null) private val _cookFoodDetailState = MutableStateFlow<CookFoodEntity?>(null)
val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow() val cookFoodDetailState: StateFlow<CookFoodEntity?> = _cookFoodDetailState.asStateFlow()
fun getCookFoodById(foodId: String, cookMode: Int) { fun getCookFoodById(foodId: String, cookMode: Int, dinnerType: String) {
viewModelScope.launch { viewModelScope.launch {
_cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode) _cookFoodDetailState.value = rep.getCookFoodById(foodId, cookMode, dinnerType)
} }
} }
@@ -48,6 +48,30 @@ class DbViewModel : ViewModel() {
} }
} }
/**
* 查询本地烹饪中数据(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/
fun getCookFoodListWithCallback(
cookMode: Int,
dinnerType: String = "0",
onResult: (MutableList<CookFoodEntity>?) -> Unit
) {
viewModelScope.launch {
onResult(rep.getCookFoodList(cookMode, dinnerType))
}
}
/**
* 直接返回本地烹饪中数据,供需要串行等待结果的场景使用(如先查库再发网络请求)
*/
suspend fun getCookFoodListDirect(
cookMode: Int,
dinnerType: String = "0"
): MutableList<CookFoodEntity>? {
return rep.getCookFoodList(cookMode, dinnerType)
}
suspend fun countCookFood(cookMode: Int): Int { suspend fun countCookFood(cookMode: Int): Int {
return rep.countCookFood(cookMode) return rep.countCookFood(cookMode)
} }
@@ -68,28 +92,62 @@ class DbViewModel : ViewModel() {
entity: CookFoodEntity, entity: CookFoodEntity,
list: MutableList<CookFoodGoodsEntity>? list: MutableList<CookFoodGoodsEntity>?
) { ) {
val data = rep.getCookFoodById(entity.foodId, cookMode) val data = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
if (data != null) { if (data == null) {
// 存在数据先删除 // 首次保存:主表 + 主辅材 + 调料 全量 insert
rep.deleteCookFood(entity.foodId, cookMode) rep.insertCookFood(entity)
rep.deleteCookFoodGoodsList(data.id, entity.foodId) val newData = rep.getCookFoodById(entity.foodId, cookMode, entity.dinnerType ?: "0")
} // 回填 id,防止下次调用时 getCookFoodById 查不到而重复插入主辅材
// 重新保存数据 newData?.let { entity.id = it.id }
rep.insertCookFood(entity) list?.let {
val newData = rep.getCookFoodById(entity.foodId, cookMode) it.forEach { goods ->
list?.let { it -> goods.id = 0
it.forEach { goods -> goods.pid = newData?.id ?: 0
goods.id = 0 }
goods.pid = newData?.id ?: 0 rep.insertGoodsList(it)
}
} else {
// 已存在:主表与主辅材保持不变,仅重建调料部分,避免主表/主辅材累积逻辑删除脏数据
rep.deleteSeasoningOnly(data.id, entity.foodId)
val seasoningList = list?.filter { it.materialType == 3 }?.toMutableList()
if (!seasoningList.isNullOrEmpty()) {
seasoningList.forEach { goods ->
goods.id = 0
goods.pid = data.id
goods.foodId = entity.foodId
}
rep.insertGoodsList(seasoningList)
} }
rep.insertGoodsList(it)
} }
} }
suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String) { suspend fun deleteCookFoodAndGoods(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode) val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) { if (data != null) {
rep.deleteCookFood(foodId = foodId, cookMode = cookMode) rep.deleteCookFood(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
}
}
/**
* 接口提交成功后删除本地菜品草稿(异步版本,绑定 viewModelScope
* 与 [markSubmittedAndDelete] 的区别:不是 suspend,内部用 viewModelScope 启动协程,
* 不受调用方 Activity 生命周期影响,适合 singleTask 跳转前触发删除的场景
*/
fun markSubmittedAndDeleteAsync(cookMode: Int, foodId: String, dinnerType: String) {
viewModelScope.launch {
markSubmittedAndDelete(cookMode, foodId, dinnerType)
}
}
/**
* 接口提交成功后删除本地菜品草稿
* 与 [deleteCookFoodAndGoods] 的区别:主表 submitState 会被置 1,便于审计区分手动删除与接口删除
*/
suspend fun markSubmittedAndDelete(cookMode: Int, foodId: String, dinnerType: String) {
val data = rep.getCookFoodById(foodId, cookMode, dinnerType)
if (data != null) {
rep.markSubmittedAndDelete(foodId = foodId, cookMode = cookMode, dinnerType = dinnerType)
rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId) rep.deleteCookFoodGoodsList(pid = data.id, foodId = foodId)
} }
} }
@@ -98,9 +156,10 @@ class DbViewModel : ViewModel() {
suspend fun getCookFoodGoodsList( suspend fun getCookFoodGoodsList(
foodId: String, foodId: String,
cookMode: Int cookMode: Int,
dinnerType: String
): MutableList<CookFoodGoodsEntity> { ): MutableList<CookFoodGoodsEntity> {
val cookFood = rep.getCookFoodById(foodId, cookMode) val cookFood = rep.getCookFoodById(foodId, cookMode, dinnerType)
return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId) return if (cookFood != null) rep.getCookFoodGoodsList(pid = cookFood.id, foodId = cookFood.foodId)
else mutableListOf() else mutableListOf()
} }
@@ -237,7 +296,240 @@ class DbViewModel : ViewModel() {
suspend fun deleteAllSlotsByDeviceId(deviceId: String) = suspend fun deleteAllSlotsByDeviceId(deviceId: String) =
rep.deleteAllSlotsByDeviceId(deviceId) rep.deleteAllSlotsByDeviceId(deviceId)
// fun updateAll(list: MutableList<SeasoningEntity>) { // ---- 数据库调试查看 ----
/**
* 物理清空全部 4 张表的测试数据,完成后通过回调通知 UI
* @param onDone 清除完成后在主线程执行的回调
*/
fun clearAllData(onDone: () -> Unit) {
viewModelScope.launch {
rep.clearAllData()
onDone()
}
}
companion object {
/** 每页加载条数 */
const val DB_INSPECT_PAGE_SIZE = 50
}
/**
* 单条数据库记录的展示模型
* @param isDel 是否已逻辑删除
* @param fields 字段名-值对列表,按顺序排列
* @param columnsPerRow 每行显示的列数,默认 3;槽位表传 1 保持单列
*/
data class DbRecord(
val isDel: Boolean,
val fields: List<Pair<String, String>>,
val columnsPerRow: Int = 3
)
/**
* 数据库调试页面 UI 状态
* @param items 当前已加载的记录列表
* @param totalCount 当前过滤条件下的总记录数
* @param hasMore 是否还有更多数据可加载
* @param isLoading 是否正在加载(首次加载或加载更多)
* @param filter 当前生效的筛选条件,供"加载更多"复用
*/
data class DbInspectUiState(
val items: List<DbRecord> = emptyList(),
val totalCount: Int = 0,
val hasMore: Boolean = false,
val isLoading: Boolean = false,
val filter: DbInspectFilter = DbInspectFilter()
)
/**
* 数据库调试页面筛选条件
* @param showDel 是否包含已删除记录
* @param exactFields 精确匹配字段:列名 → 值
* @param likeFields 模糊匹配字段:列名 → 值(自动加 %value%)
*/
data class DbInspectFilter(
val showDel: Boolean = true,
val exactFields: Map<String, String> = emptyMap(),
val likeFields: Map<String, String> = emptyMap()
)
private val _dbInspectState = MutableStateFlow(DbInspectUiState())
val dbInspectState: StateFlow<DbInspectUiState> = _dbInspectState.asStateFlow()
/**
* 加载指定表的第一页数据(切换 Tab / 修改筛选条件时调用)
* @param tableIndex 0=菜品 1=食材 2=调料 3=槽位
* @param filter 筛选条件,包含 showDel 和各字段过滤值
*/
fun loadDbInspect(tableIndex: Int, filter: DbInspectFilter) {
viewModelScope.launch {
// 立即清空旧数据并显示加载状态,避免切换 Tab 时旧数据闪现
_dbInspectState.value = DbInspectUiState(isLoading = true, filter = filter)
val (items, total) = queryPage(tableIndex, filter, offset = 0)
_dbInspectState.value = DbInspectUiState(
items = items,
totalCount = total,
hasMore = items.size < total,
isLoading = false,
filter = filter
)
}
}
/**
* 追加加载下一页数据(点击"加载更多"时调用)
* 筛选条件从当前 state 中复用,无需重新传入
* @param tableIndex 0=菜品 1=食材 2=调料 3=槽位
*/
fun loadMoreDbInspect(tableIndex: Int) {
viewModelScope.launch {
val current = _dbInspectState.value
// 已在加载中或无更多数据时直接返回,防止重复请求
if (!current.hasMore || current.isLoading) return@launch
_dbInspectState.value = current.copy(isLoading = true)
val (newItems, total) = queryPage(tableIndex, current.filter, offset = current.items.size)
val merged = current.items + newItems
_dbInspectState.value = DbInspectUiState(
items = merged,
totalCount = total,
hasMore = merged.size < total,
isLoading = false,
filter = current.filter
)
}
}
/**
* 执行分页查询,返回 DbRecord 列表和总记录数
* 通过 buildQueries 将 filter 转换为动态 SQL
*/
private suspend fun queryPage(
tableIndex: Int,
filter: DbInspectFilter,
offset: Int
): Pair<List<DbRecord>, Int> {
val limit = DB_INSPECT_PAGE_SIZE
return when (tableIndex) {
0 -> {
val (pageQ, countQ) = buildQueries("dm_cook_food", "createTime DESC", true, filter, limit, offset)
val total = rep.countCookFoodFiltered(countQ)
val rows = rep.getCookFoodPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "foodId" to "${e.foodId}", "foodName" to "${e.foodName}",
"canteenId" to "${e.canteenId}", "foodWeight" to "${e.foodWeight}", "cookMode" to "${e.cookMode}",
"dinnerType" to "${e.dinnerType}", "isDel" to "${e.isDel}", "submitState" to "${e.submitState}",
"createTime" to "${e.createTime}"
)
)
}
rows to total
}
1 -> {
val (pageQ, countQ) = buildQueries("dm_cook_food_goods", "createTime DESC", true, filter, limit, offset)
val total = rep.countCookFoodGoodsFiltered(countQ)
val rows = rep.getCookFoodGoodsPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "pid" to "${e.pid}", "goodsId" to "${e.goodsId}",
"goodsName" to "${e.goodsName}", "foodId" to "${e.foodId}", "materialType" to "${e.materialType}",
"relateionType" to "${e.relateionType}", "allEdible" to "${e.allEdible}", "useWeight" to "${e.useWeight}",
"sort" to "${e.sort}", "zjmCode" to "${e.zjmCode}", "materId" to "${e.materId}",
"isDel" to "${e.isDel}", "createTime" to "${e.createTime}"
)
)
}
rows to total
}
2 -> {
val (pageQ, countQ) = buildQueries("dm_seasoning", "sort ASC", true, filter, limit, offset)
val total = rep.countSeasoningFiltered(countQ)
val rows = rep.getSeasoningPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = e.isDel == 1,
fields = listOf(
"id" to "${e.id}", "goodsId" to "${e.goodsId}", "goodsName" to "${e.goodsName}",
"sort" to "${e.sort}", "materialType" to "${e.materialType}", "useWeight" to "${e.useWeight}",
"zjmCode" to "${e.zjmCode}", "materId" to "${e.materId}", "isDel" to "${e.isDel}",
"createTime" to "${e.createTime}"
)
)
}
rows to total
}
3 -> {
val (pageQ, countQ) = buildQueries("dm_seasoning_slot", "deviceId ASC, address ASC", false, filter, limit, offset)
val total = rep.countSlotFiltered(countQ)
val rows = rep.getSlotPagedFiltered(pageQ).map { e ->
DbRecord(
isDel = false,
fields = listOf(
"deviceId" to e.deviceId, "address" to "${e.address}",
"goodsId" to e.goodsId, "goodsName" to e.goodsName
),
columnsPerRow = 1
)
}
rows to total
}
else -> emptyList<DbRecord>() to 0
}
}
/**
* 根据筛选条件动态构建分页查询和计数查询的 SQL
* @param tableName 表名
* @param orderBy ORDER BY 子句(不含关键字)
* @param hasIsDel 是否有 isDel 字段,无则忽略 showDel 参数
* @param filter 筛选条件
* @param limit 分页大小
* @param offset 偏移量
* @return Pair<分页查询, 计数查询>
*/
private fun buildQueries(
tableName: String,
orderBy: String,
hasIsDel: Boolean,
filter: DbInspectFilter,
limit: Int,
offset: Int
): Pair<SimpleSQLiteQuery, SimpleSQLiteQuery> {
val conditions = mutableListOf<String>()
val args = mutableListOf<Any>()
if (hasIsDel && !filter.showDel) {
conditions.add("isDel = 0")
}
filter.exactFields.forEach { (col, value) ->
if (value.isNotBlank()) {
conditions.add("$col = ?")
args.add(value)
}
}
filter.likeFields.forEach { (col, value) ->
if (value.isNotBlank()) {
conditions.add("$col LIKE ?")
args.add("%$value%")
}
}
val where = if (conditions.isEmpty()) "" else "WHERE ${conditions.joinToString(" AND ")}"
val pageQuery = SimpleSQLiteQuery(
"SELECT * FROM $tableName $where ORDER BY $orderBy LIMIT ? OFFSET ?",
(args + limit + offset).toTypedArray()
)
val countQuery = SimpleSQLiteQuery(
"SELECT COUNT(*) FROM $tableName $where",
args.toTypedArray()
)
return pageQuery to countQuery
}
//--------------------------------------------------------------------------------------------------------
// viewModelScope.launch { // viewModelScope.launch {
// list.forEach { // list.forEach {
// rep.updateSeasoning(it) // rep.updateSeasoning(it)
@@ -266,6 +558,20 @@ class DbViewModel : ViewModel() {
} }
} }
/**
* 按 goodsId 查重后 upsert 调料数据,确保同一 goodsId 只有一条记录
*/
suspend fun upsertSeasoningByGoodsId(entity: SeasoningEntity) {
rep.upsertSeasoningByGoodsId(entity)
}
/**
* 按 goodsId 查询调料详情
*/
suspend fun getSeasoningByGoodsId(goodsId: String): SeasoningEntity? {
return rep.getSeasoningByGoodsId(goodsId)
}
//-------------------------------------------------------------------------------------------------------- //--------------------------------------------------------------------------------------------------------
/** /**
@@ -275,31 +581,43 @@ class DbViewModel : ViewModel() {
private val _collectedFoodListState = MutableStateFlow<List<CollectedFoodItem>>(emptyList()) private val _collectedFoodListState = MutableStateFlow<List<CollectedFoodItem>>(emptyList())
val collectedFoodListState: StateFlow<List<CollectedFoodItem>> = _collectedFoodListState.asStateFlow() val collectedFoodListState: StateFlow<List<CollectedFoodItem>> = _collectedFoodListState.asStateFlow()
/** // /**
* 加载已采集食材列表,按名称分组统计数量 // * 加载已采集食材列表,按名称分组统计数量
* 使用属性查询只读 name 字段,不加载 foodVector,节省内存 // * 使用属性查询只读 name 字段,不加载 foodVector,节省内存
* @param nameFilter 名称过滤关键词,为空时加载全部 // * @param nameFilter 名称过滤关键词,为空时加载全部
*/ // */
fun loadCollectedFoodList(nameFilter: String? = null) { // fun loadCollectedFoodList(nameFilter: String? = null) {
viewModelScope.launch { // viewModelScope.launch {
val result = ObjectBox.queryFoodNameCounts(nameFilter) // val result = ObjectBox.queryFoodNameCounts(nameFilter)
_collectedFoodListState.value = result.map { (name, count) -> // _collectedFoodListState.value = result.map { (name, count) ->
CollectedFoodItem(foodName = name, collectCount = count) // CollectedFoodItem(foodName = name, collectCount = count)
} // }
} // }
} // }
/** // /**
* 删除指定名称的所有采集记录,删除完成后刷新列表 // * 删除指定名称的所有采集记录,删除完成后刷新列表
* @param foodName 食材名称 // * @param foodName 食材名称
* @param nameFilter 当前搜索关键词,用于删除后刷新列表 // * @param nameFilter 当前搜索关键词,用于删除后刷新列表
*/ // */
fun removeCollectedFood(foodName: String, nameFilter: String? = null) { // fun removeCollectedFood(foodName: String, nameFilter: String? = null) {
viewModelScope.launch { // viewModelScope.launch {
ObjectBox.remove(foodName) // ObjectBox.remove(foodName)
loadCollectedFoodList(nameFilter) // loadCollectedFoodList(nameFilter)
} // }
} // }
// /**
// * 清除所有向量采集数据,完成后刷新列表
// * @param onDone 清除完成后在主线程执行的回调
// */
// fun clearAllCollectedFood(onDone: (() -> Unit)? = null) {
// viewModelScope.launch {
// ObjectBox.removeAll()
// loadCollectedFoodList()
// onDone?.invoke()
// }
// }
//-------------------------------------------------------------------------------------------------------- //--------------------------------------------------------------------------------------------------------
} }
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update import androidx.room.Update
import com.shuwei.dish.match.entity.CookFoodEntity import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.CookFoodEntity
@Dao @Dao
interface CookFoodDao { interface CookFoodDao {
@@ -16,8 +18,8 @@ interface CookFoodDao {
@Update @Update
suspend fun updateCookFood(item: CookFoodEntity) suspend fun updateCookFood(item: CookFoodEntity)
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode") @Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int): CookFoodEntity? suspend fun getCookFoodById(canteenId: String, foodId: String, cookMode: Int, dinnerType: String): CookFoodEntity?
@Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC") @Query("SELECT * FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode AND dinnerType = :dinnerType ORDER BY createTime DESC")
suspend fun getCookFoodList(canteenId: String, cookMode: Int, dinnerType: String): MutableList<CookFoodEntity>? suspend fun getCookFoodList(canteenId: String, cookMode: Int, dinnerType: String): MutableList<CookFoodEntity>?
@@ -25,6 +27,35 @@ interface CookFoodDao {
@Query("SELECT count(1) FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode") @Query("SELECT count(1) FROM dm_cook_food WHERE isDel = 0 AND canteenId = :canteenId AND cookMode = :cookMode")
suspend fun countCookFood(canteenId: String, cookMode: Int): Int suspend fun countCookFood(canteenId: String, cookMode: Int): Int
@Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode") // @Query("DELETE FROM dm_cook_food WHERE canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int) @Query("UPDATE dm_cook_food SET isDel = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun deleteCookFood(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 接口提交成功后逻辑删除菜品,同时把 submitState 置 1 以便在数据库审计中区分手动删除 */
@Query("UPDATE dm_cook_food SET isDel = 1, submitState = 1 WHERE isDel = 0 AND canteenId = :canteenId AND foodId = :foodId AND cookMode = :cookMode AND dinnerType = :dinnerType")
suspend fun markSubmittedAndDelete(canteenId: String, foodId: String, cookMode: Int, dinnerType: String)
/** 查询全部菜品记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food ORDER BY createTime DESC")
suspend fun getAllCookFood(): MutableList<CookFoodEntity>
/** 分页查询菜品记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_cook_food WHERE (:showDel = 1 OR isDel = 0) ORDER BY createTime DESC LIMIT :limit OFFSET :offset")
suspend fun getCookFoodPaged(showDel: Int, limit: Int, offset: Int): List<CookFoodEntity>
/** 统计菜品记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food")
suspend fun deleteAll()
/** 动态条件分页查询菜品记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<CookFoodEntity>
/** 动态条件统计菜品记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
} }
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update import androidx.room.Update
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
@Dao @Dao
interface CookFoodGoodsDao { interface CookFoodGoodsDao {
@@ -25,6 +27,35 @@ interface CookFoodGoodsDao {
@Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId AND materialType = :materialType") @Query("SELECT * FROM dm_cook_food_goods WHERE isDel = 0 AND foodId = :foodId AND materialType = :materialType")
suspend fun getCookFoodGoodsListByType(foodId: String, materialType: Int): MutableList<CookFoodGoodsEntity> suspend fun getCookFoodGoodsListByType(foodId: String, materialType: Int): MutableList<CookFoodGoodsEntity>
@Query("DELETE FROM dm_cook_food_goods WHERE pid = :pid AND foodId = :foodId") // @Query("DELETE FROM dm_cook_food_goods WHERE pid = :pid AND foodId = :foodId")
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId")
suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String) suspend fun deleteCookFoodGoodsList(pid: Long, foodId: String)
/** 仅逻辑删除调料行(materialType = 3),主辅材不动,用于 cook 时只重建调料部分 */
@Query("UPDATE dm_cook_food_goods SET isDel = 1 WHERE isDel = 0 AND pid = :pid AND foodId = :foodId AND materialType = 3")
suspend fun deleteSeasoningOnly(pid: Long, foodId: String)
/** 查询全部食材记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_cook_food_goods ORDER BY createTime DESC")
suspend fun getAllCookFoodGoods(): MutableList<CookFoodGoodsEntity>
/** 分页查询食材记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_cook_food_goods WHERE (:showDel = 1 OR isDel = 0) ORDER BY createTime DESC LIMIT :limit OFFSET :offset")
suspend fun getCookFoodGoodsPaged(showDel: Int, limit: Int, offset: Int): List<CookFoodGoodsEntity>
/** 统计食材记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_cook_food_goods WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countCookFoodGoodsAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_cook_food_goods")
suspend fun deleteAll()
/** 动态条件分页查询食材记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<CookFoodGoodsEntity>
/** 动态条件统计食材记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
} }
@@ -4,8 +4,10 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Update import androidx.room.Update
import com.shuwei.dish.match.entity.SeasoningEntity import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.SeasoningEntity
@Dao @Dao
interface SeasoningDao { interface SeasoningDao {
@@ -26,7 +28,7 @@ interface SeasoningDao {
suspend fun getSeasoningBySort(sort: Int): MutableList<SeasoningEntity>? suspend fun getSeasoningBySort(sort: Int): MutableList<SeasoningEntity>?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId = :goodsId") @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId = :goodsId")
suspend fun getSeasoningByGoodsId(goodsId: Int): SeasoningEntity? suspend fun getSeasoningByGoodsId(goodsId: String): SeasoningEntity?
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC") @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsId != -1 AND goodsName != '' ORDER BY sort ASC")
fun getAllStream(): MutableList<SeasoningEntity> fun getAllStream(): MutableList<SeasoningEntity>
@@ -34,9 +36,35 @@ interface SeasoningDao {
@Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsName LIKE '%' || :query || '%'") @Query("SELECT * FROM dm_seasoning WHERE isDel = 0 AND goodsName LIKE '%' || :query || '%'")
suspend fun search(query: String): MutableList<SeasoningEntity> suspend fun search(query: String): MutableList<SeasoningEntity>
@Query("DELETE FROM dm_seasoning WHERE isDel = 0 AND sort = :sort") // @Query("DELETE FROM dm_seasoning WHERE isDel = 0 AND sort = :sort")
@Query("UPDATE dm_seasoning SET isDel = 1 WHERE isDel = 0 AND sort = :sort")
suspend fun deleteSeasoningBySort(sort: Int): Int suspend fun deleteSeasoningBySort(sort: Int): Int
@Query("DELETE FROM dm_seasoning WHERE isDel = 0") // @Query("DELETE FROM dm_seasoning WHERE isDel = 0")
@Query("UPDATE dm_seasoning SET isDel = 1 WHERE isDel = 0")
suspend fun clearAllSeasoning(): Int suspend fun clearAllSeasoning(): Int
/** 查询全部调料记录(含已删除),用于数据库调试查看 */
@Query("SELECT * FROM dm_seasoning ORDER BY sort ASC")
suspend fun getAllSeasoning(): MutableList<SeasoningEntity>
/** 分页查询调料记录,showDel=true 时包含已删除记录 */
@Query("SELECT * FROM dm_seasoning WHERE (:showDel = 1 OR isDel = 0) ORDER BY sort ASC LIMIT :limit OFFSET :offset")
suspend fun getSeasoningPaged(showDel: Int, limit: Int, offset: Int): List<SeasoningEntity>
/** 统计调料记录总数,showDel=true 时包含已删除记录 */
@Query("SELECT COUNT(*) FROM dm_seasoning WHERE (:showDel = 1 OR isDel = 0)")
suspend fun countSeasoningAll(showDel: Int): Int
/** 物理清空全表,用于清除测试数据(比 clearAllSeasoning 的逻辑删除更彻底) */
@Query("DELETE FROM dm_seasoning")
suspend fun deleteAll()
/** 动态条件分页查询调料记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<SeasoningEntity>
/** 动态条件统计调料记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
} }
@@ -4,7 +4,9 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.shuwei.dish.match.entity.SeasoningSlotEntity import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.shuwei.dish.match.db.entity.SeasoningSlotEntity
/** /**
* 调料槽位配置 DAO * 调料槽位配置 DAO
@@ -38,6 +40,14 @@ interface SeasoningSlotDao {
@Query("SELECT * FROM dm_seasoning_slot") @Query("SELECT * FROM dm_seasoning_slot")
suspend fun queryAll(): List<SeasoningSlotEntity> suspend fun queryAll(): List<SeasoningSlotEntity>
/** 分页查询槽位记录(槽位表无 isDel,showDel 参数忽略) */
@Query("SELECT * FROM dm_seasoning_slot ORDER BY deviceId ASC, address ASC LIMIT :limit OFFSET :offset")
suspend fun getSlotPaged(limit: Int, offset: Int): List<SeasoningSlotEntity>
/** 统计槽位记录总数 */
@Query("SELECT COUNT(*) FROM dm_seasoning_slot")
suspend fun countSlotAll(): Int
/** /**
* 删除指定设备的指定槽位配置 * 删除指定设备的指定槽位配置
* @param deviceId 目标设备 ID * @param deviceId 目标设备 ID
@@ -52,4 +62,16 @@ interface SeasoningSlotDao {
*/ */
@Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId") @Query("DELETE FROM dm_seasoning_slot WHERE deviceId = :deviceId")
suspend fun deleteAllByDeviceId(deviceId: String) suspend fun deleteAllByDeviceId(deviceId: String)
/** 物理清空全表,用于清除测试数据 */
@Query("DELETE FROM dm_seasoning_slot")
suspend fun deleteAll()
/** 动态条件分页查询槽位记录,由 ViewModel 构建 SQL */
@RawQuery
suspend fun queryPagedFiltered(query: SupportSQLiteQuery): List<SeasoningSlotEntity>
/** 动态条件统计槽位记录总数,由 ViewModel 构建 SQL */
@RawQuery
suspend fun countFiltered(query: SupportSQLiteQuery): Int
} }
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.db.entity
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
@@ -18,15 +18,17 @@ data class CookFoodEntity(
//0-制作模式,1-采样模式,2-品控模式 //0-制作模式,1-采样模式,2-品控模式
var cookMode:Int = 0, var cookMode:Int = 0,
var isDel: Int = 0, var isDel: Int = 0,
/** 接口提交状态:0-未提交(本地草稿或手动删除),1-接口提交成功后删除 */
var submitState: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()), var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()),
var dinnerType: String? = "0", var dinnerType: String? = "0",
) : BaseEntity { ) : BaseEntity {
@Ignore // @Ignore
var stFoodInfoConstituteList: MutableList<CookFoodGoodsEntity>? = null // var stFoodInfoConstituteList: MutableList<CookFoodGoodsEntity>? = null
@Ignore @Ignore
var matchingConstituteInfoList: MutableList<CookFoodGoodsEntity>? = null var matchingConstituteInfoList: MutableList<CookFoodGoodsEntity>? = null
@Ignore @Ignore
var foodConstituteList: MutableList<CookFoodGoodsEntity>? = null var foodConstituteList: MutableList<CookFoodGoodsEntity>? = null
constructor() : this(foodId = "") // 必需的空构造 constructor() : this(foodId = "") // 必需的空构造
} }
@@ -1,9 +1,9 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.db.entity
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.DateTimeUtil import com.shuwei.dish.match.utils.DateTimeUtil
import java.io.Serializable import java.io.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -63,30 +63,36 @@ class CookFoodGoodsEntity(
var relateionType_dictText: String? = "", var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var isDel: Int = 0, var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()) var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity, Serializable { ) : BaseEntity, Serializable {
constructor() : this(goodsId = "") // 必需的空构造, constructor() : this(goodsId = "") // 必需的空构造
@Ignore /**
var isSetFinished: Boolean = false * 转换为 GoodsItem用于 UI DishPartAdapterPrepareFoodActivity
*/
@Ignore fun toGoodsItem(): GoodsItem = GoodsItem(
var isItemClicked: Boolean = false goodsId = goodsId,
goodsName = goodsName,
@Ignore popularName = popularName,
var isSamplingPage: Boolean = false zjmCode = zjmCode,
materId = materId,
@Ignore materCode = goodsCode,
var isNewDishType: Boolean = false materialType = materialType,
useWeight = useWeight,
@Ignore relateionType = relateionType,
var isClicked: Boolean = false allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
//true-原始数据,false-非原始数据 rawMaterialsType = rawMaterialsType
@Ignore )
var isOriginalData: Boolean = true }
@Ignore
var foodScore: Int = -1
}
@@ -0,0 +1,6 @@
package com.shuwei.dish.match.db.entity
/** 默认调料配置 JSON 反序列化的包装类 */
data class ResetReasoningRecord(
val list: MutableList<SeasoningEntity> = mutableListOf()
)
@@ -1,9 +1,10 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.db.entity
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.shuwei.dish.match.db.BaseEntity import com.shuwei.dish.match.db.BaseEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.utils.DateTimeUtil import com.shuwei.dish.match.utils.DateTimeUtil
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -55,6 +56,16 @@ data class SeasoningEntity(
var relateionType_dictText: String? = "", var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
/** 物料编码 */
var goodsCode: String? = "",
/** 净材种类 */
var rawMaterialsType: String? = null,
var sort: Int = 9999, var sort: Int = 9999,
/** /**
* isDel表示数据是否已删除1-已删除为无效数据0-正常使用有效数据 * isDel表示数据是否已删除1-已删除为无效数据0-正常使用有效数据
@@ -62,11 +73,25 @@ data class SeasoningEntity(
var isDel: Int = 0, var isDel: Int = 0,
var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now()) var createTime: String = DateTimeUtil.formatDateTime(LocalDateTime.now())
) : BaseEntity { ) : BaseEntity {
@Ignore /** UI 状态:格子类型,非数据库字段 */
var pageType: Int = 0 @Ignore var pageType: Int = 0
/** UI 状态:是否选中,非数据库字段 */
@Ignore @Ignore var isClicked: Boolean = false
var isClicked: Boolean = false
constructor() : this(goodsId = "") // 必需的空构造 constructor() : this(goodsId = "") // 必需的空构造
}
/** 转换为 GoodsItem,用于 UI 层展示 */
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.db.entity
import androidx.room.Entity import androidx.room.Entity
@@ -0,0 +1,193 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogBottomSheetBinding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.net.RemoteRepository
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.utils.ext.toast
/**
* @param goodsType 1-调料,0-食材
*/
class BottomDialog(
private var goodsType: Int = 1,
private var defGoodsName:String? = null
) {
companion object {
const val TAG = "BottomDialog"
}
private var list = mutableListOf<GoodsItem>()
private lateinit var binding: DialogBottomSheetBinding
private lateinit var goodsInfoSearchAdapter: GoodsInfoSearchAdapter
private var goodsName: String? = null
private var activity: BaseActivity? = null
fun show(activity: BaseActivity, callback: (item: GoodsItem) -> Unit) {
this.activity = activity
//直接使用BottomSheetDialog
var dialog: BottomSheetDialog? = null
val inflater = LayoutInflater.from(activity)
binding = DialogBottomSheetBinding.inflate(inflater)
binding.tvSheetName.text = if (goodsType == 1) "调料检索" else "食材检索"
binding.etSheetInput.hint = if (goodsType == 1) "输入调料名称" else "输入食材名称或速记码"
goodsInfoSearchAdapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
callback(list[position])
dialog?.dismiss()
}, 300)
}
}
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = goodsInfoSearchAdapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
super.onScrollStateChanged(rv, newState);
}
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
//解决RecyclerView和smartRefreshLayout滑动冲突问题
super.onScrolled(rv, dx, dy)
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
dialog = BottomSheetDialog(activity, R.style.BottomSheet).apply {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false // 跳过折叠状态
// behavior.peekHeight = 880.dp
// window?.setLayout(-1, 880.dp)
show()
setOnDismissListener {
activity.hideStatusBar()
}
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
getGoodsList()
}
binding.refreshLayout.setOnLoadMoreListener {
getGoodsList()
}
binding.etSheetInput.addTextChangedListener(
onTextChanged = { text, start, before, count ->
goodsName = text.toString()
})
binding.ivSearch.setOnClickListener { v ->
searchGoods(v)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v)
}
binding.etSheetInput.let { v ->
v.addOnActionSearchListener {
searchGoods(v)
}
}
binding.refreshLayout.setEnableRefresh(false)
binding.refreshLayout.setEnableLoadMore(false)
if (defGoodsName.isNullOrBlank().not()) {
binding.etSheetInput.let {
it.setText(defGoodsName)
searchGoods(it)
}
}
}
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity?.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v.context, v)
}
private var pageNo = 1
private val pageSize = 120
/**
*/
@SuppressLint("NotifyDataSetChanged")
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "$goodsType",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (goodsName.isNullOrBlank().not()) {
param["goodsName"] = goodsName!!
}
Log.d(TAG, "getGoodsList: param=$param")
activity?.lifecycleScope?.launch {
val state = RemoteRepository().queryGoodsList(param)
when (state) {
is UiState.Success -> {
val records = state.data
if (records.isNullOrEmpty()) {
activity?.toast(if (goodsType == 1) "暂未搜索到调料信息" else "暂未搜索到食材信息")
return@launch
}
if (pageNo == 1) {
list.clear()
}
list.addAll(records)
goodsInfoSearchAdapter.notifyDataSetChanged()
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) {
pageNo++
}
}
is UiState.Error -> {
activity?.toast(state.msg)
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
}
else -> {}
}
}
}
}
@@ -0,0 +1,251 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan
import android.text.style.StyleSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogBottomSheet2Binding
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.net.RemoteRepository
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.roundedOneDecimalPlace
import com.shuwei.dish.match.utils.ext.toast
/**
* @param goodsType 1-调料,0-食材
*/
class BottomDialog2(
private var weighIndex: Int,
private var weighAddress: Int,
private var clickName: String? = null
) {
companion object {
const val TAG = "BottomDialog2"
}
private val goodsType: Int = 1
private var list = mutableListOf<GoodsItem>()
private lateinit var binding: DialogBottomSheet2Binding
private lateinit var searchAdapter: SeasoningSearchAdapter
private var goodsName: String? = null
private var activity: BaseActivity? = null
fun show(activity: BaseActivity, callback: (item: GoodsItem) -> Unit) {
this.activity = activity
//直接使用BottomSheetDialog
var dialog: BottomSheetDialog? = null
val inflater = LayoutInflater.from(activity)
binding = DialogBottomSheet2Binding.inflate(inflater)
//binding.tvSheetName.text = if (goodsType == 1) "调料检索" else "食材检索"
binding.etSheetInput.hint = if (goodsType == 1) "输入调料名称" else "输入食材名称或速记码"
searchAdapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
callback(list[position].also { it.useWeight = currentWeight })
dialog?.dismiss()
}, 300)
}
}
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = searchAdapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
super.onScrollStateChanged(rv, newState);
}
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
//解决RecyclerView和smartRefreshLayout滑动冲突问题
super.onScrolled(rv, dx, dy)
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
dialog = BottomSheetDialog(activity, R.style.BottomSheet).apply {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false // 跳过折叠状态
// behavior.peekHeight = 880.dp
// window?.setLayout(-1, 880.dp)
show()
setOnDismissListener {
activity.hideStatusBar()
}
}
binding.refreshLayout.setOnRefreshListener {
pageNo = 1
getGoodsList()
}
binding.refreshLayout.setOnLoadMoreListener {
getGoodsList()
}
binding.etSheetInput.addTextChangedListener(
onTextChanged = { text, start, before, count ->
goodsName = text.toString()
})
binding.ivSearch.setOnClickListener { v ->
searchGoods(v)
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v.context, v)
}
binding.etSheetInput.let { v ->
v.addOnActionSearchListener {
searchGoods(v)
}
}
binding.refreshLayout.setEnableRefresh(false)
binding.tvClear.setOnClickListener {
Log.d(TAG, "show: weighAddress=$weighAddress")
WeightUtil.tareTwo(weighAddress)
// repeat(14) { num->
// Log.d(TAG, "show: num=$num")
// WeightUtil.tareTwo(num+1)
// }
}
WeightUtil.addWeightListener (
weightKey = TAG,
getWeight = { address, state, weight ->
if (weighAddress == address) {
this.currentWeight = weight.toDouble()
binding.tvWeight.text = getTextSpan(weight)
}
})
binding.tvWeight.text = getTextSpan(0.0)
if (clickName.isNullOrBlank().not()) {
binding.etSheetInput.setText(clickName!!.trim())
pageNo = 1
getGoodsList()
}
}
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity?.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v.context, v)
}
private var currentWeight = 0.toDouble()
private var pageNo = 1
private val pageSize = 120
/**
*/
@SuppressLint("NotifyDataSetChanged")
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "$goodsType",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (goodsName.isNullOrBlank().not()) {
param["goodsName"] = goodsName!!
}
Log.d(TAG, "getGoodsList: param=$param")
activity?.lifecycleScope?.launch {
val state = RemoteRepository().queryGoodsList(param)
when (state) {
is UiState.Success -> {
val records = state.data
if (records.isNullOrEmpty()) {
activity?.toast(if (goodsType == 1) "暂未搜索到调料信息" else "暂未搜索到食材信息")
return@launch
}
if (pageNo == 1) {
list.clear()
}
list.addAll(records)
searchAdapter.notifyDataSetChanged()
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) {
pageNo++
}
}
is UiState.Error -> {
activity?.toast(state.msg)
if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200)
} else {
binding.refreshLayout.finishLoadMore(1200)
}
}
else -> {}
}
}
}
fun getTextSpan(weight: Double): SpannableStringBuilder {
var topWeight = "$weight"
var bottomUnit = ""
if (weight >= 1000) {
topWeight = "${(weight/1000).roundedDecimalPlace(3)}"
bottomUnit = "千克"
}
return buildSpannableString {
appendText(
topWeight,
ForegroundColorSpan("#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(72, true),
LineHeightSpan { text, start, end, spanstartv, v, fm ->
fm.descent += 10.dp // 增加行间距
}
)
append("\n")
appendText(
bottomUnit,
ForegroundColorSpan("#999999".toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
@@ -7,11 +7,9 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.Window import android.view.Window
import android.widget.LinearLayout import android.view.WindowManager
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.view.updateLayoutParams import androidx.fragment.app.FragmentActivity
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.DialogCommonBinding import com.shuwei.dish.match.databinding.DialogCommonBinding
import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.dp
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -22,115 +20,62 @@ open class CommonDialog(
private lateinit var binding: DialogCommonBinding private lateinit var binding: DialogCommonBinding
private var titleText: String? = null
private var contentText: CharSequence? = null
private var negativeText = "取消"
private var positiveText = "确认"
private var neutralText: String? = null
private var negativeClick: (() -> Unit)? = null
private var positiveClick: (() -> Unit)? = null
private var neutralClick: (() -> Unit)? = null
private var dismissCallback: (() -> Unit)? = null
/** 设置标题,为空时隐藏 */
fun setTitle(text: String): CommonDialog = apply { titleText = text }
/** 设置内容,为空时隐藏 */
fun setContent(text: CharSequence): CommonDialog = apply { contentText = text }
/** 设置左侧取消按钮文字及点击回调(3按钮模式下对应顶部按钮) */
fun setNegativeButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
negativeText = text
negativeClick = onClick
}
/** 设置右侧确认按钮文字及点击回调(3按钮模式下对应底部按钮) */
fun setPositiveButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
positiveText = text
positiveClick = onClick
}
/** 设置中间第三个按钮文字及点击回调,调用后自动切换为垂直3按钮布局 */
fun setNeutralButton(text: String, onClick: (() -> Unit)? = null): CommonDialog = apply {
neutralText = text
neutralClick = onClick
}
/** 设置弹窗消失回调 */
fun setOnDismissCallback(callback: () -> Unit): CommonDialog = apply {
dismissCallback = callback
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE) requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogCommonBinding.inflate(layoutInflater) binding = DialogCommonBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// 弹窗不允许点击外部关闭
setCancelable(false)
window?.run { window?.run {
val screenWidth = context.resources.displayMetrics.widthPixels // decorView.setPadding(0.dp, 0, 0.dp, 0)
val isMaster = GlobalData.deviceRole == DeviceRole.MASTER
attributes = attributes.apply { attributes = attributes.apply {
width = if (isMaster) (screenWidth * 0.72f).toInt() else (screenWidth * 0.60f).toInt() width = 708.dp
} }
// 透明背景,让 shape_dialog_bg 的阴影层正常显示
setBackgroundDrawable(ColorDrawable()) setBackgroundDrawable(ColorDrawable())
// 应用缩放+淡入淡出动画 setCancelable(false)
setWindowAnimations(com.shuwei.dish.match.R.style.CommonDialogAnimation) // setFlags(
} // WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
// 主/从设备边距微调:主设备内容区域更大,适当增加上下留白 // WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
val verticalMargin = if (GlobalData.deviceRole == DeviceRole.MASTER) 48.dp else 36.dp // )
binding.tvTitle.updateLayoutParams<LinearLayout.LayoutParams> { topMargin = verticalMargin }
binding.tvContent.updateLayoutParams<LinearLayout.LayoutParams> { bottomMargin = verticalMargin }
// 2按钮点击事件
binding.btnLeft.setOnClickListener {
negativeClick?.invoke()
dismiss()
}
binding.btnRight.setOnClickListener {
positiveClick?.invoke()
dismiss()
} }
// 3按钮点击事件(negativeClick/positiveClick 与2按钮模式共用) binding.llContent.run {
binding.btnTop.setOnClickListener { removeAllViews()
negativeClick?.invoke() addView(messageView)
dismiss()
} }
binding.btnMiddle.setOnClickListener { addListener()
neutralClick?.invoke()
dismiss()
}
binding.btnBottom.setOnClickListener {
positiveClick?.invoke()
dismiss()
}
setOnDismissListener { dismissCallback?.invoke() }
} }
private fun addListener() {
binding.btnLeft.setOnClickListener { onLeftClick?.invoke() }
binding.btnRight.setOnClickListener { onRightClick?.invoke() }
setOnDismissListener { onDismiss?.invoke() }
}
override fun show() { override fun show() {
super.show() super.show()
binding.tvTitle.text = titleText binding.btnLeft.text = leftText
binding.tvContent.text = contentText binding.btnRight.text = rightText
binding.tvTitle.visibility = if (titleText.isNullOrEmpty()) View.GONE else View.VISIBLE }
binding.tvContent.visibility = if (contentText.isNullOrEmpty()) View.GONE else View.VISIBLE
val hasThreeButtons = !neutralText.isNullOrEmpty() var messageView: View? = null
binding.layoutButtonsHorizontal.visibility = if (hasThreeButtons) View.GONE else View.VISIBLE var onLeftClick: (() -> Unit)? = null
binding.layoutButtonsVertical.visibility = if (hasThreeButtons) View.VISIBLE else View.GONE var onRightClick: (() -> Unit)? = null
if (hasThreeButtons) { var leftText = ""
// 3按钮模式:顶部=取消,中间=中立,底部=确认 var rightText = ""
binding.btnTop.text = negativeText
binding.btnMiddle.text = neutralText var onDismiss: (() -> Unit)? = null
binding.btnBottom.text = positiveText
} else { }
// 2按钮模式
binding.btnLeft.text = negativeText open class SafeDialog(activity: Activity, @StyleRes themeResId: Int) :
binding.btnRight.text = positiveText Dialog(activity, themeResId) {
private val weakContext = WeakReference(activity)
override fun show() {
weakContext.get()?.let {
if (!it.isFinishing && !it.isDestroyed) super.show()
} }
} }
} }
@@ -0,0 +1,38 @@
package com.shuwei.dish.match.dialog
import android.content.Context
import android.view.LayoutInflater
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding
object DialogTool {
fun load(
context: Context,
title:String,
content:String,
leftBtnText: String,
rightBtnText: String,
leftBtnClick: () -> Unit = {},
rightBtnClick: () -> Unit = {}
) {
val layoutInflater = LayoutInflater.from(context)
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater)
remindBinding.tvDialogTitle.text = title
remindBinding.tvDialogContent.text = content
CommonDialog(context).apply {
messageView = remindBinding.root
leftText = leftBtnText
rightText = rightBtnText
onLeftClick = {
leftBtnClick()
dismiss()
}
onRightClick = {
rightBtnClick()
dismiss()
}
onDismiss = { }
}.show()
}
}
@@ -1,62 +0,0 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogFoodRecognizeBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
/**
* 菜品识别结果弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param onDismiss 弹窗关闭时的额外回调(可选)
* @param onItemSelected 用户点击某一菜品时的回调
*/
class FoodRecognizeDialog(
private val activity: BaseActivity,
private val onDismiss: (() -> Unit)? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogFoodRecognizeBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>()
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar(); onDismiss?.invoke() }
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodRecognizeDialog.adapter
}
}
/**
* 加载识别结果数据并显示弹窗
* @param items 识别到的菜品列表
*/
@SuppressLint("NotifyDataSetChanged")
fun loadData(items: List<CookFoodGoodsEntity>) {
list.clear()
list.addAll(items)
adapter.notifyDataSetChanged()
if (!isShowing) show()
}
}
@@ -1,198 +0,0 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogFoodSearchBinding
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* 食材搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param defGoodsName 默认填充的食材名称(可选)
* @param onItemSelected 用户点击某一食材时的回调
*/
class FoodSearchDialog(
private val activity: BaseActivity,
private val defGoodsName: String? = null,
private val onItemSelected: (item: CookFoodGoodsEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<CookFoodGoodsEntity>().apply {
add(CookFoodGoodsEntity(goodsId = "200001", goodsName = "土豆丝"))
add(CookFoodGoodsEntity(goodsId = "200002", goodsName = "土豆片"))
add(CookFoodGoodsEntity(goodsId = "200003", goodsName = "土豆丁"))
add(CookFoodGoodsEntity(goodsId = "200004", goodsName = "胡萝卜丝"))
add(CookFoodGoodsEntity(goodsId = "200005", goodsName = "胡萝卜片"))
add(CookFoodGoodsEntity(goodsId = "200006", goodsName = "胡萝卜丁"))
add(CookFoodGoodsEntity(goodsId = "200007", goodsName = "黄瓜丝"))
add(CookFoodGoodsEntity(goodsId = "200008", goodsName = "黄瓜片"))
add(CookFoodGoodsEntity(goodsId = "200009", goodsName = "黄瓜丁"))
add(CookFoodGoodsEntity(goodsId = "200010", goodsName = "洋葱丝"))
add(CookFoodGoodsEntity(goodsId = "200011", goodsName = "洋葱丁"))
}
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
binding.root.postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
setOnDismissListener { activity.hideStatusBar() }
binding.tvSheetName.text = "食材检索"
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入食材名称或速记码"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
afterTextChanged = { it: Editable? ->
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化及滑动冲突处理
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
adapter = this@FoodSearchDialog.adapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
initObserver()
// 若有默认食材名称,自动填充并触发搜索
if (!defGoodsName.isNullOrBlank()) {
binding.etSheetInput.setText(defGoodsName)
searchGoods(binding.etSheetInput)
}
}
/**
* 收集 goodsListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.goodsListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v)
}
/**
* 请求食材列表
*/
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "0",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!!
}
activity.netViewModel.queryGoodsList(param)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<CookFoodGoodsEntity>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到食材信息")
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
}
@@ -13,22 +13,14 @@ object Loading {
private var dialog: LoadingDialog? = null private var dialog: LoadingDialog? = null
fun isShowing(): Boolean { fun show(context: Context) {
return dialog != null && dialog!!.isShowing
}
fun updateContent(content: String) {
if (isShowing().not()) return
dialog!!.updateContent(content)
}
fun show(context: Context, message: String = "加载中……", onDismiss: () -> Unit = {}) {
try { try {
if (dialog?.isShowing == true) { if (dialog?.isShowing == true) {
dialog?.dismiss() dialog?.dismiss()
} }
if (dialog == null) { if (dialog == null) {
dialog = LoadingDialog(context = context, message = message, onDismiss = onDismiss) dialog = LoadingDialog(context)
} }
dialog?.show() dialog?.show()
} catch (e: Exception) { } catch (e: Exception) {
@@ -53,32 +45,25 @@ object Loading {
class LoadingDialog( class LoadingDialog(
context: Context, context: Context,
message: String, message: String = "加载中……",
private var onDismiss: () -> Unit private var onDismiss: () -> Unit = {}
) : Dialog(context, R.style.LoadingDialog) { ) : Dialog(context, R.style.LoadingDialog) {
private val binding: DialogLoadingBinding by lazy {
DialogLoadingBinding.inflate(LayoutInflater.from(context))
}
init { init {
val binding = DialogLoadingBinding.inflate(LayoutInflater.from(context))
binding.tvMessage.text = message binding.tvMessage.text = message
setContentView(binding.root) setContentView(binding.root)
setCancelable(true) setCancelable(true)
window?.run { window?.run {
setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setBackgroundDrawable(Color.TRANSPARENT.toDrawable())
// setFlags( setFlags(
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
// WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
// ); );
} }
setOnDismissListener { setOnDismissListener {
onDismiss() onDismiss()
} }
} }
fun updateContent(content: String) {
binding.tvMessage.text = content
}
} }
//class LoadingDialog3( //class LoadingDialog3(
@@ -1,259 +0,0 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.LineHeightSpan
import android.text.style.StyleSpan
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.core.graphics.toColorInt
import androidx.core.view.isEmpty
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.DialogSeasoningSearchBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.dp
import com.shuwei.dish.match.utils.ext.roundedDecimalPlace
import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
/**
* 调料搜索弹窗,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param weighIndex 秤的索引
* @param weighAddress 秤的地址,用于监听重量和去皮
* @param clickName 默认填充的调料名称(可选)
* @param onItemSelected 用户点击某一调料时的回调,携带当前重量
*/
class SeasoningSearchDialog(
private val activity: BaseActivity,
private val weighIndex: Int,
private val weighAddress: Int,
private val clickName: String? = null,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "BottomDialog2"
}
private val binding = DialogSeasoningSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position].also { it.useWeight = currentWeight })
dismiss()
}, 300)
}
}
private var goodsName: String? = null
private var currentWeight = 0.0
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener {
// 弹窗关闭时移除重量监听,避免内存泄漏
WeightUtil.removeWeightListener(TAG)
activity.hideStatusBar()
}
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入调料名称"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
afterTextChanged = {
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化及滑动冲突处理
binding.recyclerView.let {
it.layoutManager = GridLayoutManager(activity, 2, GridLayoutManager.VERTICAL, false)
it.adapter = adapter
it.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(rv, dx, dy)
// 解决 RecyclerView 与 SmartRefreshLayout 滑动冲突
val topRowVerticalPosition = if (rv.isEmpty()) 0 else rv.getChildAt(0).top
binding.refreshLayout.setNestedScrollingEnabled(topRowVerticalPosition >= 0)
}
})
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
// 去皮按钮
binding.tvClear.setOnClickListener {
Log.d(TAG, "tareTwo: weighAddress=$weighAddress")
WeightUtil.tareTwo(weighAddress)
}
// 监听对应秤的重量变化
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, _, weight ->
if (weighAddress == address) {
currentWeight = weight.toDouble()
binding.tvWeight.text = getTextSpan(weight)
}
}
)
binding.tvWeight.text = getTextSpan(0.0)
initObserver()
// 若有默认调料名称,自动填充并触发搜索
if (!clickName.isNullOrBlank()) {
binding.etSheetInput.setText(clickName.trim())
pageNo = 1
getGoodsList()
}
}
/**
* 收集 seasoningListState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
activity.lifecycleScope.launch {
activity.repeatOnLifecycle(Lifecycle.State.STARTED) {
activity.netViewModel.seasoningListState.collect { state ->
when (state) {
is UiState.Success -> loadGoodsList(state.data)
is UiState.Error -> {
activity.toast(state.msg)
finishRefresh()
}
else -> {}
}
}
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(v)
}
/**
* 请求调料列表
*/
private fun getGoodsList() {
val param = mutableMapOf<String, Any>(
"goodsType" to "1",
"placeId" to BaseApp.canteenId,
"pageNum" to pageNo,
"pageSize" to pageSize
)
if (!goodsName.isNullOrBlank()) {
param["goodsName"] = goodsName!!
}
activity.netViewModel.querySeasoningList(param)
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
/**
* 生成重量显示的富文本:数值大字加粗,单位小字灰色
*/
fun getTextSpan(weight: Double): SpannableStringBuilder {
val topWeight = if (weight >= 1000) "${(weight / 1000).roundedDecimalPlace(3)}" else "$weight"
val bottomUnit = if (weight >= 1000) "千克" else ""
return buildSpannableString {
appendText(
topWeight,
ForegroundColorSpan("#000000".toColorInt()),
StyleSpan(Typeface.BOLD),
AbsoluteSizeSpan(72, true),
LineHeightSpan { _, _, _, _, _, fm -> fm.descent += 10.dp }
)
append("\n")
appendText(
bottomUnit,
ForegroundColorSpan("#999999".toColorInt()),
AbsoluteSizeSpan(30, true)
)
}
}
}
@@ -1,200 +0,0 @@
package com.shuwei.dish.match.dialog
import android.annotation.SuppressLint
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.SeasoningSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.DialogSeasoningSelectBinding
import com.shuwei.dish.match.entity.SeasoningEntity
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.toast
/**
* 调料选择弹窗,不含重量显示,继承 BottomSheetDialog 确保只初始化一次
* @param activity 宿主 Activity
* @param clickIndex 点击的调料槽位索引
* @param onItemSelected 用户点击某一调料时的回调
*/
class SeasoningSelectDialog(
private val activity: BaseActivity,
private val onItemSelected: (item: SeasoningEntity) -> Unit
) : BottomSheetDialog(activity, R.style.BottomSheet) {
companion object {
const val TAG = "SeasoningSelectDialog"
}
private val binding = DialogSeasoningSelectBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<SeasoningEntity>()
private val adapter = SeasoningSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list[position].isClicked = true
notifyItemChanged(position)
Handler(Looper.getMainLooper()).postDelayed({
onItemSelected(list[position])
dismiss()
}, 300)
}
}
private var goodsName: String? = null
private var pageNo = 1
private val pageSize = 50
init {
setContentView(binding.root)
setCancelable(true)
behavior.skipCollapsed = false
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN)
window?.setGravity(Gravity.BOTTOM)
window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
window?.setWindowAnimations(R.style.DialogSoftInputAnimation)
window?.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setOnDismissListener {
activity.hideStatusBar()
}
// 输入框:清空时重置列表
binding.etSheetInput.run {
hint = "输入调料名称"
addTextChangedListener(
onTextChanged = { text, _, _, _ -> goodsName = text.toString() },
afterTextChanged = {
if (it.isNullOrBlank()) {
list.clear()
adapter.notifyDataSetChanged()
}
}
)
addOnActionSearchListener { searchGoods(this) }
}
// RecyclerView 初始化
binding.recyclerView.run {
layoutManager = GridLayoutManager(activity, 3, GridLayoutManager.VERTICAL, false)
adapter = this@SeasoningSelectDialog.adapter
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
setOnRefreshListener { pageNo = 1; getGoodsList() }
setOnLoadMoreListener { getGoodsList() }
}
binding.ivSearch.setOnClickListener { searchGoods(it) }
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
/** 弹窗显示时自动加载默认调料列表,并在 Window.Callback 层提前拦截触摸以解决滑动冲突 */
override fun show() {
super.show()
pageNo = 1
getGoodsList()
// Window.Callback.dispatchTouchEvent 在整个 View 树的 onInterceptTouchEvent 之前执行,
// 是最早能感知触摸的时机。在此处判断触摸点位置来控制 behavior.isDraggable
// 从而确保 BottomSheetBehavior 在 CoordinatorLayout.onInterceptTouchEvent 时
// 已经是禁用状态,不会拦截列表区域的滑动手势。
val originalCallback = window?.callback ?: return
window?.callback = object : Window.Callback by originalCallback {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val rect = Rect()
binding.refreshLayout.getGlobalVisibleRect(rect)
// 触摸点落在列表区域内则禁止 BottomSheet 拖拽
behavior.isDraggable = !rect.contains(event.rawX.toInt(), event.rawY.toInt())
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
behavior.isDraggable = true
}
}
return originalCallback.dispatchTouchEvent(event)
}
}
}
/**
* 触发搜索:校验输入、隐藏键盘、发起请求
*/
private fun searchGoods(v: View) {
if (goodsName.isNullOrBlank()) {
activity.toast("${binding.etSheetInput.hint}")
return
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard( v)
}
/**
* 请求调料列表(临时使用本地模拟数据,正式联调时替换为真实接口)
*/
private fun getGoodsList() {
// TODO: 联调时注释掉下方模拟数据,取消注释真实接口调用
val nameList = listOf(
"", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香",
"料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "", "", "", "香菜", ""
)
val mockData = mutableListOf<SeasoningEntity>()
nameList.forEachIndexed { index, name ->
val padString = "${index + 1}".padStart(2, '0')
mockData.add(SeasoningEntity(goodsId = "1000$padString", goodsName = name))
}
loadGoodsList(mockData)
// val param = mutableMapOf<String, Any>(
// "goodsType" to "1",
// "placeId" to BaseApp.canteenId,
// "pageNum" to pageNo,
// "pageSize" to pageSize
// )
// if (!goodsName.isNullOrBlank()) {
// param["goodsName"] = goodsName!!
// }
// activity.querySeasoningList(param = param, onSuccess = {
// loadGoodsList(it)
// }, onFailure = { _, msg ->
// activity.toast(msg)
// finishRefresh()
// })
}
/**
* 将返回数据填充到列表
*/
@SuppressLint("NotifyDataSetChanged")
private fun loadGoodsList(records: MutableList<SeasoningEntity>?) {
finishRefresh()
if (records.isNullOrEmpty()) {
activity.toast("暂未搜索到调料信息")
return
}
if (pageNo == 1) list.clear()
list.addAll(records)
adapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) pageNo++
}
private fun finishRefresh() {
if (pageNo == 1) binding.refreshLayout.finishRefresh()
else binding.refreshLayout.finishLoadMore()
}
}
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.model
/** /**
* 已采集食材列表项数据模型 * 已采集食材列表项数据模型
@@ -0,0 +1,43 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品详情接口传输对象(DTO),不依赖 Room
* 对应接口:getConstituteByFoodId / saveConstitute
*/
data class CookFoodDTO(
var foodId: String = "",
var foodName: String? = null,
var canteenId: String? = null,
var foodWeight: Double = 0.0,
// 0-制作模式,1-采样模式,2-品控模式
var cookMode: Int = 0,
var dinnerType: String? = "0",
// 接口返回的食材构成列表
var matchingConstituteInfoList: MutableList<CookFoodGoodsDTO>? = null,
var foodConstituteList: MutableList<CookFoodGoodsDTO>? = null
) : Serializable {
/**
* 转换为 Room 实体,用于写入本地数据库
* id/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodEntity = CookFoodEntity(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType
).also { entity ->
entity.matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toEntity() }
?.toMutableList()
entity.foodConstituteList = foodConstituteList
?.map { it.toEntity() }
?.toMutableList()
}
}
@@ -0,0 +1,119 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import java.io.Serializable
/**
* 菜品构成(主辅料/调料)接口传输对象(DTO),不依赖 Room
* 对应 CookFoodDTO 中 foodConstituteList、matchingConstituteInfoList 的元素
*/
data class CookFoodGoodsDTO(
var goodsId: String = "",
var goodsName: String? = null,
var foodId: String? = "",
var goodsOrRelationCode: String? = "",
// 调料数据顺序
var sort: Int = 0,
// 食材原材料:1,预制品:2
var relateionType: Int = 0,
// 物品类型:1主料 2辅料 3调料
var materialType: Int = 0,
// 物品 是否全部可食:true-全部可食,false-部分可食
var allEdible: Boolean = true,
// 物品 用料重量
var useWeight: Double? = 0.0,
var popularName: String? = "",
var canteenId: String? = "",
var relateionType_dictText: String? = "",
var zjmCode: String? = "",
var materId: String? = "",
// 物料编码
var goodsCode: String? = "",
// 净材种类
var rawMaterialsType: String? = null
) : Serializable {
/**
* 转换为 GoodsItem,用于 UI 层
*/
fun toGoodsItem(): GoodsItem = GoodsItem(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
materCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 Room 实体,用于写入本地数据库
* id/pid/isDel/createTime 由 Room 自行管理,不从 DTO 携带
*/
fun toEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
}
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/pid/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodGoodsEntity.toDTO(): CookFoodGoodsDTO = CookFoodGoodsDTO(
goodsId = goodsId,
goodsName = goodsName,
foodId = foodId,
goodsOrRelationCode = goodsOrRelationCode,
sort = sort,
relateionType = relateionType,
materialType = materialType,
allEdible = allEdible,
useWeight = useWeight,
popularName = popularName,
canteenId = canteenId,
relateionType_dictText = relateionType_dictText,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
rawMaterialsType = rawMaterialsType
)
/**
* Room 实体 → DTO 反向映射,用于将本地数据转为接口提交格式
* id/isDel/createTime 为本地数据库字段,不参与提交
*/
fun CookFoodEntity.toDTO(): CookFoodDTO = CookFoodDTO(
foodId = foodId,
foodName = foodName,
canteenId = canteenId,
foodWeight = foodWeight,
cookMode = cookMode,
dinnerType = dinnerType,
matchingConstituteInfoList = matchingConstituteInfoList
?.map { it.toDTO() }
?.toMutableList(),
foodConstituteList = foodConstituteList
?.map { it.toDTO() }
?.toMutableList()
)
@@ -1,11 +1,10 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.model
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import java.io.File import java.io.File
data class HomeModeBean( data class HomeModeBean(
var modeName: String = "", var modeName: String = "",
var modeDesc: String = "", var modeDesc: String = "",
var modeIconId: Int = 0, var modeIconId: Int = 0,
@@ -14,34 +13,6 @@ data class HomeModeBean(
var onClick: (() -> Unit)? = null var onClick: (() -> Unit)? = null
) )
data class FoodRecordBean(
var records: MutableList<FoodRecord>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class GoodsRecordBean(
var records: MutableList<CookFoodGoodsEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class SeasoningRecordBean(
var records: MutableList<SeasoningEntity>? = null,
var total: Int,
var size: Int,
var current: Int,
var page: Int
)
data class ResetReasoningRecord(
val list: MutableList<SeasoningEntity>
)
data class FoodCollectionBean( data class FoodCollectionBean(
var imageUri: Uri? = null, var imageUri: Uri? = null,
var bitmap: Bitmap? = null, var bitmap: Bitmap? = null,
@@ -79,26 +50,3 @@ data class FoodCollectionBean(
return result return result
} }
} }
//data class DishDetailEntity(
// var foodId: String? = null,
// var foodWeight: Double? = null,
// var foodRealWeight: Double? = null,
// var voList: List<DishPartEntity>? = null
//)
//data class DishPartEntity(
// var foodId: String? = null,
// var foodName: String? = null,
// var foodWeight: Int? = null,
// var useWeight: Int? = null,
// var useRealWeight: Int? = null,
// var goodsId: Int? = null,
// var goodsName: String? = null,
// var materialType: Int? = null,
// var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
// var isSamplingPage: Boolean = false
//)
@@ -1,4 +1,4 @@
package com.shuwei.dish.match.entity package com.shuwei.dish.match.model
import java.io.Serializable import java.io.Serializable
@@ -17,4 +17,3 @@ data class FoodRecord(
var isOriginalData: Boolean = true, var isOriginalData: Boolean = true,
var sort: Int = 0 var sort: Int = 0
) : Serializable ) : Serializable
@@ -0,0 +1,83 @@
package com.shuwei.dish.match.model
import com.shuwei.dish.match.db.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.db.entity.SeasoningEntity
import java.io.Serializable
/**
* 物品信息接口返回的数据类(DTO),不依赖 Room,同时承载 UI 状态字段
* 对应接口:queryGoodsInfoList,食材(goodsType=0)和调料(goodsType=1)共用此结构
*/
data class GoodsItem(
// 接口返回字段
var goodsId: String = "",
val goodsName: String? = null,
val popularName: String? = null,
val zjmCode: String? = null,
var materId: String? = null,
val materCode: String? = null,
// 从 CookFoodGoodsEntity 迁移的数据字段(DishPartAdapter 显示及转换用)
var materialType: Int = 0,
var useWeight: Double? = 0.0,
var relateionType: Int = 0,
var allEdible: Boolean = true,
var goodsOrRelationCode: String? = "",
// 从 CookFoodGoodsEntity @Ignore 迁移的 UI 状态字段
var isSetFinished: Boolean = false,
// var isItemClicked: Boolean = false,
var isSamplingPage: Boolean = false,
var isNewDishType: Boolean = false,
var isClicked: Boolean = false,
// true-原始数据,false-非原始数据(识图新增)
var isOriginalData: Boolean = true,
// 识图匹配分数,-1 表示不显示
var foodScore: Int = -1,
// 净材种类(当前选中值)
var rawMaterialsType: String? = null,
// 净材种类候选列表(临时字段,逗号分隔,不持久化)
var rawMaterialsTypes: String? = null,
var oldGoodsId: String? = null
) : Serializable {
/**
* 物料编码,与查询接口返回的 materCode 同值,统一对外暴露为 goodsCode 供下游使用
*/
val goodsCode: String? get() = materCode
/**
* 转换为 CookFoodGoodsEntity,用于写入数据库或传给 SubmitFoodActivity
*/
fun toCookFoodGoodsEntity(): CookFoodGoodsEntity = CookFoodGoodsEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
useWeight = useWeight,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
/**
* 转换为 SeasoningEntity,用于保存调料完整信息到 dm_seasoning
*/
fun toSeasoningEntity(): SeasoningEntity = SeasoningEntity(
goodsId = goodsId,
goodsName = goodsName,
popularName = popularName,
zjmCode = zjmCode,
materId = materId,
goodsCode = goodsCode,
materialType = materialType,
relateionType = relateionType,
allEdible = allEdible,
goodsOrRelationCode = goodsOrRelationCode,
rawMaterialsType = rawMaterialsType
)
}
@@ -0,0 +1,12 @@
package com.shuwei.dish.match.model
/**
* 物品名称批量查询入参 DTO
* 用于 queryGoodsInfoList 接口 goodsNames 字段的元素类型
* @param goodsName 物品名称
* @param rawMaterialsType 净材种类
*/
data class GoodsNameQueryDTO(
val goodsName: String,
val rawMaterialsType: String
)
@@ -0,0 +1,40 @@
package com.shuwei.dish.match.model
/**
* 设置页面的列表项实体类
*
* @param type 菜单项类型,用于唯一标识每一项
* @param title 主标题文字
* @param subtitle 副标题文字,仅部分项目(如菜品模式)有值
* @param isHidden 是否为隐藏项,隐藏项需触发开发者模式后才显示
* @param onClick 点击回调,初始化时直接注入对应的业务逻辑
*/
data class SettingItem(
val type: Type,
val title: String,
val subtitle: String? = null,
val isHidden: Boolean = false,
val onClick: () -> Unit
) {
/** 设置菜单项类型枚举 */
enum class Type {
/** 菜品模式切换 */
COOK_MODE,
/** 调料区设置 */
SEASONING_CONFIG,
/** 食材采集 */
FOOD_COLLECT,
/** 减重配置 */
WEIGHT_CONFIG,
/** 数据库查看(隐藏项) */
DB_INSPECT,
/** 秤数据监控(隐藏项) */
SCALE_OBSERVE,
/** 切换环境(隐藏项) */
ENV_SWITCH,
/** 数据清除(隐藏项) */
CLEAR_DATA,
/** 测试调料拿取(隐藏项) */
TEST_SEASONING,
}
}
@@ -1,131 +0,0 @@
package com.shuwei.dish.match.net
import com.google.gson.JsonSyntaxException
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toObject
import com.shuwei.dish.match.utils.ext.toType
import org.apache.http.conn.ConnectTimeoutException
import retrofit2.HttpException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
///**
// * 通用网络请求封装,统一处理异常
// * @param onRequest 实际的 Retrofit 接口请求
// * @param onSuccess 成功回调
// * @param onFailure 自定义错误回调(可选,不传则用默认提示)
// */
//suspend fun <T> apiRequest(
// onRequest: suspend () -> T,
// onSuccess: (T) -> Unit,
// onFailure: ((ApiException) -> Unit)? = null
//) {
// try {
// // 执行实际的网络请求
// val result = onRequest()
// onSuccess(result)
// } catch (e: Exception) {
// val apiException = getApiException(e)
// // 优先执行自定义错误回调,没有则用默认提示
// if (onFailure != null) {
// onFailure(apiException)
// } else {
// // 默认的错误提示(可根据 ErrorType 定制)
// val tip = apiException.errorMsg
// // 切换到UI线程弹提示(用 MainScope 确保在主线程)
// MainScope().launch {
// BaseApp.instance?.toast(tip)
// }
// }
// }
//}
/**
* 通用网络请求封装,统一处理异常
* @param onRequest 实际的 Retrofit 接口请求
* @param onSuccess 成功回调
* @param onFailure 自定义错误回调(可选,不传则用默认提示)
*/
@Suppress("UNCHECKED_CAST")
suspend fun <T> request(
onRequest: suspend () -> ApiResponse<T>,
onSuccess: (T) -> Unit,
onFailure: (String, String) -> Unit
) {
var response: ApiResponse<T>?
try {
// 执行实际的网络请求
response = onRequest()
} catch (e: Exception) {
val apiException = getApiException(e)
onFailure("-1", apiException.errorMsg)
return
}
if (response.isSuccess()) {
onSuccess(response.data as T)
} else {
onFailure(response.code, response.msg ?: "")
}
}
data class CodeMsg(
var code: String? = null,
var msg: String? = null
)
@Suppress("DEPRECATION")
fun getApiException(e: Exception): ApiException {
// 捕获各类网络异常并转换为自定义异常
return when (e) {
is HttpException -> httpException2ApiException(e)
is JsonSyntaxException -> ApiException(
ErrorType.NETWORK_ERROR,
errorMsg = "服务器响应异常,请稍后重试"
)
is UnknownHostException -> ApiException(
ErrorType.NETWORK_ERROR,
errorMsg = "网络未连接,请检查网络"
)
is SocketTimeoutException -> ApiException(
ErrorType.TIMEOUT_ERROR,
errorMsg = "请求超时,请稍后重试"
)
is ConnectTimeoutException -> ApiException(
ErrorType.TIMEOUT_ERROR,
errorMsg = "连接超时,请检查网络"
)
is IOException -> ApiException(
ErrorType.NETWORK_ERROR,
errorMsg = "网络异常:${e.message ?: "未知IO错误"}"
)
is ApiException -> e
else -> ApiException(
ErrorType.UNKNOWN_ERROR,
errorMsg = "未知错误:${e.message ?: "未知"}",
throwable = e
)
}
}
fun httpException2ApiException(e: HttpException): ApiException {
val body = e.response()?.errorBody()?.string()
val codeMsg: CodeMsg? = try {
body?.toObject<CodeMsg>()
} catch (_: Exception) {
null
}
return ApiException(
ErrorType.NETWORK_ERROR,
errorMsg = if (codeMsg == null) "数据解析异常(${e.code()})"
else codeMsg.msg ?: "服务器错误(${e.code()})"
)
}
@@ -1,13 +1,17 @@
package com.shuwei.dish.match.net package com.shuwei.dish.match.net
import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.entity.SeasoningEntity import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.PartMap
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -20,7 +24,7 @@ interface ApiService {
suspend fun getFoodDetail( suspend fun getFoodDetail(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/getConstituteByFoodId",
@Query("foodId") foodId: String @Query("foodId") foodId: String
): ApiResponse<CookFoodEntity?> ): ApiResponse<CookFoodDTO?>
/** /**
@@ -29,7 +33,7 @@ interface ApiService {
@POST @POST
suspend fun submitCookFood( suspend fun submitCookFood(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/saveConstitute",
@Body param: CookFoodEntity @Body param: CookFoodDTO
): ApiResponse<Any?> ): ApiResponse<Any?>
/** /**
@@ -37,7 +41,7 @@ interface ApiService {
*/ */
@POST @POST
suspend fun searchFoodList( suspend fun searchFoodList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?> ): ApiResponse<MutableList<FoodRecord>?>
@@ -46,68 +50,27 @@ interface ApiService {
*/ */
@POST @POST
suspend fun getSamplingList( suspend fun getSamplingList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<FoodRecord>?> ): ApiResponse<MutableList<FoodRecord>?>
/** /**
* 物品信息 * 物品信息(食材 goodsType=0,调料 goodsType=1 共用此接口)
*/ */
@POST @POST
suspend fun queryGoodsList( suspend fun queryGoodsList(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList",
@Body param: MutableMap<String, Any> @Body param: MutableMap<String, Any>
): ApiResponse<MutableList<CookFoodGoodsEntity>?> ): ApiResponse<MutableList<GoodsItem>?>
/** /**
* 调料信息 * 提交采集图片数据
*/ */
@Multipart
@POST @POST
suspend fun querySeasoningList( suspend fun uploadFoodVectorData(
@Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList", @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData",
@Body param: MutableMap<String, Any> @PartMap params: MutableMap<String, RequestBody>,
): ApiResponse<MutableList<SeasoningEntity>?> @Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?>
// /**
// *获取配置信息
// */
// @GET("/equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = App.appVersion
// ): ApiResponse<DeviceConfigInfo>
//
// @GET
// suspend fun getAccessToken(
// @Url url: String = UrlConfig.GET_ACCESS_TOKEN,
// @Query("qrcodeId") qrcodeId: String
// ): ApiResponse<String?>
//
// @GET
// suspend fun getShelfList(
// @Url url: String = UrlConfig.GET_SHELF_LIST,
// @Query("deviceId") deviceId: String
// ): ApiResponse<ShelfResult>
//
// @GET
// suspend fun getGoodsList(
// @Url url: String = UrlConfig.GET_GOODS_LIST,
// @Query("canteenId") canteenId: String = "0",
// @Query("goodsName") goodsName: String? = null,
// @Query("pageNo") pageNo: Int = 1,
// @Query("pageSize") pageSize: Int = 50
// ): ApiResponse<MutableList<GoodsModel>?>
//
// @POST
// suspend fun saveShelfGoodsList(
// @Url url: String = UrlConfig.SAVE_SHELF_GOODS_LIST,
// @Body body: ShelfBody
// ): ApiResponse<Any?>
} }
@@ -1,5 +1,10 @@
package com.shuwei.dish.match.net package com.shuwei.dish.match.net
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
// 1. 网络异常类型枚举 // 1. 网络异常类型枚举
enum class ErrorType { enum class ErrorType {
NETWORK_ERROR, // 网络错误(无网) NETWORK_ERROR, // 网络错误(无网)
@@ -16,4 +21,14 @@ class ApiException(
val errorCode: Int = -1, val errorCode: Int = -1,
val errorMsg: String, val errorMsg: String,
val throwable: Throwable? = null val throwable: Throwable? = null
) : Exception(errorMsg, throwable) ) : Exception(errorMsg, throwable)
/** 将任意异常转换为 ApiException,供 safeApiCall 统一使用 */
fun getApiException(e: Exception): ApiException = when (e) {
is ApiException -> e
is UnknownHostException -> ApiException(ErrorType.NETWORK_ERROR, errorMsg = "网络未连接,请检查网络")
is SocketTimeoutException -> ApiException(ErrorType.TIMEOUT_ERROR, errorMsg = "请求超时,请稍后重试")
is ConnectException -> ApiException(ErrorType.TIMEOUT_ERROR, errorMsg = "连接超时,请检查网络")
is IOException -> ApiException(ErrorType.NETWORK_ERROR, errorMsg = "网络异常:${e.message ?: "未知IO错误"}")
else -> ApiException(ErrorType.UNKNOWN_ERROR, errorMsg = "未知错误:${e.message ?: "未知"}", throwable = e)
}
@@ -2,14 +2,23 @@ package com.shuwei.dish.match.net
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.GoodsNameQueryDTO
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
/** /**
* 网络请求 ViewModel,所有网络操作通过 RemoteRepository 发起,不直接依赖 apiService * 网络请求 ViewModel,所有网络操作通过 RemoteRepository 发起,不直接依赖 apiService
@@ -28,7 +37,7 @@ class NetViewModel(
/** /**
* 提交制作菜品 * 提交制作菜品
*/ */
fun submitCookFood(entity: CookFoodEntity) { fun submitCookFood(entity: CookFoodDTO) {
viewModelScope.launch { viewModelScope.launch {
_submitCookFoodState.value = UiState.Loading _submitCookFoodState.value = UiState.Loading
_submitCookFoodState.value = repository.submitCookFood(entity) _submitCookFoodState.value = repository.submitCookFood(entity)
@@ -38,8 +47,8 @@ class NetViewModel(
/** /**
* 查询菜品详情的 UI 状态流,UI 层通过 collect 监听 * 查询菜品详情的 UI 状态流,UI 层通过 collect 监听
*/ */
private val _foodDetailState = MutableStateFlow<UiState<CookFoodEntity?>>(UiState.Idle) private val _foodDetailState = MutableStateFlow<UiState<CookFoodDTO?>>(UiState.Idle)
val foodDetailState: StateFlow<UiState<CookFoodEntity?>> = _foodDetailState.asStateFlow() val foodDetailState: StateFlow<UiState<CookFoodDTO?>> = _foodDetailState.asStateFlow()
/** /**
* 查询菜品详情 * 查询菜品详情
@@ -51,24 +60,51 @@ class NetViewModel(
} }
} }
fun resetFoodDetailState() {
_foodDetailState.value = UiState.Idle
}
/** /**
* 搜索菜品列表的 UI 状态流UI 层通过 collect 监听 * 搜索菜品列表的 UI 状态流
* 使用 replay=1 保证 Fragment 重建后能收到最近一次结果;
* emit 时包一层 Event wrapper 打破 equals 去重,确保相同数据也能触发 collect。
*/ */
private val _searchFoodState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle) private val _searchFoodState = MutableSharedFlow<UiState<MutableList<FoodRecord>?>>(
val searchFoodState: StateFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asStateFlow() replay = 1,
extraBufferCapacity = 1
)
val searchFoodState: SharedFlow<UiState<MutableList<FoodRecord>?>> = _searchFoodState.asSharedFlow()
/** /**
* 搜索菜品列表 * 搜索菜品列表
*/ */
fun searchFoodList(param: MutableMap<String, Any>) { fun searchFoodList(param: MutableMap<String, Any>) {
viewModelScope.launch { viewModelScope.launch {
_searchFoodState.value = UiState.Loading // pageNum=1 时先 emit Idle 重置 replay 缓存,防止相同数据导致 SharedFlow 去重不触发
_searchFoodState.value = repository.searchFoodList(param) if (param["pageNum"] == 1) {
_searchFoodState.emit(UiState.Idle)
}
_searchFoodState.emit(UiState.Loading)
_searchFoodState.emit(repository.searchFoodList(param))
}
}
/**
* 搜索菜品列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow/SharedFlow
* 适合需要每次都能触发的场景(下拉刷新、上拉加载更多)
*/
fun searchFoodListWithCallback(
param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.searchFoodList(param))
} }
} }
/** /**
* 查询采样数据列表的 UI 状态流,UI 层通过 collect 监听
*/ */
private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle) private val _samplingListState = MutableStateFlow<UiState<MutableList<FoodRecord>?>>(UiState.Idle)
val samplingListState: StateFlow<UiState<MutableList<FoodRecord>?>> = _samplingListState.asStateFlow() val samplingListState: StateFlow<UiState<MutableList<FoodRecord>?>> = _samplingListState.asStateFlow()
@@ -87,15 +123,61 @@ class NetViewModel(
} }
/** /**
* 查询物品信息列表的 UI 状态流,UI 层通过 collect 监听 * 查询采样数据列表(回调版本),结果通过 onResult 直接回调,不经过 StateFlow
* 适合不需要跨生命周期持续监听的一次性查询场景
*/ */
private val _goodsListState = MutableStateFlow<UiState<MutableList<CookFoodGoodsEntity>?>>(UiState.Idle) fun getSamplingListWithCallback(
val goodsListState: StateFlow<UiState<MutableList<CookFoodGoodsEntity>?>> = _goodsListState.asStateFlow() param: MutableMap<String, Any>,
onLoading: () -> Unit = {},
onResult: (UiState<MutableList<FoodRecord>?>) -> Unit
) {
viewModelScope.launch {
onLoading()
onResult(repository.getSamplingList(param))
}
}
/** /**
* 查询物品信息列表 * 查询物品信息列表的 UI 状态流(食材/调料共用),UI 层通过 collect 监听
*/ */
fun queryGoodsList(param: MutableMap<String, Any>) { private val _goodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val goodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _goodsListState.asStateFlow()
/**
* 食材搜索弹窗专用的 UI 状态流,与 goodsListState 隔离,避免弹窗请求影响其他页面
*/
private val _foodSearchGoodsListState = MutableStateFlow<UiState<MutableList<GoodsItem>?>>(UiState.Idle)
val foodSearchGoodsListState: StateFlow<UiState<MutableList<GoodsItem>?>> = _foodSearchGoodsListState.asStateFlow()
fun resetGoodsListState() {
_goodsListState.value = UiState.Idle
}
/**
* 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用)
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/
fun queryGoodsList(
goodsType: String,
placeId: String = BaseApp.canteenId,
pageNum: Int = 1,
pageSize: Int = 50,
goodsName: String? = null,
goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch { viewModelScope.launch {
_goodsListState.value = UiState.Loading _goodsListState.value = UiState.Loading
_goodsListState.value = repository.queryGoodsList(param) _goodsListState.value = repository.queryGoodsList(param)
@@ -103,18 +185,74 @@ class NetViewModel(
} }
/** /**
* 查询调料信息列表的 UI 状态流,UI 层通过 collect 监听 * 食材搜索弹窗专用的查询方法,结果写入 foodSearchGoodsListState,不影响 goodsListState
* @param goodsType 物品类型:0=食材,1=调料
* @param placeId 场所 ID,默认取当前食堂 ID
* @param pageNum 页码,默认第 1 页
* @param pageSize 每页条数,默认 50
* @param goodsName 按名称模糊搜索,为 null 时不传该字段
* @param goodsNames 按名称+净材种类列表批量查询,为 null 时不传该字段
*/ */
private val _seasoningListState = MutableStateFlow<UiState<MutableList<SeasoningEntity>?>>(UiState.Idle) fun queryFoodSearchGoodsList(
val seasoningListState: StateFlow<UiState<MutableList<SeasoningEntity>?>> = _seasoningListState.asStateFlow() goodsType: String,
placeId: String = BaseApp.canteenId,
/** pageNum: Int = 1,
* 查询调料信息列表 pageSize: Int = 50,
*/ goodsName: String? = null,
fun querySeasoningList(param: MutableMap<String, Any>) { goodsNames: List<GoodsNameQueryDTO>? = null
) {
val param = mutableMapOf<String, Any>(
"goodsType" to goodsType,
"placeId" to placeId,
"pageNum" to pageNum,
"pageSize" to pageSize
)
goodsName?.let { param["goodsName"] = it }
goodsNames?.let { param["goodsNames"] = it }
viewModelScope.launch { viewModelScope.launch {
_seasoningListState.value = UiState.Loading _foodSearchGoodsListState.value = UiState.Loading
_seasoningListState.value = repository.querySeasoningList(param) _foodSearchGoodsListState.value = repository.queryGoodsList(param)
} }
} }
/**
* 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听
*/
private val _uploadCollectState = MutableStateFlow<UiState<List<String>?>>(UiState.Idle)
val uploadCollectState: StateFlow<UiState<List<String>?>> = _uploadCollectState.asStateFlow()
/**
* 上传采集菜品信息(触发 StateFlow,适合单次上传场景)
*/
fun uploadFoodVectorData(
fileList: List<File>,
params: MutableMap<String, RequestBody>
) {
val fileParts = fileList.map { file ->
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
viewModelScope.launch {
_uploadCollectState.value = UiState.Loading
_uploadCollectState.value = repository.uploadFoodVectorData(params, fileParts)
}
}
/**
* 上传采集菜品信息(直接返回结果,适合分批循环上传场景)
* @param fileList 图片文件列表
* @param params 附加参数
* @return UiState 包装的结果
*/
suspend fun uploadFoodVectorDataBatch(
fileList: List<File>,
params: MutableMap<String, RequestBody>
): UiState<List<String>?> {
val fileParts = fileList.map { file ->
//multipart/form-data image/jpeg
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
return repository.uploadFoodVectorData(params, fileParts)
}
} }
@@ -1,9 +1,10 @@
package com.shuwei.dish.match.net package com.shuwei.dish.match.net
import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.model.CookFoodDTO
import com.shuwei.dish.match.entity.CookFoodGoodsEntity import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.entity.SeasoningEntity import okhttp3.MultipartBody
import okhttp3.RequestBody
/** /**
* 网络数据仓库,封装所有 ApiService 调用 * 网络数据仓库,封装所有 ApiService 调用
@@ -11,101 +12,42 @@ import com.shuwei.dish.match.entity.SeasoningEntity
*/ */
class RemoteRepository { class RemoteRepository {
/** /** 统一封装 API 调用:处理响应映射和异常转换,返回 UiState */
* 提交制作菜品 private suspend fun <T> safeApiCall(call: suspend () -> ApiResponse<T>): UiState<T?> {
* @param entity 菜品实体
* @return UiState 包装的结果,Success 表示提交成功,Error 携带错误信息
*/
suspend fun submitCookFood(entity: CookFoodEntity): UiState<Any?> {
return try { return try {
val resp = apiService.submitCookFood(param = entity) val resp = call()
if (resp.isSuccess()) UiState.Success(resp.data) if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "") else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) { } catch (e: Exception) {
val ex = getApiException(e) UiState.Error("-1", getApiException(e).errorMsg)
UiState.Error("-1", ex.errorMsg)
} }
} }
/** /** 提交制作菜品 */
* 查询菜品详情 suspend fun submitCookFood(entity: CookFoodDTO): UiState<Any?> =
* @param foodId 菜品 ID safeApiCall { apiService.submitCookFood(param = entity) }
* @return UiState 包装的结果,Success 携带详情数据,Error 携带错误信息
*/
suspend fun getFoodDetail(foodId: String): UiState<CookFoodEntity?> {
return try {
val resp = apiService.getFoodDetail(foodId = foodId)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** /** 查询菜品详情 */
* 搜索菜品列表 suspend fun getFoodDetail(foodId: String): UiState<CookFoodDTO?> =
* @param param 查询参数 safeApiCall { apiService.getFoodDetail(foodId = foodId) }
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.searchFoodList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** /** 搜索菜品列表 */
* 查询采样数据列表 suspend fun searchFoodList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
* @param param 查询参数 safeApiCall { apiService.searchFoodList(param = param) }
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun getSamplingList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> {
return try {
val resp = apiService.getSamplingList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** /** 查询采样数据列表 */
* 查询物品信息列表 suspend fun getSamplingList(param: MutableMap<String, Any>): UiState<MutableList<FoodRecord>?> =
* @param param 查询参数 safeApiCall { apiService.getSamplingList(param = param) }
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息
*/
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<CookFoodGoodsEntity>?> {
return try {
val resp = apiService.queryGoodsList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
/** 查询物品信息列表(食材 goodsType=0,调料 goodsType=1 共用) */
suspend fun queryGoodsList(param: MutableMap<String, Any>): UiState<MutableList<GoodsItem>?> =
safeApiCall { apiService.queryGoodsList(param = param) }
/** /** 上传采集数据 */
* 查询调料信息列表 suspend fun uploadFoodVectorData(
* @param param 查询参数 params: MutableMap<String, RequestBody>,
* @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息 foodPics: List<MultipartBody.Part>
*/ ): UiState<List<String>?> =
suspend fun querySeasoningList(param: MutableMap<String, Any>): UiState<MutableList<SeasoningEntity>?> { safeApiCall { apiService.uploadFoodVectorData(params = params, foodPics = foodPics) }
return try {
val resp = apiService.querySeasoningList(param = param)
if (resp.isSuccess()) UiState.Success(resp.data)
else UiState.Error(resp.code, resp.msg ?: "")
} catch (e: Exception) {
val ex = getApiException(e)
UiState.Error("-1", ex.errorMsg)
}
}
} }
@@ -1,37 +0,0 @@
package com.shuwei.dish.match.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
}
}
@@ -1,12 +0,0 @@
package com.shuwei.dish.match.objbox
import android.graphics.Bitmap
import android.net.Uri
import java.io.File
data class FoodClassInfo(
var class_names: List<String>,
var class_to_idx: Map<String, Int>,
var idx_to_class: Map<String, String>
)
@@ -1,308 +0,0 @@
package com.shuwei.dish.match.objbox
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.core.graphics.scale
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.shuwei.dish.match.utils.AssetsTool
import com.shuwei.dish.match.utils.LogSaveUtil
import com.shuwei.dish.match.utils.ext.toJsonString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.pytorch.IValue
import org.pytorch.Module
import org.pytorch.torchvision.TensorImageUtils
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
object FoodModule {
private lateinit var module_mobile: Module
private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
// 1. 定义你的模型固定输入尺寸 (根据你的tflite模型修改,比如224x224)
private const val MODEL_INPUT_WIDTH = 300
private const val MODEL_INPUT_HEIGHT = 300
const val DEFAULT_FOOD_INDEX = -1
const val DEFAULT_QUERY_COUNT = 50
const val BAG_RATE = 0.05
suspend fun init(context: Context) {
withContext(Dispatchers.IO) {
module_mobile = Module.load(copyAssetToCache(context, "best_embedding_model_mobile.pt"))
initDefFoodData(context)
}
}
// fun uri2FloatArray(uri: Uri): FloatArray? {
// return MyApp.instance?.let { context ->
// ImageUtil.uriToBitmap(context, uri)?.let {
// bitmap2FloatArray(it)
// }
// }
// }
fun bitmap2FloatArray(originBitmap: Bitmap, isRecycle: Boolean): FloatArray? {
var rgb565Bitmap: Bitmap? = null
try {
val scaledBitmap = originBitmap.scale(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
if (isRecycle) {
originBitmap.recycle()
}
rgb565Bitmap = scaledBitmap.copy(Bitmap.Config.RGB_565, false)
scaledBitmap.recycle()
val inputTensor = TensorImageUtils.bitmapToFloat32Tensor(
rgb565Bitmap,
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
} catch (e: OutOfMemoryError) {
e.printStackTrace()
logInfo("bitmap2FloatArray异常:${e.message}")
} finally {
if (rgb565Bitmap != null && rgb565Bitmap.isRecycled.not()) {
rgb565Bitmap.recycle()
}
//System.gc()
//System.runFinalization()
}
return null
}
// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List<String>? {
// return uri2FloatArray(uri)?.let {
// queryFood(it, queryCount)
// }
// }
// /**
// * 返回识别物品名称列表
// */
// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFood(floatArray, queryCount)
// }
// /**
// * 返回识别物品IdNameScore对象列表
// */
// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFoodNameScore(floatArray, queryCount)
// }
suspend fun queryFoodNameScore(
floatArray: FloatArray?,
queryCount: Int = DEFAULT_QUERY_COUNT
): List<IdNameScore> {
if (floatArray == null) return emptyList()
// 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}" }
// var idScoreList: List<IdWithScore>
// try {
// idScoreList = query.findIdsWithScores();
// } finally {
// // 先关闭Query,释放Cursor
// query.close()
// }
logInfo("queryFood向量:${floatArray.slice(0 until 50).toJsonString()}")
val idScoreList = ObjectBox.query(floatArray, queryCount)
// 批量加载所有实体,一次DB操作替代循环单条查询
val foodMap = ObjectBox.getByIds(idScoreList.map { it.id }).associateBy { it.id }
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
}
logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList
}
suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
val floatArray = bitmap2FloatArray(bitmap, false) ?: return emptyList()
logInfo("getFoodScoreList向量:${floatArray.toJsonString()}")
val nameScoreList = queryFoodNameScore(floatArray, queryCount)
if (nameScoreList.isEmpty()) {
return emptyList()
}
//暂时不过滤
val maxScoreList = nameScoreList
//.filter { it.score < 0.15 }
.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)
// })
val sortedScoreList = maxScoreList.sortedBy { it.score }
logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}")
return sortedScoreList
}
// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// 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
)
suspend fun initDefFoodData(context: Context, action: () -> Unit = {}) {
try {
val count = ObjectBox.countIndexField(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")
val embeddingsList: List<List<Float>> =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.type)
val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java)
val classInfo: FoodClassInfo =
Gson().fromJson(classInfoJson, FoodClassInfo::class.java)
val foodMap = classInfo.idx_to_class
val list = mutableListOf<Food>()
val size = embeddingsList.size
val size2 = labelsList.size
val size3 = foodMap.size
logInfo("initDefFoodData: $size,$size2,$size3")
embeddingsList.forEachIndexed { index, floatList ->
val classIdx = labelsList[index]
val foodName = foodMap["$classIdx"]
val array = floatList.toFloatArray()
// ObjectBox.boxStore.runInTx {
// box.put(Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX))
// }
val food = Food(name = foodName, foodVector = array, foodIdx = DEFAULT_FOOD_INDEX)
list.add(food)
}
// val tempList = list.chunked(10)
ObjectBox.putAll(list)
action()
} catch (e: Exception) {
e.printStackTrace()
logInfo("initDefFoodData: ---${e.message}--")
}
}
/**
* ,此方法的主要目的是:从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
// }
fun copyAssetToCache(context: Context, fileName: String): String? {
val cacheFile = File(context.cacheDir, fileName)
val buffer = ByteArray(8 * 1024)
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
try {
inputStream = context.assets.open(fileName)
outputStream = FileOutputStream(cacheFile)
var byteCount: Int
while (inputStream.read(buffer).also { byteCount = it } != -1) {
outputStream.write(buffer, 0, byteCount)
}
outputStream.channel.force(true) // 强制物理落盘,比flush更彻底
return cacheFile.absolutePath
} catch (e: IOException) {
e.printStackTrace()
logInfo("文件拷贝失败 fileName=$fileName, error=${e.message}")
// 拷贝失败时删除残缺文件,避免下次读取到损坏文件
if (cacheFile.exists()) {
cacheFile.delete()
}
} finally {
outputStream?.close()
inputStream?.close()
}
return null
}
private fun logInfo(msg:String) {
Log.d("FoodModule", msg)
LogSaveUtil.saveLogFile(msg)
}
}
@@ -1,327 +0,0 @@
/*
* 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.shuwei.dish.match.objbox
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.utils.LogSaveUtil
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.BoxStoreBuilder
import io.objectbox.exception.DbException
import io.objectbox.exception.FileCorruptException
import io.objectbox.kotlin.boxFor
import io.objectbox.query.Query
import io.objectbox.sync.Sync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.GZIPOutputStream
import com.shuwei.dish.match.BuildConfig
/**
* 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"
// 添加 @Volatile 保证多线程下的可见性
@Volatile
var boxStore: BoxStore? = null
/**
* If building the [boxStore] failed, contains the thrown error message.
*/
var dbExceptionMessage: String? = null
private set
fun init(context: Context) {
if (boxStore != null && boxStore!!.isClosed.not()) return
// 优化:调大事务回收超时时间
System.setProperty("objectbox.finalizerTimeout", "30000");
// On Android make sure to pass a Context when building the Store.
boxStore = try {
MyObjectBox.builder()
.androidContext(context.applicationContext)
//.disableMultiProcess() // 开启多进程支持(按需关闭,单进程可删)
//.disableFileCompression() // 禁用文件压缩,避免存储异常
.build()
} catch (e: DbException) {
if (e.javaClass == 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()
LogSaveUtil.saveLogFile("ObjectBox.init异常1:$dbExceptionMessage")
return
} else {
LogSaveUtil.saveLogFile("ObjectBox.init异常2:${e.message}")
// 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
io.objectbox.android.Admin(boxStore).start(context.applicationContext)
}
}
/**
* If the database file is not in use, compresses (GZIP) and copies it to the given [target].
* 增加 IOException 捕获,避免文件异常导致崩溃。
*/
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
return try {
File(context.filesDir, "objectbox/$dbName/data.mdb").inputStream().use { input ->
target.parentFile?.mkdirs()
GZIPOutputStream(target.outputStream()).use { output ->
input.copyTo(output, DEFAULT_BUFFER_SIZE)
}
}
true
} catch (e: IOException) {
Log.e(TAG, "备份数据库文件失败", e)
false
}
}
// 获取指定实体的Box,自动关联BoxStore
inline fun <reified T> getBox(): Box<T>? = boxStore?.boxFor()
// 协程安全执行数据库操作(推荐所有操作使用此方法)
suspend fun <T> safeDbOp(operation: suspend () -> T): T? {
return withContext(Dispatchers.IO) {
try {
dbMutex.withLock { operation() }
} catch (e: FileCorruptException) {
// 操作中触发损坏,尝试重建数据库
e.printStackTrace()
LogSaveUtil.saveLogFile("safeDbOp出现FileCorruptException异常:${e.message},${e.errorCode}")
// 使用安全调用替代 !! 断言,避免 NPE
val app = BaseApp.instance ?: return@withContext null
init(app)
null
} catch (e: Exception) {
e.printStackTrace()
LogSaveUtil.saveLogFile("safeDbOp出现其它异常:${e.message}")
null
} finally {
// 线程资源清理
boxStore?.closeThreadResources()
}
}
}
suspend fun query(floatArray: FloatArray, queryCount: Int) = safeDbOp {
val query: Query<Food>? = getBox<Food>()?.query(
Food_.foodVector
.nearestNeighbors(floatArray, queryCount)
)?.build()
try {
query?.findIdsWithScores()
} finally {
// 先关闭Query,释放Cursor
query?.close()
}
} ?: emptyList()
suspend fun get(id: Long) = safeDbOp {
getBox<Food>()?.get(id)
}
// 批量按ID加载,一次DB事务替代循环单条查询
suspend fun getByIds(ids: List<Long>): List<Food> = safeDbOp {
if (ids.isEmpty()) return@safeDbOp emptyList<Food>()
getBox<Food>()?.get(ids.toLongArray()) ?: emptyList<Food>()
} ?: emptyList()
suspend fun put(entity: Food) = safeDbOp {
getBox<Food>()?.put(entity)
}
suspend fun putAll(entities: List<Food>) = safeDbOp {
val box = getBox<Food>()
box?.put(entities)
}
// 使用 QueryBuilder 在数据库层计数,避免全量加载到内存
suspend fun countNameField(name: String) = safeDbOp {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.count()
} ?: 0L
suspend fun countIndexField(idx: Int) = safeDbOp {
getBox<Food>()?.query(Food_.foodIdx.equal(idx))?.build()?.count()
} ?: 0L
// 使用 QueryBuilder 在数据库层过滤,避免全量加载到内存
suspend fun filter(name: String?) = safeDbOp {
if (!name.isNullOrBlank()) {
getBox<Food>()?.query(Food_.name.contains(name))?.build()?.find()
?.distinctBy { it.name }
} else {
getBox<Food>()?.all?.distinctBy { it.name }
} ?: emptyList()
}
// 使用 QueryBuilder 在数据库层删除,避免全量加载到内存
suspend fun remove(name: String) = safeDbOp {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
}
/**
* 按名称分组统计采集数量
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
* @param nameFilter 名称过滤关键词,为空时查询全部
* @return 按名称分组后的 (名称, 数量) 列表,按名称排序
*/
suspend fun queryFoodNameCounts(nameFilter: String? = null) = safeDbOp {
val box = getBox<Food>() ?: return@safeDbOp emptyList<Pair<String, Int>>()
val query = if (!nameFilter.isNullOrBlank()) {
box.query(Food_.name.contains(nameFilter)).build()
} else {
box.query().build()
}
try {
// 只查 name 字段,完全不加载 foodVector
query.property(Food_.name).findStrings()
.groupingBy { it }
.eachCount()
.map { (name, count) -> Pair(name, count) }
.sortedBy { it.first }
} finally {
query.close()
}
} ?: emptyList()
private val dbMutex = Mutex() // 协程并发锁,保证写入操作原子性
private const val DB_DIR_NAME = "objectbox" // ObjectBox 默认数据库目录
private const val BACKUP_DIR_NAME = "objectbox_backup" // 备份目录
// 检查内部存储是否可读写(操作数据库前调用)
// 修复:原逻辑错误地检查外部存储状态,数据库存于内部存储,与外部存储无关
fun isStorageAvailable(context: Context): Boolean {
return try {
context.filesDir.canRead() && context.filesDir.canWrite()
} catch (e: Exception) {
false
}
}
// 备份数据库到应用私有目录(无权限要求,推荐)
suspend fun backupDb(context: Context): Boolean = withContext(Dispatchers.IO) {
if (!isStorageAvailable(context)) return@withContext false
val dbDir = File(context.filesDir, DB_DIR_NAME)
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
return@withContext copyDir(dbDir, backupDir)
}
// 从备份恢复数据库(恢复后会重建BoxStore)
suspend fun restoreDb(context: Context): Boolean = withContext(Dispatchers.IO) {
if (!isStorageAvailable(context)) return@withContext false
val dbDir = File(context.filesDir, DB_DIR_NAME)
val backupDir = File(context.filesDir, BACKUP_DIR_NAME)
if (!backupDir.exists()) return@withContext false
// 先关闭旧的BoxStore,删除损坏文件,再恢复备份
boxStore?.close()
deleteDbFiles(context)
val isSuccess = copyDir(backupDir, dbDir)
// 重新初始化
init(context)
return@withContext isSuccess
}
// 递归删除数据库文件
private fun deleteDbFiles(context: Context) {
val dbDir = File(context.filesDir, DB_DIR_NAME)
if (dbDir.exists()) deleteDirRecursively(dbDir)
}
// 递归删除目录
private fun deleteDirRecursively(file: File) {
if (file.isDirectory) {
file.listFiles()?.forEach { deleteDirRecursively(it) }
}
file.delete()
}
// 递归复制目录(核心备份/恢复逻辑)
private fun copyDir(srcDir: File, destDir: File): Boolean {
return try {
if (!srcDir.exists()) return false
if (!destDir.exists()) destDir.mkdirs()
srcDir.listFiles()?.forEach { srcFile ->
val destFile = File(destDir, srcFile.name)
if (srcFile.isDirectory) {
copyDir(srcFile, destFile)
} else {
copyFile(srcFile, destFile)
}
}
true
} catch (e: IOException) {
e.printStackTrace()
false
}
}
// 复制单个文件(使用NIO,高效稳定)
private fun copyFile(srcFile: File, destFile: File) {
FileInputStream(srcFile).channel.use { srcChannel ->
FileOutputStream(destFile).channel.use { destChannel ->
destChannel.transferFrom(srcChannel, 0, srcChannel.size())
}
}
}
// 关闭BoxStore(应用退出时调用,可选)
fun close() {
boxStore?.close()
boxStore = null
}
}
@@ -1,156 +0,0 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
import java.util.LinkedList
/**
* mDNS 设备发现管理器(仅主设备运行)
* 持续扫描局域网,发现或丢失 DishMatch 子设备时通过回调通知连接管理器
*
* 注意:Android NsdManager.resolveService 不支持并发调用,多台子设备同时被发现时
* 会报 FAILURE_ALREADY_ACTIVE(3) 导致解析失败。此处使用串行队列逐一解析。
*/
class MdnsDiscoveryManager(private val context: Context) {
companion object {
private const val TAG = "MdnsDiscoveryManager"
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var isDiscovering = false
/** 待解析队列,串行处理避免并发解析失败 */
private val resolveQueue = LinkedList<NsdServiceInfo>()
@Volatile
private var isResolving = false
/**
* 发现新设备时的回调:(deviceId, host, port)
*/
var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null
/**
* 设备离线时的回调:(deviceId)
*/
var onDeviceLost: ((deviceId: String) -> Unit)? = null
/**
* 开始扫描局域网中的 DishMatch 设备
*/
fun startDiscovery() {
if (isDiscovering) return
discoveryListener = object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "开始扫描失败: errorCode=$errorCode")
isDiscovering = false
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Log.e(TAG, "停止扫描失败: errorCode=$errorCode")
}
override fun onDiscoveryStarted(serviceType: String) {
Log.d(TAG, "mDNS 扫描已启动")
isDiscovering = true
}
override fun onDiscoveryStopped(serviceType: String) {
Log.d(TAG, "mDNS 扫描已停止")
isDiscovering = false
}
override fun onServiceFound(service: NsdServiceInfo) {
// 只处理 DishMatch 服务
if (!service.serviceName.startsWith("DishMatch-")) return
Log.d(TAG, "发现设备: ${service.serviceName}")
enqueueResolve(service)
}
override fun onServiceLost(service: NsdServiceInfo) {
if (!service.serviceName.startsWith("DishMatch-")) return
val deviceId = service.serviceName.removePrefix("DishMatch-")
Log.d(TAG, "设备离线: $deviceId")
onDeviceLost?.invoke(deviceId)
}
}
nsdManager.discoverServices(
MdnsRegisterManager.SERVICE_TYPE,
NsdManager.PROTOCOL_DNS_SD,
discoveryListener
)
}
/**
* 解析服务获取 IP 和端口
*/
private fun resolveService(service: NsdServiceInfo) {
nsdManager.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "解析设备地址失败: ${info.serviceName}, errorCode=$errorCode")
// 解析结束(失败),继续处理队列中下一个
processNextResolve()
}
override fun onServiceResolved(info: NsdServiceInfo) {
val deviceId = info.serviceName.removePrefix("DishMatch-")
val host = info.host?.hostAddress ?: run {
processNextResolve()
return
}
val port = info.port
Log.d(TAG, "设备地址已解析: $deviceId -> $host:$port")
onDeviceFound?.invoke(deviceId, host, port)
// 解析结束(成功),继续处理队列中下一个
processNextResolve()
}
})
}
/** 将待解析服务加入队列,若当前无解析任务则立即开始 */
@Synchronized
private fun enqueueResolve(service: NsdServiceInfo) {
resolveQueue.add(service)
if (!isResolving) {
processNextResolve()
}
}
/** 从队列取出下一个服务进行解析 */
@Synchronized
private fun processNextResolve() {
val next = resolveQueue.poll() ?: run {
isResolving = false
return
}
isResolving = true
resolveService(next)
}
/**
* 停止扫描,在应用退出时调用
*/
fun stopDiscovery() {
if (!isDiscovering) return
discoveryListener?.let {
try {
nsdManager.stopServiceDiscovery(it)
} catch (e: Exception) {
Log.e(TAG, "停止扫描异常: ${e.message}")
}
}
discoveryListener = null
synchronized(this) {
resolveQueue.clear()
isResolving = false
}
}
}
@@ -1,80 +0,0 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log
/**
* mDNS 服务注册管理器(主设备和子设备均运行)
* 将本机 WebSocket 服务以 mDNS 形式广播到局域网,让主设备能发现本机 IP:PORT
*/
class MdnsRegisterManager(private val context: Context) {
companion object {
private const val TAG = "MdnsRegisterManager"
/** mDNS 服务类型,所有 DishMatch 设备使用统一类型 */
const val SERVICE_TYPE = "_dishmatch._tcp."
/** WebSocket 服务监听端口 */
const val WS_PORT = 8765
}
private val nsdManager: NsdManager by lazy {
context.getSystemService(Context.NSD_SERVICE) as NsdManager
}
private var registrationListener: NsdManager.RegistrationListener? = null
private var isRegistered = false
/**
* 向局域网注册 mDNS 服务
* @param deviceId 当前设备 ID,作为服务名称后缀,便于主设备识别
*/
fun register(deviceId: String) {
if (isRegistered) return
val serviceInfo = NsdServiceInfo().apply {
serviceName = "DishMatch-$deviceId"
serviceType = SERVICE_TYPE
port = WS_PORT
}
registrationListener = object : NsdManager.RegistrationListener {
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注册失败, errorCode=$errorCode")
isRegistered = false
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
Log.e(TAG, "mDNS 注销失败, errorCode=$errorCode")
}
override fun onServiceRegistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 注册成功: ${info.serviceName}")
isRegistered = true
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
Log.d(TAG, "mDNS 已注销: ${info.serviceName}")
isRegistered = false
}
}
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
}
/**
* 注销 mDNS 服务,在应用退出时调用
*/
fun unregister() {
if (!isRegistered) return
registrationListener?.let {
try {
nsdManager.unregisterService(it)
} catch (e: Exception) {
Log.e(TAG, "注销 mDNS 异常: ${e.message}")
}
}
registrationListener = null
}
}
@@ -1,17 +0,0 @@
package com.shuwei.dish.match.scale
/**
* 主设备向子设备发送的控制指令
* @param cmd 指令类型,如 "tare"(清零)
* @param deviceId 目标设备 ID,子设备收到后校验是否匹配自身
* @param address 目标秤地址
*/
data class ScaleCommand(
val cmd: String,
val deviceId: String,
val address: Int
) {
companion object {
const val CMD_TARE = "tare"
}
}
@@ -1,20 +0,0 @@
package com.shuwei.dish.match.scale
/**
* 单个秤的数据快照
* @param deviceId 所属设备 ID
* @param address 秤地址编号(硬件 address,对应 WeightUtil 回调的 address 参数)
* @param weight 重量,单位克
* @param state 秤状态:1=稳定,0=不稳定,2=量程溢出(对应 WeightUtil 中的 STATE_* 常量)
* @param ts 数据时间戳(毫秒)
* @param ip 所属设备的 IP 地址,用于 UI 展示;网络传输数据中可能为空
*/
data class ScaleData(
val deviceId: String,
val address: Int,
val weight: Double,
val state: Int,
val ts: Long,
val ip: String = "",
val name: String? = null
)
@@ -1,122 +0,0 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
/**
* 秤数据聚合器(仅主设备运行)
* 统一管理本机秤(直接读取 WeightUtil)和远端子设备秤(通过 WebSocket 接收)的数据
* 通过 StateFlow 将所有秤的最新快照暴露给 UI 层
*
* 数据 Map 的 key 格式:"{deviceId}#{address}",便于 UI 按设备分组展示
*/
class ScaleDataAggregator(private val localDeviceId: String) {
companion object {
private const val TAG = "ScaleDataAggregator"
/** 本机秤监听器的 key,防止与其他监听器冲突 */
private const val LOCAL_LISTENER_KEY = "ScaleDataAggregator_local"
}
/** 所有秤的最新数据,UI 层通过此 Flow 订阅更新 */
private val _allScales = MutableStateFlow<Map<String, ScaleData>>(emptyMap())
val allScales: StateFlow<Map<String, ScaleData>> = _allScales.asStateFlow()
/** 内部缓存,并发安全 */
private val cache = ConcurrentHashMap<String, ScaleData>()
/** deviceId → IP 地址映射,用于写入 ScaleData.ip 字段 */
private val deviceIpMap = ConcurrentHashMap<String, String>()
/**
* 设置指定设备的 IP 地址
* 若缓存中已有该设备的数据且 ip 为空,立即回填并触发 StateFlow 更新,
* 解决早期数据包在 IP 到达前入缓存导致一直显示"未知"的问题
* @param deviceId 设备 ID
* @param ip IP 地址字符串
*/
fun setDeviceIp(deviceId: String, ip: String) {
if (ip.isEmpty()) return
deviceIpMap[deviceId] = ip
// 回填该设备所有 ip 为空的缓存条目
val keys = cache.keys.filter { it.startsWith("$deviceId#") }
if (keys.isEmpty()) return
var updated = false
keys.forEach { key ->
val old = cache[key] ?: return@forEach
if (old.ip.isEmpty()) {
cache[key] = old.copy(ip = ip)
updated = true
}
}
if (updated) {
_allScales.value = HashMap(cache)
Log.d(TAG, "已回填设备IP: $deviceId -> $ip, 共 ${keys.size}")
}
}
/**
* 启动本机秤数据监听
* 主设备本机秤直接读取 WeightUtil,无需经过网络
*/
fun startLocalCollection() {
WeightUtil.addWeightListener(LOCAL_LISTENER_KEY) { address, state, weight ->
val data = ScaleData(
deviceId = localDeviceId,
address = address,
weight = weight,
state = state,
ts = System.currentTimeMillis(),
ip = deviceIpMap[localDeviceId] ?: ""
)
updateCache(data)
}
Log.d(TAG, "本机秤监听已启动, deviceId=$localDeviceId")
}
/**
* 处理来自 WebSocket 客户端的远端秤数据
* 由 ScaleWebSocketClient.onScaleData 回调触发
* 自动补充该设备已记录的 IP 信息
*/
fun onRemoteScaleData(data: ScaleData) {
val ip = deviceIpMap[data.deviceId] ?: data.ip
updateCache(if (ip.isNotEmpty() && data.ip.isEmpty()) data.copy(ip = ip) else data)
}
/**
* 移除指定设备的所有秤数据(设备离线时调用)
*/
fun removeDevice(deviceId: String) {
val keysToRemove = cache.keys.filter { it.startsWith("$deviceId#") }
if (keysToRemove.isEmpty()) return
keysToRemove.forEach { cache.remove(it) }
deviceIpMap.remove(deviceId)
_allScales.value = HashMap(cache)
Log.d(TAG, "已移除设备数据: $deviceId, 共 ${keysToRemove.size} 个秤")
}
/**
* 停止本机秤监听并清空所有数据
*/
fun stop() {
WeightUtil.removeWeightListener(LOCAL_LISTENER_KEY)
cache.clear()
deviceIpMap.clear()
_allScales.value = emptyMap()
Log.d(TAG, "ScaleDataAggregator 已停止")
}
/**
* 更新缓存并发布新快照到 StateFlow
*/
private fun updateCache(data: ScaleData) {
val key = "${data.deviceId}#${data.address}"
cache[key] = data
_allScales.value = HashMap(cache)
}
}
@@ -1,22 +0,0 @@
package com.shuwei.dish.match.scale
/**
* 各子设备的固定 ID 与秤地址排列顺序配置
* MasterScaleActivity 和 SlaveActivity 共用此配置,避免重复定义
*/
object ScaleDeviceConfig {
const val DEVICE_ID_2 = "8fc2ab34-2137-3112-acca-f884ea8736d4"
const val DEVICE_ID_22 = "a46fa55c-113c-3511-bb1f-41e5eff77c4b"
const val DEVICE_ID_18 = "1038da9f-c6eb-326e-a1d9-d6d3af978b22"
const val DEVICE_ID_1 = "7cc0f6ea-f13d-3013-a867-fc998eb554ac"
/** 22个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_22 = listOf(20, 22, 19, 21, 6, 12, 18, 5, 11, 17, 4, 10, 16, 3, 9, 15, 2, 8, 14, 1, 7, 13)
/** 18个秤的显示顺序(按物理位置排列) */
val SCALE_ORDER_18 = listOf(6, 5, 4, 3, 2, 1, 12, 11, 10, 9, 8, 7, 18, 17, 16, 15, 14, 13)
/** 设备在列表中的显示顺序 */
val DEVICE_ORDER = listOf(DEVICE_ID_2, DEVICE_ID_22, DEVICE_ID_18, DEVICE_ID_1)
}
@@ -1,38 +0,0 @@
package com.shuwei.dish.match.scale
/**
* 秤事件数据类,用于主设备和子设备之间的非重量类通知
* @param type 事件类型,参见 companion object 中的常量
* @param deviceId 发送方设备 ID
* @param address 触发事件的秤地址,TYPE_SEASONING_CONFIG 时为 0(无意义)
* @param delta 重量变化量(克),仅 TYPE_SEASONING_ADDED 时有意义
* @param slots 调料槽位配置列表,仅 TYPE_SEASONING_CONFIG 时有效
*/
data class ScaleEvent(
val type: String,
val deviceId: String,
val address: Int = 0,
val delta: Double = 0.0,
val slots: List<SlotConfig>? = null
) {
/**
* 单个槽位的配置信息,随 TYPE_SEASONING_CONFIG 事件一起传输
* @param deviceId 所属设备 ID
* @param address 秤硬件地址
* @param goodsId 调料 ID
* @param goodsName 调料名称
*/
data class SlotConfig(
val deviceId: String,
val address: Int,
val goodsId: String,
val goodsName: String
)
companion object {
/** 调料添加事件:某秤重量增加超过阈值 */
const val TYPE_SEASONING_ADDED = "seasoning_added"
/** 调料槽位配置同步事件:主设备配置变更后广播给子设备 */
const val TYPE_SEASONING_CONFIG = "seasoning_config"
}
}
@@ -1,245 +0,0 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.util.Log
import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* 秤服务统一门面(单例)
* 根据当前设备角色(MASTER/SLAVE)启动对应的服务组合:
*
* 所有设备均运行:
* - MdnsRegisterManager:将本机 WebSocket 服务广播到局域网
* - ScaleWebSocketServer:对外提供本机秤数据推送服务
*
* 仅主设备(MASTER)额外运行:
* - MdnsDiscoveryManager:发现局域网内子设备
* - ScaleWebSocketClient:连接子设备 WebSocket
* - ScaleDataAggregator:聚合本机 + 远端所有秤数据
*
* 使用方式:
* 在 Application.onCreate() 中调用 ScaleServiceManager.start(context)
* 在 Application.onTerminate() 中调用 ScaleServiceManager.stop()
* 主设备 UI 通过 ScaleServiceManager.allScales 订阅全量秤数据
*/
object ScaleServiceManager {
private const val TAG = "ScaleServiceManager"
private var mdnsRegister: MdnsRegisterManager? = null
private var wsServer: ScaleWebSocketServer? = null
private var udpSender: UdpBroadcastSender? = null
// 以下仅主设备使用
private var mdnsDiscovery: MdnsDiscoveryManager? = null
private var udpReceiver: UdpBroadcastReceiver? = null
private var wsClient: ScaleWebSocketClient? = null
private var aggregator: ScaleDataAggregator? = null
/**
* 所有秤数据的实时流,仅主设备有效;子设备返回 null
* UI 层通过 lifecycleScope.launch { allScales?.collect { ... } } 订阅
*/
val allScales: StateFlow<Map<String, ScaleData>>?
get() = aggregator?.allScales
/**
* 收到子设备秤事件(如调料添加)时的回调,仅主设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
private var _onScaleEvent: ((ScaleEvent) -> Unit)? = null
var onScaleEvent: ((ScaleEvent) -> Unit)?
get() = _onScaleEvent
set(value) {
_onScaleEvent = value
wsClient?.onScaleEvent = value
}
/**
* 收到主设备下发的调料配置同步事件时的回调,仅子设备有效
* 在子线程中调用,需自行切换到主线程更新 UI
*/
var onSeasoningConfig: ((ScaleEvent) -> Unit)?
get() = wsServer?.onSeasoningConfig
set(value) { wsServer?.onSeasoningConfig = value }
/**
* 向所有已连接子设备广播调料配置(主设备调用)
* @param slots 全量槽位配置列表
*/
fun sendSeasoningConfig(slots: List<ScaleEvent.SlotConfig>) {
wsClient?.sendToAllSlaves(buildConfigEvent(slots))
}
/**
* 向单台刚连接的子设备推送调料配置(连接时按需调用)
* @param deviceId 目标子设备 ID
*/
private fun sendSeasoningConfigTo(deviceId: String, slots: List<ScaleEvent.SlotConfig>) {
wsClient?.sendToDevice(deviceId, buildConfigEvent(slots))
}
private fun buildConfigEvent(slots: List<ScaleEvent.SlotConfig>) = ScaleEvent(
type = ScaleEvent.TYPE_SEASONING_CONFIG,
deviceId = GlobalData.deviceId,
slots = slots
)
/**
* 向主设备广播秤事件(子设备调用)
* @param event 要广播的事件
*/
fun broadcastEvent(event: ScaleEvent) {
wsServer?.broadcastEvent(event)
}
/**
* 主设备连接状态回调(仅子设备使用)
* 设置后会立即转发给 wsServer;需在 start() 之后设置才能生效
*/
var onMasterConnectionChanged: ((connected: Boolean) -> Unit)?
get() = wsServer?.onConnectionChanged
set(value) { wsServer?.onConnectionChanged = value }
/** 当前是否有主设备连接(仅子设备使用) */
val isMasterConnected: Boolean
get() = wsServer?.isConnected == true
/**
* 启动所有秤服务,在 Application.onCreate() 中调用
*/
fun start(context: Context) {
val deviceId = GlobalData.deviceId
val role = GlobalData.deviceRole
Log.d(TAG, "启动秤服务, deviceId=$deviceId, role=$role")
// 所有设备:启动 WebSocket 服务端 + mDNS 注册
wsServer = ScaleWebSocketServer(deviceId, context).also { it.start() }
mdnsRegister = MdnsRegisterManager(context).also { it.register(deviceId) }
if (role == DeviceRole.MASTER) {
startMasterServices(context, deviceId)
} else {
// 子设备:启动 UDP 广播,作为 mDNS 的兜底发现机制
val localIp = getLocalIp(context)
udpSender = UdpBroadcastSender(deviceId, localIp).also { it.start() }
}
}
/**
* 获取本机局域网 IP,委托给 NetworkUtil 统一处理版本兼容
*/
private fun getLocalIp(context: Context): String {
return NetworkUtil.getLocalIpAddress(context)
}
/**
* 启动主设备专属服务
*/
private fun startMasterServices(context: Context, deviceId: String) {
// 聚合器:统一管理本机 + 远端秤数据
aggregator = ScaleDataAggregator(deviceId).also {
// 记录本机 IP,供本机秤数据展示使用
it.setDeviceIp(deviceId, getLocalIp(context))
it.startLocalCollection()
}
// WebSocket 客户端
val client = ScaleWebSocketClient().also {
it.onScaleData = { data -> aggregator?.onRemoteScaleData(data) }
it.onScaleEvent = { event -> _onScaleEvent?.invoke(event) }
it.onDeviceDisconnected = { remoteId -> aggregator?.removeDevice(remoteId) }
it.onDeviceConnected = { remoteId ->
// 子设备连接成功时,从 Room 读取全量配置并单独推送给该设备
CoroutineScope(Dispatchers.IO).launch {
val slots = BaseApp.instance!!.database.seasoningSlotDao()
.queryAll()
.map { slot ->
ScaleEvent.SlotConfig(
slot.deviceId, slot.address, slot.goodsId, slot.goodsName
)
}
if (slots.isNotEmpty()) sendSeasoningConfigTo(remoteId, slots)
}
}
}
wsClient = client
// 统一的设备发现处理:mDNS 和 UDP 共用同一逻辑
fun onDeviceFound(remoteId: String, host: String, port: Int) {
if (remoteId == deviceId) return
Log.d(TAG, "发现子设备 $remoteId ($host:$port), 建立连接")
aggregator?.setDeviceIp(remoteId, host)
client.connect(remoteId, host, port)
}
// mDNS 发现:发现子设备 → 建立 WebSocket 连接;设备离线 → 仅清除 UDP 缓存,WebSocket 层自行重连
mdnsDiscovery = MdnsDiscoveryManager(context).also { discovery ->
discovery.onDeviceFound = { remoteId, host, port ->
onDeviceFound(remoteId, host, port)
}
discovery.onDeviceLost = { remoteId ->
// mDNS 抖动不永久断开,WebSocket 层会持续重连直到网络恢复
// 仅清除 UDP 缓存,确保 UDP 重新发现时能再次触发 onDeviceFound
udpReceiver?.removeDevice(remoteId)
Log.d(TAG, "子设备 mDNS 离线: $remoteId, 保持 WebSocket 重连")
}
discovery.startDiscovery()
}
// UDP 广播接收:作为 mDNS 的兜底,发现 mDNS 未能触发的子设备
udpReceiver = UdpBroadcastReceiver().also { receiver ->
receiver.onDeviceFound = { remoteId, host, port ->
onDeviceFound(remoteId, host, port)
}
receiver.start()
}
}
/**
* 向指定设备的指定秤发送清零指令
* - 本机秤:直接调用 WeightUtil.tareTwo
* - 子设备秤:通过 WebSocket 发送指令
* @param deviceId 目标设备 ID
* @param address 目标秤地址
*/
fun sendTare(deviceId: String, address: Int): Boolean {
return if (deviceId == GlobalData.deviceId) {
WeightUtil.tareTwo(address)
true
} else {
val cmd = ScaleCommand(ScaleCommand.CMD_TARE, deviceId, address)
wsClient?.sendCommand(deviceId, cmd) ?: false
}
}
/**
* 停止所有秤服务,在 Application.onTerminate() 或退出时调用
*/
fun stop() {
mdnsDiscovery?.stopDiscovery()
udpReceiver?.stop()
udpSender?.stop()
wsClient?.shutdown()
aggregator?.stop()
wsServer?.stop()
mdnsRegister?.unregister()
mdnsDiscovery = null
udpReceiver = null
udpSender = null
wsClient = null
aggregator = null
wsServer = null
mdnsRegister = null
Log.d(TAG, "所有秤服务已停止")
}
}
@@ -1,202 +0,0 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
* 秤数据 WebSocket 客户端连接管理器(仅主设备运行)
* 管理与多台子设备的 WebSocket 长连接,支持断线自动重连(指数退避)
*/
class ScaleWebSocketClient {
companion object {
private const val TAG = "ScaleWebSocketClient"
/** 初始重连间隔(毫秒) */
private const val RECONNECT_BASE_MS = 2_000L
/** 最大重连间隔(毫秒) */
private const val RECONNECT_MAX_MS = 30_000L
}
private val gson = Gson()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // WebSocket 长连接不设置读取超时
.pingInterval(20, TimeUnit.SECONDS) // 心跳保活
.build()
/** 各设备当前 WebSocket 连接,key = deviceId */
private val connections = ConcurrentHashMap<String, WebSocket>()
/** 各设备当前重连任务,key = deviceId */
private val reconnectTasks = ConcurrentHashMap<String, ScheduledFuture<*>>()
/** 各设备当前重连延迟,key = deviceId */
private val reconnectDelays = ConcurrentHashMap<String, Long>()
/** 已主动移除的设备集合,不对其触发重连 */
private val removedDevices = ConcurrentHashMap.newKeySet<String>()
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
/** 收到秤数据时的回调,在子线程调用 */
var onScaleData: ((data: ScaleData) -> Unit)? = null
/** 收到秤事件(如调料添加通知)时的回调,在子线程调用 */
var onScaleEvent: ((event: ScaleEvent) -> Unit)? = null
/** 设备断线时的回调(连接失败或关闭),在子线程调用 */
var onDeviceDisconnected: ((deviceId: String) -> Unit)? = null
/** 子设备首次连接成功时的回调,在子线程调用;可用于主设备主动推送全量配置 */
var onDeviceConnected: ((deviceId: String) -> Unit)? = null
/**
* 连接到指定子设备
* @param deviceId 子设备 ID
* @param host 子设备 IP
* @param port 子设备 WebSocket 端口
*/
fun connect(deviceId: String, host: String, port: Int) {
removedDevices.remove(deviceId)
// 取消旧 IP 的重连任务,防止 IP 变化时新旧任务并发竞争
reconnectTasks.remove(deviceId)?.cancel(false)
reconnectDelays[deviceId] = RECONNECT_BASE_MS
doConnect(deviceId, host, port)
}
private fun doConnect(deviceId: String, host: String, port: Int) {
val url = "ws://$host:$port"
val request = Request.Builder().url(url).build()
val ws = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "已连接子设备: $deviceId ($url)")
connections[deviceId] = webSocket
reconnectDelays[deviceId] = RECONNECT_BASE_MS
reconnectTasks.remove(deviceId)?.cancel(false)
onDeviceConnected?.invoke(deviceId)
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
// 通过 type 字段区分 ScaleEvent 与 ScaleData
val raw = gson.fromJson(text, Map::class.java)
if (raw.containsKey("type")) {
val event = gson.fromJson(text, ScaleEvent::class.java)
onScaleEvent?.invoke(event)
} else {
val data = gson.fromJson(text, ScaleData::class.java)
onScaleData?.invoke(data)
}
} catch (e: Exception) {
Log.w(TAG, "解析消息失败: ${e.message}")
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "连接子设备失败: $deviceId, ${t.message}")
connections.remove(deviceId)
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "子设备连接已关闭: $deviceId, reason=$reason")
connections.remove(deviceId)
onDeviceDisconnected?.invoke(deviceId)
scheduleReconnect(deviceId, host, port)
}
})
connections[deviceId] = ws
}
/**
* 指数退避重连
* 入口不拦截 removedDevices,仅在延迟任务执行前检查,
* 确保 disconnect() 后已入队的任务不执行,但新断线事件始终能进入重连流程
*/
private fun scheduleReconnect(deviceId: String, host: String, port: Int) {
val delay = reconnectDelays[deviceId] ?: RECONNECT_BASE_MS
Log.d(TAG, "将在 ${delay}ms 后重连子设备: $deviceId")
val future = scheduler.schedule({
if (!removedDevices.contains(deviceId)) {
doConnect(deviceId, host, port)
}
}, delay, TimeUnit.MILLISECONDS)
reconnectTasks[deviceId]?.cancel(false)
reconnectTasks[deviceId] = future
// 下次重连延迟翻倍,上限 30s
reconnectDelays[deviceId] = minOf(delay * 2, RECONNECT_MAX_MS)
}
/**
* 向指定子设备发送控制指令
* @param deviceId 目标子设备 ID
* @param command 指令对象
* @return true=发送成功,false=设备未连接
*/
fun sendCommand(deviceId: String, command: ScaleCommand): Boolean {
val ws = connections[deviceId] ?: return false
return ws.send(gson.toJson(command))
}
/**
* 向所有已连接的子设备广播事件(主设备调用,用于配置同步等场景)
* @param event 要广播的事件
*/
fun sendToAllSlaves(event: ScaleEvent) {
val json = gson.toJson(event)
connections.values.forEach { ws ->
try { ws.send(json) } catch (e: Exception) {
Log.w(TAG, "sendToAllSlaves 失败: ${e.message}")
}
}
}
/**
* 向指定子设备发送事件(用于连接时单独推送配置)
* @param deviceId 目标子设备 ID
* @param event 要发送的事件
*/
fun sendToDevice(deviceId: String, event: ScaleEvent) {
val ws = connections[deviceId] ?: return
try { ws.send(gson.toJson(event)) } catch (e: Exception) {
Log.w(TAG, "sendToDevice $deviceId 失败: ${e.message}")
}
}
/**
* 断开指定子设备连接(不再重连)
* @param deviceId 子设备 ID
*/
fun disconnect(deviceId: String) {
removedDevices.add(deviceId)
reconnectTasks.remove(deviceId)?.cancel(false)
connections.remove(deviceId)?.close(1000, "设备离线")
reconnectDelays.remove(deviceId)
Log.d(TAG, "已断开子设备: $deviceId")
}
/**
* 关闭所有连接并释放资源,在应用退出时调用
*/
fun shutdown() {
val ids = connections.keys.toList()
ids.forEach { disconnect(it) }
scheduler.shutdownNow()
httpClient.dispatcher.executorService.shutdown()
Log.d(TAG, "ScaleWebSocketClient 已关闭")
}
}
@@ -1,178 +0,0 @@
package com.shuwei.dish.match.scale
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.shuwei.dish.match.utils.NetworkUtil
import com.shuwei.dish.match.utils.WeightUtil
import org.java_websocket.WebSocket
import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
/**
* 秤数据 WebSocket 服务端(主设备和子设备均运行)
* 监听 WeightUtil 回调,将本机秤数据实时推送给所有已连接的主设备客户端
*/
class ScaleWebSocketServer(private val deviceId: String, private val context: Context) {
companion object {
private const val TAG = "ScaleWebSocketServer"
/** 节流间隔:两次推送之间的最小间隔(毫秒) */
private const val THROTTLE_MS = 100L
}
private val gson = Gson()
private var server: InternalServer? = null
/** 主设备连接状态变化回调:true=已连接,false=已断开;在主线程外调用,需自行切换线程 */
var onConnectionChanged: ((connected: Boolean) -> Unit)? = null
/** 收到主设备下发的调料配置同步事件时的回调,在子线程调用 */
var onSeasoningConfig: ((event: ScaleEvent) -> Unit)? = null
/** 当前是否有主设备连接 */
val isConnected: Boolean
get() = server?.connections?.isNotEmpty() == true
/** 每个秤地址的最近一次推送时间,用于节流 */
private val lastPushTime = mutableMapOf<Int, Long>()
/** 缓存各秤最新数据,新客户端连接时立即同步当前状态 */
private val latestData = mutableMapOf<Int, ScaleData>()
/**
* 启动 WebSocket 服务器,并注册 WeightUtil 回调
*/
fun start() {
server = InternalServer(MdnsRegisterManager.WS_PORT)
server?.isReuseAddr = true
server?.start()
Log.d(TAG, "WebSocket 服务端已启动,端口 ${MdnsRegisterManager.WS_PORT}")
// 注册本机秤数据监听
WeightUtil.addWeightListener(TAG) { address, state, weight ->
val now = System.currentTimeMillis()
// 节流:同一地址 100ms 内只推送一次
if (now - (lastPushTime[address] ?: 0L) < THROTTLE_MS) return@addWeightListener
lastPushTime[address] = now
val data = ScaleData(
deviceId = deviceId,
address = address,
weight = weight,
state = state,
ts = now,
ip = NetworkUtil.getLocalIpAddress(context)
)
latestData[address] = data
broadcast(gson.toJson(data))
}
}
/**
* 停止 WebSocket 服务器,移除秤监听
*/
fun stop() {
WeightUtil.removeWeightListener(TAG)
onConnectionChanged = null
onSeasoningConfig = null
try {
server?.stop(1000)
} catch (e: Exception) {
Log.e(TAG, "停止服务端异常: ${e.message}")
}
server = null
latestData.clear()
lastPushTime.clear()
Log.d(TAG, "WebSocket 服务端已停止")
}
/**
* 向所有已连接的主设备广播秤事件(如调料添加通知)
* @param event 要广播的事件
*/
fun broadcastEvent(event: ScaleEvent) {
broadcast(gson.toJson(event))
}
/**
* 向所有已连接客户端广播消息
*/
private fun broadcast(json: String) {
server?.connections?.forEach { conn ->
if (conn.isOpen) {
try {
conn.send(json)
} catch (e: Exception) {
Log.w(TAG, "推送失败: ${e.message}")
}
}
}
}
/**
* 内部 WebSocket 服务器实现
*/
private inner class InternalServer(port: Int) :
WebSocketServer(InetSocketAddress(port)) {
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
Log.d(TAG, "主设备已连接: ${conn.remoteSocketAddress}")
onConnectionChanged?.invoke(true)
// 新客户端连接后,立即推送所有秤的最新数据
latestData.values.forEach { data ->
try {
conn.send(gson.toJson(data))
} catch (e: Exception) {
Log.w(TAG, "初始推送失败: ${e.message}")
}
}
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
Log.d(TAG, "主设备已断开: ${conn.remoteSocketAddress}, reason=$reason")
// 若已无任何连接,通知子设备 UI 更新为未连接状态
if (server?.connections.isNullOrEmpty()) {
onConnectionChanged?.invoke(false)
}
}
override fun onMessage(conn: WebSocket, message: String) {
try {
val raw = gson.fromJson(message, Map::class.java)
if (raw.containsKey("type")) {
// 主设备下发的事件(如调料配置同步)
val event = gson.fromJson(message, ScaleEvent::class.java)
when (event.type) {
ScaleEvent.TYPE_SEASONING_CONFIG -> onSeasoningConfig?.invoke(event)
}
return
}
val cmd = gson.fromJson(message, ScaleCommand::class.java)
// 校验指令目标设备是否为本机
if (cmd.deviceId != deviceId) {
Log.d(TAG, "收到清零指令, 但设备号不正确, deviceId=${cmd.deviceId}")
return
}
when (cmd.cmd) {
ScaleCommand.CMD_TARE -> {
Log.d(TAG, "收到清零指令, address=${cmd.address}")
WeightUtil.tareTwo(cmd.address)
}
}
} catch (e: Exception) {
Log.w(TAG, "收到消息, 解析失败: ${e.message}")
}
}
override fun onError(conn: WebSocket?, ex: Exception) {
Log.e(TAG, "WebSocket 服务端错误: ${ex.message}")
}
override fun onStart() {
Log.d(TAG, "WebSocket 服务端 onStart")
connectionLostTimeout = 60
}
}
}
@@ -1,95 +0,0 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/**
* UDP 广播接收器(主设备运行)
* 监听子设备发来的 UDP 广播包,解析后通过回调通知连接管理器
* 与 mDNS 互为备份,任意一条路发现子设备均可建立 WebSocket 连接
*/
class UdpBroadcastReceiver {
companion object {
private const val TAG = "UdpBroadcastReceiver"
private const val BUFFER_SIZE = 1024
}
private val gson = Gson()
private val executor = Executors.newSingleThreadExecutor()
/** 已发现的设备集合,避免重复触发回调,key=deviceId, value="host:port" */
private val knownDevices = ConcurrentHashMap<String, String>()
@Volatile
private var running = false
private var socket: DatagramSocket? = null
/**
* 发现新设备时的回调:(deviceId, host, port)
* 与 MdnsDiscoveryManager.onDeviceFound 签名一致,可共用同一处理逻辑
*/
var onDeviceFound: ((deviceId: String, host: String, port: Int) -> Unit)? = null
/**
* 开始监听 UDP 广播
*/
fun start() {
if (running) return
running = true
executor.execute { listenLoop() }
Log.d(TAG, "UDP 广播接收已启动, 监听端口=${UdpBroadcastSender.BROADCAST_PORT}")
}
private fun listenLoop() {
try {
socket = DatagramSocket(UdpBroadcastSender.BROADCAST_PORT).also { it.broadcast = true }
val buf = ByteArray(BUFFER_SIZE)
while (running) {
val packet = DatagramPacket(buf, buf.size)
socket?.receive(packet) ?: break
val json = String(packet.data, 0, packet.length, Charsets.UTF_8)
handlePacket(json)
}
} catch (e: Exception) {
if (running) Log.w(TAG, "UDP 接收异常: ${e.message}")
} finally {
socket?.close()
socket = null
}
}
private fun handlePacket(json: String) {
try {
val payload = gson.fromJson(json, UdpBroadcastSender.Payload::class.java)
val key = "${payload.ip}:${payload.port}"
// 同一设备已通知过则跳过,避免每5秒重复触发连接
if (knownDevices.put(payload.deviceId, key) == key) return
Log.d(TAG, "UDP 发现设备: ${payload.deviceId} -> ${payload.ip}:${payload.port}")
onDeviceFound?.invoke(payload.deviceId, payload.ip, payload.port)
} catch (e: Exception) {
Log.w(TAG, "解析 UDP 广播包失败: ${e.message}")
}
}
/**
* 移除已知设备记录,设备离线时调用,确保设备重新上线后能再次触发回调
*/
fun removeDevice(deviceId: String) {
knownDevices.remove(deviceId)
}
/**
* 停止监听,释放资源
*/
fun stop() {
running = false
socket?.close()
executor.shutdownNow()
Log.d(TAG, "UDP 广播接收已停止")
}
}
@@ -1,76 +0,0 @@
package com.shuwei.dish.match.scale
import android.util.Log
import com.google.gson.Gson
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
/**
* UDP 广播发送器(子设备运行)
* 每隔固定间隔向局域网广播本机的 deviceId、IP、WebSocket 端口
* 作为 mDNS 的兜底发现机制,确保主设备能可靠发现子设备
*/
class UdpBroadcastSender(
private val deviceId: String,
private val localIp: String,
private val wsPort: Int = MdnsRegisterManager.WS_PORT
) {
companion object {
private const val TAG = "UdpBroadcastSender"
/** UDP 广播目标端口,与 UdpBroadcastReceiver 保持一致 */
const val BROADCAST_PORT = 8766
/** 广播间隔(秒) */
private const val INTERVAL_SEC = 5L
}
private val gson = Gson()
private val scheduler = Executors.newSingleThreadScheduledExecutor()
private var task: ScheduledFuture<*>? = null
/** 广播数据包结构 */
data class Payload(val deviceId: String, val ip: String, val port: Int)
/**
* 开始定时广播
*/
fun start() {
if (task != null) return
task = scheduler.scheduleWithFixedDelay({
sendBroadcast()
}, 0, INTERVAL_SEC, TimeUnit.SECONDS)
Log.d(TAG, "UDP 广播已启动, deviceId=$deviceId, ip=$localIp, port=$wsPort")
}
private fun sendBroadcast() {
try {
val payload = gson.toJson(Payload(deviceId, localIp, wsPort))
val data = payload.toByteArray(Charsets.UTF_8)
DatagramSocket().use { socket ->
socket.broadcast = true
val packet = DatagramPacket(
data, data.size,
InetAddress.getByName("255.255.255.255"),
BROADCAST_PORT
)
socket.send(packet)
}
} catch (e: Exception) {
Log.w(TAG, "UDP 广播发送失败: ${e.message}")
}
}
/**
* 停止广播,释放资源
*/
fun stop() {
task?.cancel(false)
task = null
scheduler.shutdownNow()
Log.d(TAG, "UDP 广播已停止")
}
}
@@ -1,192 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Typeface
import android.os.Bundle
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.inputmethod.EditorInfo
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.CollectedFoodAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityCollectedFoodBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.entity.CollectedFoodItem
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.appendText
import com.shuwei.dish.match.utils.ext.buildSpannableString
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.launch
/**
* 已采集食材列表页面
* 展示所有已采集的食材及其采集数量,支持搜索和删除
*/
class CollectedFoodActivity : BaseActivity() {
private lateinit var binding: ActivityCollectedFoodBinding
private val foodList = mutableListOf<CollectedFoodItem>()
private var emptyViewBinding: LayoutEmptyViewBinding? = null
private val adapter by lazy {
CollectedFoodAdapter(foodList).apply {
isStateViewEnable = true
// 点击删除按钮,移除对应食材
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
showDeleteDialog(foodList[position].foodName)
}
setOnItemClickListener { _, view, _ ->
KeyboardUtil.hideKeyboard(view)
}
}
}
/**
* 删除弹窗
*/
private fun showDeleteDialog(foodName: String) {
val showFoodName = foodName.split("WP").first()
val separator = if (showFoodName.length <= 10) "\n" else ""
val content = buildSpannableString {
appendText("食材:")
appendText(showFoodName, StyleSpan(Typeface.BOLD), ForegroundColorSpan(Color.BLACK))
appendText(" 删除后无法恢复,${separator}确认要删除吗?")
}
CommonDialog(this)
.setTitle("删除提示")
.setContent(content)
.setNegativeButton("取消")
.setPositiveButton("确认") {
appViewModel.removeCollectedFood(
foodName = foodName,
nameFilter = binding.editSearch.text.toString()
)
}
.show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
statusBarDarkFont(enable = true)
binding = ActivityCollectedFoodBinding.inflate(layoutInflater)
setContentView(binding.root)
setHeaderBackground()
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
}
setTitleBar(titleBarAction = {
it.visible()
it.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
}
}, titleAction = {
it.text = "已采集食材"
}, rightIconActon = {
it.gone()
}, backAction = {
it.visible()
it.setOnClickListener {
finish()
}
})
initRecyclerView()
initSearch()
initObserver()
loadCollectedFoodList()
}
private fun loadCollectedFoodList(name: String = "") {
appViewModel.loadCollectedFoodList(nameFilter = name)
}
/**
* 收集 searchFoodState,统一处理 Loading / Success / Error 状态
*/
@SuppressLint("NotifyDataSetChanged")
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
appViewModel.collectedFoodListState.collect { items ->
foodList.clear()
foodList.addAll(items)
adapter.notifyDataSetChanged()
if (items.isEmpty()) {
loadEmptyView()
}
}
}
}
}
/** 展示空视图 */
private fun loadEmptyView() {
if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate(
LayoutInflater.from(this), binding.rvCollectedFood, false
)
}
emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "未查询到食材信息"
adapter.stateView = emptyViewBinding!!.root
}
/** 初始化列表 */
private fun initRecyclerView() {
binding.rvCollectedFood.run {
layoutManager = LinearLayoutManager(this@CollectedFoodActivity)
adapter = this@CollectedFoodActivity.adapter
}
}
/** 初始化搜索栏交互 */
private fun initSearch() {
binding.btnSearch.setOnClickListener { v ->
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
}
binding.editSearch.run {
addTextChangedListener {
performSearch {
loadCollectedFoodList()
}
}
setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
performSearch {
toast(binding.editSearch.hint.toString())
}
KeyboardUtil.hideKeyboard(v)
true
} else {
false
}
}
}
}
/** 根据关键词过滤食材列表 */
@SuppressLint("NotifyDataSetChanged")
private fun performSearch(action: () -> Unit) {
val keyword = binding.editSearch.text.toString().trim()
if (keyword.isEmpty()) {
action()
return
}
// 根据关键词从数据库查询并刷新列表
loadCollectedFoodList(keyword)
}
}
@@ -1,345 +1,282 @@
//package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
//
//import android.graphics.Typeface import android.graphics.Typeface
//import android.os.Bundle import android.os.Bundle
//import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
//import android.text.style.AbsoluteSizeSpan import android.text.style.AbsoluteSizeSpan
//import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
//import android.text.style.LineHeightSpan import android.text.style.LineHeightSpan
//import android.text.style.StyleSpan import android.text.style.StyleSpan
//import android.util.Log import android.util.Log
//import android.util.SparseArray import android.util.SparseArray
//import android.widget.FrameLayout import android.widget.FrameLayout
//import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
//import androidx.core.view.forEach import androidx.core.view.forEach
//import androidx.lifecycle.ViewModelProvider import androidx.core.content.ContextCompat
//import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.R
//import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseActivity
//import com.shuwei.dish.match.db.DbRepository import com.shuwei.dish.match.db.entity.SeasoningEntity
//import com.shuwei.dish.match.dialog.CommonDialog import com.shuwei.dish.match.utils.AddressUtil
//import com.shuwei.dish.match.entity.SeasoningEntity import com.shuwei.dish.match.utils.WeightUtil
//import com.shuwei.dish.match.entity.ResetReasoningRecord import com.shuwei.dish.match.utils.ext.appendText
//import com.shuwei.dish.match.utils.AddressUtil import com.shuwei.dish.match.utils.ext.buildSpannableString
//import com.shuwei.dish.match.utils.JsonAssetsLoader import com.shuwei.dish.match.utils.ext.dp
//import com.shuwei.dish.match.utils.MultiClickDetector import com.shuwei.dish.match.utils.ext.startActivity
//import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.ext.toast
//import com.shuwei.dish.match.utils.ext.appendText import com.shuwei.dish.match.utils.ext.visible
//import com.shuwei.dish.match.utils.ext.buildSpannableString import com.shuwei.dish.match.databinding.ActivityDeviceConfigBinding
//import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.dialog.BottomDialog2
//import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.model.GoodsItem
//import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.SpTool
//import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.TextCellTool
//import com.shuwei.dish.match.adapter.TextCellAdapter import com.shuwei.dish.match.utils.ext.clickWithDebounce
//import com.shuwei.dish.match.databinding.ActivityDeviceConfigBinding import com.shuwei.dish.match.utils.ext.gone
//import com.shuwei.dish.match.dialog.SeasoningSearchDialog
//import com.shuwei.dish.match.utils.SpTool class DeviceConfigActivity : BaseActivity() {
//import com.shuwei.dish.match.utils.ext.clickWithDebounce
//import com.shuwei.dish.match.utils.ext.gone companion object {
//import com.shuwei.dish.match.utils.ext.toJsonString
// const val TAG = "DeviceSettingActivity"
//class DeviceConfigActivity : BaseActivity() {
// const val COOK_MODE = "cookMode"
// companion object {
// val modeList = listOf<ModeBean>(
// const val TAG = "DeviceConfigActivity" ModeBean("制作模式", "菜品快速制作,记录熟重"),
// ModeBean("采样模式", "菜品快速制作,记录熟重"),
// const val COOK_MODE = "cookMode" ModeBean("品控模式", "菜品快速制作,记录熟重")
// )
// val modeList = listOf<ModeBean>( }
// ModeBean("制作模式", "菜品快速制作,记录熟重"),
// ModeBean("采样模式", "菜品快速制作,记录熟重"), data class ModeBean(
// ModeBean("品控模式", "菜品快速制作,记录熟重") var modeName: String,
// ) var modeDesc: String
// } )
//
// data class ModeBean( private lateinit var binding: ActivityDeviceConfigBinding
// var modeName: String,
// var modeDesc: String private var cookMode: Int = 0
// )
// // private val weightArray = SparseIntArray()
// private lateinit var binding: ActivityDeviceConfigBinding private val weightArray = SparseArray<Double>()
// private val addressArray = AddressUtil.getWeighAddressArray()
// private lateinit var appViewModel: DbViewModel
// override fun onCreate(savedInstanceState: Bundle?) {
// private var cookMode: Int = 0 super.onCreate(savedInstanceState)
//// private val weightArray = SparseIntArray() binding = ActivityDeviceConfigBinding.inflate(layoutInflater)
// private val weightArray = SparseArray<Double>() setContentView(binding.root)
// private val addressArray = AddressUtil.getWeighAddressArray() cookMode = intent.getIntExtra(COOK_MODE, 0)
// setHeaderBackground()
// override fun onCreate(savedInstanceState: Bundle?) { setTitleBar(titleBarAction = {
// super.onCreate(savedInstanceState) it.visible()
// binding = ActivityDeviceConfigBinding.inflate(layoutInflater) }, titleAction = {
// setContentView(binding.root) it.text = "设备配置"
// cookMode = intent.getIntExtra(COOK_MODE, 0) }, rightIconActon = {
// initViewModel() it.gone()
// setHeaderBackground() })
// setTitleBar(titleBarAction = {
// it.visible() loadQualitySpan(isEnable = false)
// }, titleAction = { when (cookMode) {
// it.text = "设备配置" 1 -> {
// }, rightIconActon = { binding.rbModeCook.isChecked = true
// it.gone() binding.rbModeSampling.isChecked = false
//// it.alpha = 0.0F loadCookSpan(isClicked = true)
//// it.setImageResource(R.drawable.ic_setting) loadSamplingSpan()
//// it.setOnClickListener {_-> }
//// detector.setOnDelayedMultiClickListener(it) {
//// defaultDataSettingDialog() 2 -> {
//// } binding.rbModeCook.isChecked = false
//// } binding.rbModeSampling.isChecked = true
// }) loadCookSpan()
// loadSamplingSpan(isClicked = true)
// loadQualitySpan(isEnable = false) }
// when (cookMode) {
// 1 -> { else -> {
// binding.rbModeCook.isChecked = true binding.rbModeCook.isChecked = true
// binding.rbModeSampling.isChecked = false binding.rbModeSampling.isChecked = false
// loadCookSpan(isClicked = true) loadCookSpan(isClicked = true)
// loadSamplingSpan() loadSamplingSpan()
// } }
// }
// 2 -> {
// binding.rbModeCook.isChecked = false binding.rbModeCook.setOnClickListener {
// binding.rbModeSampling.isChecked = true SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0)
// loadCookSpan() binding.rbModeCook.isChecked = true
// loadSamplingSpan(isClicked = true) binding.rbModeSampling.isChecked = false
// } loadCookSpan(isClicked = true)
// loadSamplingSpan()
// else -> { startActivity<SelectDishActivity> { }
// binding.rbModeCook.isChecked = true finish()
// binding.rbModeSampling.isChecked = false }
// loadCookSpan(isClicked = true) binding.rbModeSampling.setOnClickListener {
// loadSamplingSpan() SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1)
// } binding.rbModeCook.isChecked = false
// } binding.rbModeSampling.isChecked = true
// loadCookSpan()
// binding.rbModeCook.setOnClickListener { loadSamplingSpan(isClicked = true)
// SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0) startActivity<SamplingListActivity> { }
// binding.rbModeCook.isChecked = true finish()
// binding.rbModeSampling.isChecked = false }
// loadCookSpan(isClicked = true) addWeighListener()
// loadSamplingSpan() addGridItemListener()
// startActivity<SelectDishActivity> { loadSeasoning()
// putExtra(SelectDishActivity.IS_CONFIG_PAGE, true) }
// }
// finish() private fun addWeighListener() {
// } WeightUtil.addWeightListener(
// binding.rbModeSampling.setOnClickListener { weightKey = TAG,
// SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1) getWeight = { address, state, weight ->
// binding.rbModeCook.isChecked = false Log.d(TAG, "addWeighListener: address=$address,stat=$state,weight=$weight")
// binding.rbModeSampling.isChecked = true weightArray.put(address, weight)
// loadCookSpan() val item = seasoningItems.firstOrNull { address == addressArray[it.sort] }
// loadSamplingSpan(isClicked = true) item?.let {
// startActivity<SamplingListActivity> { it.useWeight = weight
// putExtra(SamplingListActivity.IS_CONFIG_PAGE, true) } updateGridData(it)
// finish() // 根据是否量程溢出切换格子背景色
// } val frameLayout = binding.include.root.findViewWithTag<FrameLayout>(it.sort.toString())
// addWeighListener() frameLayout?.background = if (state == WeightUtil.STATE_OVER_WEIGHT) {
// addGridItemListener() ContextCompat.getDrawable(this, R.drawable.shape_red_10_corners)
// loadSeasoning() } else {
// } ContextCompat.getDrawable(this, R.drawable.selector_text_cell)
// }
// private fun addWeighListener() { }
// WeightUtil.addWeightListener( Log.d(TAG, "addWeighListener: update-----------------------")
// weightKey = TAG, })
// getWeight = { address, state, weight -> }
// Log.d(TAG, "addWeighListener: address=$address,stat=$state,weight=$weight")
// weightArray.put(address, weight) private var seasoningItems = mutableListOf<SeasoningEntity>()
// val item = seasoningItems.firstOrNull { address == addressArray[it.sort] }
// item?.let { private fun updateGridData(entity: SeasoningEntity) {
// it.useWeight = weight.toDouble() val gridLayout = binding.include.root
// updateGridData(it) val tag = entity.sort.toString()
// } val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
// Log.d(TAG, "addWeighListener: update-----------------------") TextCellTool.loadLayout(frameLayout, entity)
// }) }
// }
// private fun addGridItemListener() {
// private var seasoningItems = mutableListOf<SeasoningEntity>() binding.include.root.forEach { child ->
// private fun addGridItemListener() { child.clickWithDebounce {
// binding.include.root.forEach { child -> val sort = child.tag.toString().toInt()
// child.clickWithDebounce { val entity = seasoningItems.firstOrNull { it.sort == sort }
// val sort = child.tag.toString().toInt() ?: SeasoningEntity().also { it.sort = sort }
// val entity = seasoningItems.firstOrNull { it.sort == sort } ?: SeasoningEntity() clickGridItem(sort, entity)
// clickGridItem(sort, entity) }
// } }
// } }
// }
// private fun loadSeasoning() {
// val detector = MultiClickDetector(targetCount = 10, intervalMs = 800) Log.d(TAG, "loadSeasoning,start-----------------------")
// appViewModel.loadSeasoning { list ->
// private fun defaultDataSettingDialog() { Log.d(TAG, "loadSeasoning,list.size=${list.size}-----------------------")
// CommonDialog(this) seasoningItems.clear()
// .setTitle("温馨提示") // 补全缺失的格子:对 0~11 中没有对应 sort 的位置,追加空实体
// .setContent("您好,使用默认配置作为您的调料数据,则已有配置将被清除,确认吗?") val existingSorts = list.map { it.sort }.toSet()
// .setNegativeButton("取消") for (index in 0 until 12) {
// .setPositiveButton("确认") { resetList() } if (index !in existingSorts) {
// .setOnDismissCallback { hideStatusBar() } list.add(SeasoningEntity().also { it.sort = index })
// .show() }
// } }
// seasoningItems.addAll(list)
// private fun resetList() { // 按 sort 字段找到对应 FrameLayout,填充数据
// runCatching { list.forEach { entity ->
// val record = JsonAssetsLoader.parseJsonFromAssets<ResetReasoningRecord>( val tag = entity.sort.toString()
// this, val frameLayout = binding.include.root.findViewWithTag<FrameLayout>(tag)
// "default_seasoning_list.json" frameLayout?.let { TextCellTool.loadLayout(it, entity) }
// ) }
//// val weightArray = WeightUtil.weightArray }
// appViewModel.clearAllSeasoning { }
// record?.list?.forEach { entity ->
// entity.useWeight = weightArray[addressArray[entity.sort]].toDouble() private fun clickGridItem(sort: Int, entity: SeasoningEntity) {
// appViewModel.saveSeasoning(entity) { val currentAddress = AddressUtil.getWeighAddressArray().get(sort)
// updateGridData(entity) BottomDialog2(
// } weighIndex = sort,
// } weighAddress = currentAddress,
//// loadSeasoning() clickName = entity.goodsName
// } ).show(this) { item ->
// }.onFailure { it.printStackTrace() } syncItem(entity, item)
// } entity.materialType = 3
// entity.sort = sort
// private fun initViewModel() { saveSeasoning(entity) {
// val db = BaseApp.instance!!.database val frameLayout = binding.include.root.findViewWithTag<FrameLayout>(sort.toString())
// val factory = frameLayout?.let { TextCellTool.loadLayout(it, entity) }
// AppFactory(DbRepository(db.appDao())) }
// appViewModel = }
// ViewModelProvider(this, factory)[DbViewModel::class.java] }
// }
// private fun syncItem(oldItem: SeasoningEntity, newItem: GoodsItem) {
// private fun loadSeasoning() { oldItem.run {
// appViewModel.loadSeasoning { goodsId = newItem.goodsId
// seasoningItems.clear() goodsName = newItem.goodsName
// seasoningItems.addAll(it) goodsOrRelationCode = newItem.goodsOrRelationCode
// initConfigData() relateionType = newItem.relateionType
// setGridData(seasoningItems) materialType = newItem.materialType
// } allEdible = newItem.allEdible
// } useWeight = newItem.useWeight
// popularName = newItem.popularName
// private fun initConfigData() { }
// repeat(12) { num -> }
// val firstOne = seasoningItems.firstOrNull { it.sort == num }
// if (firstOne == null) { private fun loadQualitySpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// seasoningItems.add(SeasoningEntity().also { it.sort = num }) binding.rbModeQuality.text =
// } getModeTextSpan(
// } topText = modeList[2].modeName,
// } bottomText = modeList[2].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// private fun setGridData(list: MutableList<SeasoningEntity>) { bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// Log.d(TAG, "setGridData: ${list.toJsonString()}") )
// val gridLayout = binding.include.root }
// list.forEach { entity ->
// val tag = entity.sort.toString() private fun loadSamplingSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag) binding.rbModeSampling.text =
// TextCellAdapter.loadLayout(frameLayout, entity) getModeTextSpan(
// } topText = modeList[1].modeName,
// } bottomText = modeList[1].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// private fun updateGridData(entity: SeasoningEntity) { bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// val gridLayout = binding.include.root )
// val tag = entity.sort.toString() }
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag)
// TextCellAdapter.loadLayout(frameLayout, entity) private fun loadCookSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// } binding.rbModeCook.text =
// getModeTextSpan(
// private fun clickGridItem(sort: Int, entity: SeasoningEntity) { topText = modeList[0].modeName,
// val currentAddress = AddressUtil.getWeighAddressArray().get(sort) bottomText = modeList[0].modeDesc,
//// val seasoningEntity = list.first{it.sort == sort} topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// SeasoningSearchDialog( bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// activity = this, )
// weighIndex = sort, }
// weighAddress = currentAddress,
// clickName = entity.goodsName private fun getModeTextSpan(
// ) { item -> topText: String,
// syncItem(entity, item) topColor: String,
// //3-调料 bottomText: String,
// entity.materialType = 3 bottomColor: String
// entity.sort = sort ): SpannableStringBuilder {
// saveSeasoning(entity) {
// val tag = sort.toString()
// val gridLayout = binding.include.root return buildSpannableString {
// val frameLayout = gridLayout.findViewWithTag<FrameLayout>(tag) appendText(
// TextCellAdapter.loadLayout(frameLayout, entity) topText,
// } ForegroundColorSpan(topColor.toColorInt()),
// }.show() StyleSpan(Typeface.BOLD),
// } AbsoluteSizeSpan(36, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// private fun syncItem(oldItem: SeasoningEntity, newItem: SeasoningEntity) { fm.descent += 10.dp // 增加行间距
// oldItem.run { }
// goodsId = newItem.goodsId )
// goodsName = newItem.goodsName append("\n")
// goodsOrRelationCode = newItem.goodsOrRelationCode appendText(
// relateionType = newItem.relateionType bottomText,
// materialType = newItem.materialType ForegroundColorSpan(bottomColor.toColorInt()),
// allEdible = newItem.allEdible AbsoluteSizeSpan(26, true)
// useWeight = newItem.useWeight )
// popularName = newItem.popularName }
// canteenId = newItem.canteenId }
// relateionType_dictText = newItem.relateionType_dictText
// fun saveSeasoning(entity: SeasoningEntity, action: () -> Unit = {}) {
// foodId = newItem.foodId appViewModel.saveSeasoning(entity) {
// sort = newItem.sort action()
// } toast("保存成功")
// } }
// }
// private fun loadQualitySpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// binding.rbModeQuality.text = }
// getModeTextSpan(
// topText = modeList[2].modeName,
// bottomText = modeList[2].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// )
// }
//
// private fun loadSamplingSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// binding.rbModeSampling.text =
// getModeTextSpan(
// topText = modeList[1].modeName,
// bottomText = modeList[1].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// )
// }
//
// private fun loadCookSpan(isClicked: Boolean = false, isEnable: Boolean = true) {
// binding.rbModeCook.text =
// getModeTextSpan(
// topText = modeList[0].modeName,
// bottomText = modeList[0].modeDesc,
// topColor = if (isEnable) if (isClicked) "#00BC71" else "#000000" else "#B4B4B4",
// bottomColor = if (isEnable) "#8B8B8B" else "#B4B4B4"
// )
// }
//
// private fun getModeTextSpan(
// topText: String,
// topColor: String,
// bottomText: String,
// bottomColor: String
// ): SpannableStringBuilder {
//
//
// return buildSpannableString {
// appendText(
// topText,
// ForegroundColorSpan(topColor.toColorInt()),
// StyleSpan(Typeface.BOLD),
// AbsoluteSizeSpan(36, true),
// LineHeightSpan { text, start, end, spanstartv, v, fm ->
// fm.descent += 10.dp // 增加行间距
// }
// )
// append("\n")
// appendText(
// bottomText,
// ForegroundColorSpan(bottomColor.toColorInt()),
// AbsoluteSizeSpan(26, true)
// )
// }
// }
//
// fun saveSeasoning(entity: SeasoningEntity, action: () -> Unit = {}) {
// appViewModel.saveSeasoning(entity) {
// action()
// toast("保存成功")
// }
// }
//
//}
@@ -0,0 +1,268 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.DishPartAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityDishSamplingBinding
import com.shuwei.dish.match.databinding.LayoutFoodRemindBinding
import com.shuwei.dish.match.dialog.BottomDialog
import com.shuwei.dish.match.dialog.CommonDialog
import com.shuwei.dish.match.db.entity.CookFoodEntity
import com.shuwei.dish.match.model.GoodsItem
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toJsonString
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable
class DishSamplingActivity : BaseActivity() {
private lateinit var binding: ActivityDishSamplingBinding
var foodName: String? = null
private var foodId: String? = null
companion object {
private const val TAG = "DishSamplingActivity"
public const val FOOD_NAME = "foodName"
public const val PAGE_FROM = "pageFrom"
public const val HOME = "home"
}
private var pageFrom: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDishSamplingBinding.inflate(layoutInflater)
setContentView(binding.root)
pageFrom = intent.getStringExtra(PAGE_FROM)
setTitleBar()
setHeaderBackground()
addViewClickListener()
foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName)
WeightUtil.addWeightListener(
weightKey = TAG,
getWeight = { address, state, weight ->
//Log.d(TAG, "addViewClickListener: address=$address,state=$state,weight=$weight")
if (address == AddressUtil.ONE) {
tempDishEntity?.useWeight = weight.toDouble()
binding.tvDishPartWeight.text = "${weight}"
}
})
}
private fun setTitleBar() {
setTitleBar(titleBarAction = {
it.visible()
}, titleAction = {
it.text = "菜品采集"
}, rightIconActon = {
it.gone()
it.setImageResource(R.drawable.ic_setting)
it.setOnClickListener {
//startActivity<DeviceSettingActivity>()
}
}, backAction = {
it.visible()
it.setOnClickListener {
onBackPressed()
}
})
}
fun getCookFoodList(action: (MutableList<CookFoodEntity>?) -> Unit) {
appViewModel.getCookFoodList(cookMode = 1) { action(it) }
}
@Suppress("DEPRECATION")
@SuppressLint("GestureBackNavigation")
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (list.isEmpty().not()) {
//说明有新增的食材
saveDataRemindDialog()
return
}
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
super.onBackPressed()
}
private fun saveDataRemindDialog() {
val remindBinding = LayoutFoodRemindBinding.inflate(layoutInflater)
remindBinding.tvDialogTitle.text = "温馨提示"
remindBinding.tvDialogContent.text = "您好,当前页面存在未保存的数据,确认返回吗?"
CommonDialog(this).apply {
messageView = remindBinding.root
leftText = "取消"
rightText = "确认"
onLeftClick = { dismiss() }
onRightClick = {
dismiss()
if (pageFrom == HOME) {
startActivity<SamplingListActivity>()
}
finish()
}
onDismiss = { hideStatusBar() }
}.show()
}
val list: MutableList<GoodsItem> = mutableListOf()
private var tempDishEntity: GoodsItem? = null
private var dishMaterialType = 1
private val dishAdapter by lazy {
DishPartAdapter(list).apply {
addOnItemChildClickListener(R.id.ivOperateIcon) { _, _, position ->
//删除操作
list.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, list.size - position)
}
}
}
@SuppressLint("NotifyDataSetChanged")
private fun addViewClickListener() {
binding.rvDishPartList.run {
layoutManager =
LinearLayoutManager(this@DishSamplingActivity, LinearLayoutManager.VERTICAL, false)
adapter = dishAdapter
}
binding.etInputDishType.addOnActionSearchListener {
searchDishType()
}
binding.ivDishTypeSearch.setOnClickListener {
searchDishType()
}
binding.ivWeightClear.setOnClickListener {
WeightUtil.tareTwo(AddressUtil.ONE)
}
binding.ivWeightAdd.setOnClickListener {
if (tempDishEntity == null) {
toast("请输入食材名称")
return@setOnClickListener
}
if (binding.rgSampling.checkedRadioButtonId == -1) {
toast("请选择主辅材类型")
return@setOnClickListener
}
val realWeight = tempDishEntity!!.useWeight ?: 0.0
if (realWeight <= 0.toDouble()) {
toast("食材用量需要大于0")
return@setOnClickListener
}
if (tempDishEntity?.goodsName != binding.etInputDishType.text.toString().trim()) {
toast("所选择的食材名称与输入框内容不一致")
return@setOnClickListener
}
tempDishEntity!!.apply {
isSamplingPage = true
materialType = dishMaterialType
}
val result = list.firstOrNull { it.goodsId == tempDishEntity!!.goodsId }
if (result != null) {
toast("${result.goodsName}食材已添加,请勿重复操作")
return@setOnClickListener
}
list.add(tempDishEntity!!)
dishAdapter.notifyDataSetChanged()
tempDishEntity = null
binding.etInputDishType.setText("")
binding.rgSampling.clearCheck()
}
binding.rgSampling.setOnCheckedChangeListener { group, checkedId ->
dishMaterialType = if (checkedId == R.id.rbDishTypeFirst) 1 else 2
}
binding.ivDishSearch.setOnClickListener { v ->
jumpSearch(v)
}
binding.btnCook.clickWithDebounce {
val showFoodName = binding.etInputDish.text.toString().trim()
if (showFoodName.isBlank()) {
toast("菜品名称为空")
return@clickWithDebounce
}
if (list.isEmpty()) {
toast("菜品构成信息未设置")
return@clickWithDebounce
}
val food = FoodRecord()
binding.etInputDish.let {
if (it.tag!=null && it.tag.toString() == it.text.toString()) {
food.foodId = foodId
}
}
food.foodName = showFoodName
food.cookMode = 1
Log.d(TAG, "goToSubmit: goodsList:${list.toJsonString()}")
// startActivity<SubmitDishActivity> {
startActivity<SubmitFoodActivity> {
putExtra(SubmitFoodActivity.GOODS_LIST, list.map { it.toCookFoodGoodsEntity() }.toMutableList() as java.io.Serializable)
putExtra(SubmitFoodActivity.FOOD_ITEM, food as Serializable)
}
}
binding.etInputDish.let { v ->
v.addOnActionSearchListener {
jumpSearch(v)
}
}
}
private fun jumpSearch(v: View) {
launch(Intent(this, FoodSearchActivity::class.java).apply {
putExtra(FoodSearchActivity.FOOD_NAME, binding.etInputDish.text.toString().trim())
}) { intent ->
intent?.let {
foodId = it.getStringExtra(FoodSearchActivity.FOOD_ID)
foodName = it.getStringExtra(FoodSearchActivity.FOOD_NAME)
binding.etInputDish.run {
setText(foodName)
tag = foodName
setSelection(text.length)
}
}
}
KeyboardUtil.hideKeyboard(v.context, v)
}
private fun searchDishType() {
val inputText = binding.etInputDishType.text.toString().trim()
BottomDialog(goodsType = 0, defGoodsName = inputText).show(this@DishSamplingActivity) { item ->
val filterResult = list.firstOrNull { it.goodsName == item.goodsName }
if (filterResult != null) {
toast("不允许重复添加同一食材")
return@show
}
binding.etInputDishType.run {
setText(item.goodsName)
setSelection(length())
}
tempDishEntity = item
}
}
}
@@ -1,370 +0,0 @@
package com.shuwei.dish.match.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.activity.addCallback
import androidx.core.content.IntentCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.dish.match.adapter.GoodsInfoSearchAdapter
import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.databinding.ActivityFoodRecognizeBinding
import com.shuwei.dish.match.dialog.FoodSearchDialog
import com.shuwei.dish.match.entity.CookFoodGoodsEntity
import com.shuwei.dish.match.utils.ActivityManager
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.clickWithDebounce
import com.shuwei.dish.match.utils.ext.gone
import com.shuwei.dish.match.utils.ext.visible
import androidx.core.net.toUri
import androidx.core.view.updateLayoutParams
import com.shuwei.dish.match.utils.ext.dp
/**
* 食材识别结果页面
* 展示识别到的食材照片、食材列表、选中食材名称及秤1重量
*/
class FoodRecognizeActivity : BaseActivity() {
companion object {
private const val TAG = "FoodRecognizeActivity"
private const val EXTRA_IMAGE_URI = "extra_image_uri"
private const val EXTRA_CURRENT_WEIGHT = "extra_current_weight"
const val EXTRA_SELECTED_ITEM = "extra_selected_item"
const val IS_MANUAL_CANCEL = "isManualCancel"
/** 内存缓存食材列表,避免通过 Intent 序列化传递大数据 */
private var pendingGoodsList: ArrayList<CookFoodGoodsEntity>? = null
/**
* 启动本页面,通过 onResult 回调返回用户选中的食材
* @param activity 宿主 Activity
* @param imageUri 食材照片路径(可为空)
* @param goodsList 识别到的食材列表
* @param finishCallback 页面关闭后回调,携带用户选中的食材(未选中时为 null)
*/
fun start(
activity: BaseActivity,
imageUri: String? = null,
currentWeight: Double = 0.0,
goodsList: ArrayList<CookFoodGoodsEntity> = arrayListOf(),
finishCallback: (Boolean, CookFoodGoodsEntity?) -> Unit = { _, _ -> }
) {
pendingGoodsList = goodsList
val launchIntent = Intent(activity, FoodRecognizeActivity::class.java).apply {
putExtra(EXTRA_IMAGE_URI, imageUri)
putExtra(EXTRA_CURRENT_WEIGHT, currentWeight)
}
activity.startActivity(launchIntent) { resultIntent ->
val isManualCancel = resultIntent?.getBooleanExtra(IS_MANUAL_CANCEL, false) ?: false
val entity = resultIntent?.let {
IntentCompat.getSerializableExtra(
it,
EXTRA_SELECTED_ITEM,
CookFoodGoodsEntity::class.java)
}
finishCallback(isManualCancel, entity)
}
}
/**
* 关闭当前识别页面,并将选中食材通过 Activity Result 回传
* 由外部(如重量归零时)调用
*/
fun close() {
ActivityManager.getActivityStack()
.filterIsInstance<FoodRecognizeActivity>()
.lastOrNull()
?.finishWithResult(false)
}
}
private lateinit var binding: ActivityFoodRecognizeBinding
private val list = mutableListOf<CookFoodGoodsEntity>()
/** 当前选中的列表位置,-1 表示无选中 */
private var selectedPosition = -1
/** 秤1当前实时重量 */
private var currentWeight = 0.0
/** 重量减少时延迟更新的任务,页面关闭前取消可防止用错误重量 */
private var pendingWeightRunnable: Runnable? = null
/** 当前待延迟更新的目标重量,避免相同重量重复重置计时器 */
private var pendingWeight = 0.0
/** 右侧自定义滚动指示条最小高度 */
private val scrollIndicatorMinHeightPx by lazy { (24 * resources.displayMetrics.density).toInt() }
/** 食材搜索弹窗,持有引用防止重复打开 */
private var foodSearchDialog: FoodSearchDialog? = null
private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateScrollIndicator()
}
}
private val adapter = GoodsInfoSearchAdapter(list).apply {
setOnItemClickListener { _, _, position ->
list.forEachIndexed { i, item -> item.isClicked = (i == position) }
// 点击选择时记录当前秤重量
list[position].useWeight = currentWeight
notifyDataSetChanged()
selectedPosition = position
binding.tvSelectedFood.text = list[position].goodsName ?: "-"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFoodRecognizeBinding.inflate(layoutInflater)
setContentView(binding.root)
registerWeightListener()
setHeaderBackground()
// 显示标题栏
setTitleBar(
titleBarAction = { it.visible() },
titleAction = { it.text = "食材识别" },
backAction = {
it.setOnClickListener {
manualBack()
}
}
)
initData()
initRecyclerView()
updateRvParentLayoutHeight()
initClickListeners()
onBackPressedDispatcher.addCallback(this) {
manualBack()
}
}
/**
* 手动返回
*/
private fun manualBack() {
finishWithResult(true)
}
/**
* 从 Intent 读取数据并初始化页面
*/
private fun initData() {
// 加载食材照片
val imageUri = intent.getStringExtra(EXTRA_IMAGE_URI)
if (!imageUri.isNullOrBlank()) {
binding.ivFoodPhoto.setImageURI(imageUri.toUri())
}
currentWeight = intent.getDoubleExtra(EXTRA_CURRENT_WEIGHT, 0.0)
binding.tvWeight.text = "${currentWeight}g"
// 取出内存缓存的食材列表,取用后立即清空
val goodsList = pendingGoodsList?.also { pendingGoodsList = null }
if (!goodsList.isNullOrEmpty()) {
list.addAll(goodsList)
// 默认选中第一项
selectedPosition = 0
list[0].isClicked = true
list[0].useWeight = currentWeight
binding.tvSelectedFood.text = list[0].goodsName ?: "-"
}
}
/**
* 初始化 RecyclerView
*/
private fun initRecyclerView() {
binding.recyclerView.let {
it.layoutManager = GridLayoutManager(this@FoodRecognizeActivity, 2)
it.adapter = adapter
it.isVerticalScrollBarEnabled = false
it.removeOnScrollListener(scrollListener)
it.addOnScrollListener(scrollListener)
it.post { updateScrollIndicator() }
}
updateListVisibility()
}
/**
* 更新RecyclerView父布局高度
*/
private fun updateRvParentLayoutHeight() {
binding.flListContainer.updateLayoutParams {
height = if (adapter.items.size > 2) 260.dp else 130.dp
}
}
/**
* 根据列表是否有数据控制 RecyclerView 显隐
*/
private fun updateListVisibility() {
if (list.isEmpty()) {
binding.flListContainer.gone()
} else {
binding.flListContainer.visible()
binding.recyclerView.post { updateScrollIndicator() }
}
}
/**
* 根据 RecyclerView 滚动状态刷新右侧自定义指示条
*/
private fun updateScrollIndicator() {
val recyclerView = binding.recyclerView
val track = binding.vScrollIndicatorTrack
val thumb = binding.vScrollIndicatorThumb
if (!recyclerView.canScrollVertically(1) && !recyclerView.canScrollVertically(-1)) {
track.gone()
thumb.gone()
return
}
val range = recyclerView.computeVerticalScrollRange()
val extent = recyclerView.computeVerticalScrollExtent()
val offset = recyclerView.computeVerticalScrollOffset()
if (range <= 0 || extent <= 0 || range <= extent) {
track.gone()
thumb.gone()
return
}
val trackHeight = track.height.takeIf { it > 0 } ?: recyclerView.height
if (trackHeight <= 0) return
val thumbHeight = ((extent.toFloat() / range) * trackHeight)
.toInt()
.coerceAtLeast(scrollIndicatorMinHeightPx)
.coerceAtMost(trackHeight)
val maxTop = (trackHeight - thumbHeight).coerceAtLeast(0)
val scrollable = (range - extent).coerceAtLeast(1)
val thumbTop = ((offset.toFloat() / scrollable) * maxTop).toInt().coerceIn(0, maxTop)
if (thumb.layoutParams.height != thumbHeight) {
thumb.layoutParams = thumb.layoutParams.apply { height = thumbHeight }
}
thumb.translationY = thumbTop.toFloat()
track.visible()
thumb.visible()
}
/**
* 初始化按钮点击事件
*/
private fun initClickListeners() {
// 更换食材:打开食材搜索弹窗,已打开时跳过
binding.btnChangeFood.clickWithDebounce {
// val currentName = list.getOrNull(selectedPosition)?.goodsName
if (foodSearchDialog?.isShowing == true) return@clickWithDebounce
foodSearchDialog = FoodSearchDialog(
activity = this,
// defGoodsName = currentName,
onItemSelected = { item -> updateSelectedFood(item) }
).also { it.show() }
}
// 取消:关闭页面
binding.btnCancel.setOnClickListener { manualBack() }
}
/**
* 用新食材替换当前选中项(或追加到列表首位)
*/
@SuppressLint("NotifyDataSetChanged")
private fun updateSelectedFood(item: CookFoodGoodsEntity) {
val existIndex = list.indexOfFirst { it.goodsId == item.goodsId }
if (existIndex >= 0) {
list.forEachIndexed { i, e -> e.isClicked = (i == existIndex) }
selectedPosition = existIndex
} else {
list.add(0, item)
list.forEachIndexed { i, e -> e.isClicked = (i == 0) }
selectedPosition = 0
}
adapter.notifyDataSetChanged()
updateRvParentLayoutHeight()
binding.tvSelectedFood.text = item.goodsName ?: "-"
updateListVisibility()
binding.recyclerView.post { updateScrollIndicator() }
}
/**
* 注册秤1重量监听
* 只有重量高于识别阈值时才更新选中食材的 useWeight,
* 低于阈值说明食材已被取走,保留上次有效值等待页面关闭
*/
private fun registerWeightListener() {
// WeightUtil.addWeightListener(TAG) { address, state, weight ->
// if (address == AddressUtil.ONE && state == WeightUtil.STATE_STABLE) {
// runOnUiThread {
// currentWeight = weight
// binding.tvWeight.run {
// if (tag != weight) {
// text = "$weight g"
// tag = weight
// }
// }
// if (weight >= PrepareCookActivity.WEIGHT_RECOGNIZE_VALUE) {
// adapter.items.firstOrNull { it.isClicked }?.let { item ->
// if (pendingWeight != weight) {
// // 无论增减,均延迟更新,避免手拿物品时的瞬时增重干扰
// cancelPendingWeightUpdate()
// pendingWeight = weight
// pendingWeightRunnable = Runnable {
// item.useWeight = weight
// pendingWeightRunnable = null
// }.also { handler.postDelayed(it, 3000) }
// }
// }
// }
// }
// }
// }
}
/**
* 取消待执行的延迟重量更新任务
*/
private fun cancelPendingWeightUpdate() {
// pendingWeightRunnable?.let { handler.removeCallbacks(it) }
// pendingWeightRunnable = null
}
/**
* 携带当前选中食材关闭页面
*/
private fun finishWithResult(isManualCancel: Boolean) {
cancelPendingWeightUpdate()
val intent = Intent().apply {
putExtra(IS_MANUAL_CANCEL, isManualCancel)
if (isManualCancel.not()) {
list.getOrNull(selectedPosition)?.let {
// 兜底:若 useWeight 仍为空或0,使用当前实时重量
if ((it.useWeight ?: 0.0) <= 0.0) {
it.useWeight = currentWeight
}
putExtra(EXTRA_SELECTED_ITEM, it)
}
}
}
setResult(RESULT_OK, intent)
finish()
}
override fun onDestroy() {
super.onDestroy()
cancelPendingWeightUpdate()
WeightUtil.removeWeightListener(TAG)
}
}
@@ -4,9 +4,6 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.FoodRecordAdapter import com.shuwei.dish.match.adapter.FoodRecordAdapter
@@ -14,7 +11,8 @@ import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding import com.shuwei.dish.match.databinding.ActivityFoodSearchBinding
import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding import com.shuwei.dish.match.databinding.LayoutEmptyViewBinding
import com.shuwei.dish.match.entity.FoodRecord
import com.shuwei.dish.match.model.FoodRecord
import com.shuwei.dish.match.net.UiState import com.shuwei.dish.match.net.UiState
import com.shuwei.dish.match.utils.KeyboardUtil import com.shuwei.dish.match.utils.KeyboardUtil
import com.shuwei.dish.match.utils.ext.addOnActionSearchListener import com.shuwei.dish.match.utils.ext.addOnActionSearchListener
@@ -23,11 +21,7 @@ import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import java.io.Serializable import java.io.Serializable
import kotlinx.coroutines.launch
/**
* 菜品搜索页面
*/
class FoodSearchActivity : BaseActivity() { class FoodSearchActivity : BaseActivity() {
companion object { companion object {
@@ -42,15 +36,15 @@ class FoodSearchActivity : BaseActivity() {
private var list: MutableList<FoodRecord> = mutableListOf() private var list: MutableList<FoodRecord> = mutableListOf()
private var pageType = 0 private var pageType = 0
private var dinnerType = "1" private var dinnerType = "0"
private val recordAdapter by lazy { private val recordAdapter by lazy {
FoodRecordAdapter(list).apply { FoodRecordAdapter(list).apply {
isStateViewEnable = true isStateViewEnable = true
setOnItemClickListener { adapter, view, position -> setOnItemClickListener { adapter, view, position ->
if (pageType == 1) { if (pageType == 1) {
val jumpItem = list[position].also { it.dinnerType = dinnerType } val jumpItem = list[position].also { it.dinnerType = dinnerType }
startActivity<PrepareFoodActivity> { startActivity<PrepareCookActivity> {
putExtra(PrepareFoodActivity.FOOD_ITEM, jumpItem as Serializable) putExtra(PrepareCookActivity.FOOD_ITEM, jumpItem as Serializable)
} }
return@setOnItemClickListener return@setOnItemClickListener
} }
@@ -70,7 +64,7 @@ class FoodSearchActivity : BaseActivity() {
binding = ActivityFoodSearchBinding.inflate(layoutInflater) binding = ActivityFoodSearchBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
pageType = intent.getIntExtra(PAGE_TYPE, 0) pageType = intent.getIntExtra(PAGE_TYPE, 0)
dinnerType = intent.getStringExtra(DINNER_TYPE) ?: "0" dinnerType = intent.getStringExtra(DINNER_TYPE)?:"0"
setHeaderBackground() setHeaderBackground()
foodName = intent.getStringExtra(FOOD_NAME) foodName = intent.getStringExtra(FOOD_NAME)
binding.etInputDish.setText(foodName) binding.etInputDish.setText(foodName)
@@ -91,38 +85,13 @@ class FoodSearchActivity : BaseActivity() {
adapter = recordAdapter adapter = recordAdapter
} }
addViewListener() addViewListener()
initObserver()
}
/**
* 收集 searchFoodState,统一处理 Loading / Success / Error 状态
*/
private fun initObserver() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
netViewModel.searchFoodState.collect { state ->
when (state) {
is UiState.Loading -> showLoading()
is UiState.Success -> handleSearchResult(state.data)
is UiState.Error -> {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
if (pageNo == 1) loadEmptyView()
}
is UiState.Idle -> {}
}
}
}
}
} }
private fun addViewListener() { private fun addViewListener() {
binding.ivDishSearch.setOnClickListener { v -> binding.ivDishSearch.setOnClickListener { v ->
pageNo = 1 pageNo = 1
getInputAndSearch() getInputAndSearch()
KeyboardUtil.hideKeyboard(v) KeyboardUtil.hideKeyboard(v.context, v)
} }
binding.refreshLayout.setEnableRefresh(true) binding.refreshLayout.setEnableRefresh(true)
binding.refreshLayout.setEnableLoadMore(false) binding.refreshLayout.setEnableLoadMore(false)
@@ -139,13 +108,13 @@ class FoodSearchActivity : BaseActivity() {
queryListInfo(foodName!!) queryListInfo(foodName!!)
} }
binding.root.setOnClickListener { v -> binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v) KeyboardUtil.hideKeyboard(v.context, v)
} }
binding.etInputDish.let { v -> binding.etInputDish.let { v ->
v.addOnActionSearchListener { v.addOnActionSearchListener {
pageNo = 1 pageNo = 1
getInputAndSearch() getInputAndSearch()
KeyboardUtil.hideKeyboard( v) KeyboardUtil.hideKeyboard(v.context, v)
} }
} }
} }
@@ -161,7 +130,7 @@ class FoodSearchActivity : BaseActivity() {
} }
private var pageNo = 1 private var pageNo = 1
private var pageSize = 10 private var pageSize = 100
fun getDinnerTypeText(): String { fun getDinnerTypeText(): String {
return when (dinnerType) { return when (dinnerType) {
"1" -> "早餐" "1" -> "早餐"
@@ -170,39 +139,58 @@ class FoodSearchActivity : BaseActivity() {
else -> "早餐" else -> "早餐"
} }
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun queryListInfo(input: String) { private fun queryListInfo(input: String) {
val map = mutableMapOf<String, Any>( showLoading()
val param = mutableMapOf<String, Any>(
"foodName" to input.trim(), "foodName" to input.trim(),
"pageNum" to pageNo, "pageNo" to pageNo,
"pageSize" to pageSize, "pageSize" to pageSize,
"placeId" to BaseApp.canteenId, "canteenId" to BaseApp.canteenId
"dinnerType" to getDinnerTypeText()
) )
netViewModel.searchFoodList(param = map) if (dinnerType.toInt() > 0) {
} param["dinnerType"] = getDinnerTypeText()
/**
* 处理搜索结果,填充列表或展示空视图
*/
@SuppressLint("NotifyDataSetChanged")
private fun handleSearchResult(records: MutableList<FoodRecord>?) {
delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
if (records.isNullOrEmpty()) {
if (pageNo == 1) loadEmptyView()
return
} }
if (pageNo == 1) list.clear() netViewModel.searchFoodListWithCallback(
list.addAll(records) param = param,
recordAdapter.notifyDataSetChanged() onResult = { state ->
val isLoadMoreEnable = records.size >= pageSize when (state) {
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable) is UiState.Success -> {
if (isLoadMoreEnable) pageNo++ delayDismissLoading()
finishRefresh()
binding.refreshLayout.setEnableRefresh(true)
val records = state.data
if (records.isNullOrEmpty()) {
if (pageNo == 1) {
loadEmptyView()
}
return@searchFoodListWithCallback
}
if (pageNo == 1) {
list.clear()
}
list.addAll(records)
recordAdapter.notifyDataSetChanged()
val isLoadMoreEnable = records.size >= pageSize
binding.refreshLayout.setEnableLoadMore(isLoadMoreEnable)
if (isLoadMoreEnable) {
pageNo++
}
}
is UiState.Error -> {
binding.refreshLayout.setEnableRefresh(true)
toast(state.msg)
delayDismissLoading()
finishRefresh()
if (pageNo == 1) {
loadEmptyView()
}
}
else -> {}
}
}
)
} }
private fun finishRefresh() { private fun finishRefresh() {
if (pageNo == 1) { if (pageNo == 1) {
binding.refreshLayout.finishRefresh(1200) binding.refreshLayout.finishRefresh(1200)
@@ -212,17 +200,12 @@ class FoodSearchActivity : BaseActivity() {
} }
private var emptyViewBinding: LayoutEmptyViewBinding? = null private var emptyViewBinding: LayoutEmptyViewBinding? = null
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() { private fun loadEmptyView() {
list.clear() list.clear()
recordAdapter.notifyDataSetChanged() recordAdapter.notifyDataSetChanged()
if (emptyViewBinding == null) { if (emptyViewBinding == null) {
emptyViewBinding = LayoutEmptyViewBinding.inflate( emptyViewBinding = LayoutEmptyViewBinding.inflate(LayoutInflater.from(this), binding.rvDishSearchList, false)
LayoutInflater.from(this),
binding.rvDishSearchList,
false
)
} }
emptyViewBinding!!.tvContent.text = "暂无数据" emptyViewBinding!!.tvContent.text = "暂无数据"
emptyViewBinding!!.tvSubContent.text = "可以尝试换个名称重新搜索" emptyViewBinding!!.tvSubContent.text = "可以尝试换个名称重新搜索"
@@ -2,23 +2,19 @@ package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.shuwei.dish.match.R import com.shuwei.dish.match.R
import com.shuwei.dish.match.adapter.HomeModeAdapter import com.shuwei.dish.match.adapter.HomeModeAdapter
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.databinding.ActivityHomeBinding import com.shuwei.dish.match.databinding.ActivityHomeBinding
import com.shuwei.dish.match.entity.HomeModeBean
import com.shuwei.dish.match.model.HomeModeBean
import com.shuwei.dish.match.utils.SpTool import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast import com.shuwei.dish.match.utils.ext.toast
import kotlinx.coroutines.launch
/**
* 主页,包括三种模式:制作模式、采样模式、品控模式
*/
class HomeActivity : BaseActivity() { class HomeActivity : BaseActivity() {
companion object { companion object {
@@ -42,14 +38,13 @@ class HomeActivity : BaseActivity() {
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setHeaderBackground(isHomePage = true) setHeaderBackground(isHomePage = true)
binding.radioGroup.setOnCheckedChangeListener { group, checkedId -> binding.radioGroup.setOnCheckedChangeListener { _, checkedId ->
if (checkedId == R.id.radioSw) { if (checkedId == R.id.radioSw) {
BaseApp.canteenId = "1678234139391512577" BaseApp.canteenId = "1678234139391512577"
} else { } else {
BaseApp.canteenId = "0" BaseApp.canteenId = "0"
} }
} }
onBackPressedDispatcher.addCallback(this) {}
// binding.radioGroup.check(R.id.radioZk) // binding.radioGroup.check(R.id.radioZk)
initRecyclerView() initRecyclerView()
// HttpUtil.getAppToken() // HttpUtil.getAppToken()
@@ -107,8 +102,8 @@ class HomeActivity : BaseActivity() {
modeDesc = "菜品快速制作,记录熟重", modeDesc = "菜品快速制作,记录熟重",
modeIconId = R.drawable.ic_mode_cook, modeIconId = R.drawable.ic_mode_cook,
onClick = { onClick = {
SpTool.cookMode = 0 SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 0)
startActivity<CookingModeActivity>() startActivity<SelectDishActivity>()
finish() finish()
}) })
) )
@@ -118,7 +113,7 @@ class HomeActivity : BaseActivity() {
modeDesc = "菜品快速制作,记录熟重", modeDesc = "菜品快速制作,记录熟重",
modeIconId = R.drawable.ic_mode_sampling, modeIconId = R.drawable.ic_mode_sampling,
onClick = { onClick = {
SpTool.cookMode = 1 SpTool.put(SpTool.LAUNCH_PAGE_TYPE, 1)
goSampling() goSampling()
}) })
) )
@@ -134,30 +129,36 @@ class HomeActivity : BaseActivity() {
) )
} }
private fun goSampling() { private fun goSampling() {
lifecycleScope.launch { appViewModel.loadSeasoning {
val slots = appViewModel.loadSeasoningSlot() if (it.isEmpty()) {
if (slots.isEmpty()) { //无调料信息打开采样历史列表页面,点击设置配置调料信息
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息 startActivity<SamplingListActivity>()
startActivity<SamplingModeActivity>()
finish() finish()
return@launch return@loadSeasoning
} }
val count = appViewModel.countCookFood(cookMode = 1) appViewModel.countCookFood(cookMode = 1) { count ->
if (count > 0) { if (count > 0) {
// 有烹饪中数据打开采样历史列表页面 //有烹饪中数据打开采样历史列表页面
startActivity<SamplingModeActivity>() startActivity<SamplingListActivity>()
finish()
return@countCookFood
}
//无烹饪数据打开新增采样页面
startActivity<DishSamplingActivity> {
putExtra(DishSamplingActivity.PAGE_FROM, DishSamplingActivity.HOME)
}
finish() finish()
return@launch
} }
// 无烹饪数据,打开新增采样页面
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
}
finish()
} }
} }
@Deprecated("Deprecated in Java")
@SuppressLint("GestureBackNavigation", "MissingSuperCall")
override fun onBackPressed() {
//super.onBackPressed()
//当前页面不允许返回
//dialog = toast("不允许返回操作")
}
} }
@@ -1,31 +1,26 @@
package com.shuwei.dish.match.ui package com.shuwei.dish.match.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.provider.Settings
import android.os.Looper import android.util.Log
import android.os.SystemClock
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.shuwei.dish.match.BuildConfig
import com.shuwei.dish.match.base.BaseActivity import com.shuwei.dish.match.base.BaseActivity
import com.shuwei.dish.match.base.BaseApp import com.shuwei.dish.match.base.BaseApp
import com.shuwei.dish.match.base.DeviceRole
import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.databinding.ActivityInitBinding import com.shuwei.dish.match.databinding.ActivityInitBinding
import com.shuwei.dish.match.objbox.FoodModule import com.shuwei.dish.match.base.GlobalData
import com.shuwei.dish.match.scale.ScaleServiceManager
import com.shuwei.dish.match.utils.AddressUtil
import com.shuwei.dish.match.utils.AppUtil import com.shuwei.dish.match.utils.AppUtil
import com.shuwei.dish.match.utils.NetworkUtils
import com.shuwei.dish.match.utils.SpTool import com.shuwei.dish.match.utils.SpTool
import com.shuwei.dish.match.utils.WeightUtil import com.shuwei.dish.match.utils.WeightUtil
import com.shuwei.dish.match.utils.ext.gone import com.shuwei.dish.match.utils.ext.invisible
import com.shuwei.dish.match.utils.ext.startActivity import com.shuwei.dish.match.utils.ext.startActivity
import com.shuwei.dish.match.utils.ext.toast
import com.shuwei.dish.match.utils.ext.visible import com.shuwei.dish.match.utils.ext.visible
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.toString
class InitActivity : BaseActivity() { class InitActivity : BaseActivity() {
companion object { companion object {
@@ -33,219 +28,120 @@ class InitActivity : BaseActivity() {
} }
private lateinit var binding: ActivityInitBinding private lateinit var binding: ActivityInitBinding
private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
private val countdownHandler = Handler(Looper.getMainLooper())
private val countdownTask = object : Runnable {
override fun run() {
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
// 每 10 秒检测一次网络
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
// 倒计时未结束,继续更新 UI
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
countdownHandler.postDelayed(this, 100)
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this@InitActivity)) {
startNextPage()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}
}
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
addBackKeyListener()
statusBarDarkFont(enable = true) statusBarDarkFont(enable = true)
// useBackground(false) // useBackground(false)
binding = ActivityInitBinding.inflate(layoutInflater) binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
lifecycleScope.launch {
FoodModule.init(this@InitActivity)
}
// setHeaderBackground(isHomePage = true) // setHeaderBackground(isHomePage = true)
setHeaderBgVisible(false) setHeaderBgVisible(false)
BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString() BaseApp.appVersion = AppUtil.getAppVersionCode(this).toString()
// 获取 ANDROID_ID
// var androidId =
// Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
// androidId = "39a7abdd06b3c7ab"
val deviceId = if (BuildConfig.IS_TEST_DEVICE) GlobalData.DEVICE_ID_2 else AppUtil.getUDID(this)
GlobalData.deviceId = deviceId
SpTool.put(SpTool.DEVICE_ID, deviceId)
// GlobalData.appBaseUrl = GlobalData.TEST_BASE_URL
GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
BaseApp.canteenId = "0" BaseApp.canteenId = "0"
// // TODO: 以上保存deviceId用于临时使用,后续改为下面注释方式
// var deviceId = AppUtil.getUDID(this)
// Log.d(TAG, "onCreate: deviceId=$deviceId")
//// deviceId = "39a7abdd06b3c7ab"
// deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
//// deviceId = "6ce7cd59-b875-38b2-b73d-88a570be3212"
//
// BaseApp.deviceId = deviceId
//
// SpTool.put(SpTool.DEVICE_ID, deviceId)
// val deviceConfigCache = SpTool.getString(SpTool.DEVICE_CONFIG_CACHE)
// val checkResult = checkConfigData(deviceConfigCache)
// if (checkResult.not()) {
// binding.ivQrCode.visible()
// binding.btnInit.visible()
// //进行初始化操作
// initConfig()
// return
// }
binding.ivQrCode.invisible()
binding.btnInit.invisible()
WeightUtil.init() WeightUtil.init()
WeightUtil.getWeight() WeightUtil.getWeight()
WeightUtil.startContinuousRead() WeightUtil.startContinuousRead()
//对净材秤1和熟食秤2称进行清零 countDown()
WeightUtil.tareTwo(AddressUtil.ONE)
WeightUtil.tareTwo(AddressUtil.TWO)
initViews()
} }
private fun startNextPage() {
private fun startNextPage(withDelay: Boolean = false) { val launchPageType = SpTool.getInt(SpTool.LAUNCH_PAGE_TYPE, -1)
lifecycleScope.launch { when (launchPageType) {
if (withDelay) delay(2000) 0 -> {
// 子设备:直接进入小屏专属页面,不走原有大屏业务流程 startActivity<SelectDishActivity>()
if (GlobalData.deviceRole == DeviceRole.SLAVE) {
startActivity<SlaveActivity>()
return@launch
} }
// 主设备:走原有路由逻辑
when (SpTool.cookMode) {
0 -> {
startActivity<CookingModeActivity>()
}
1 -> { 1 -> {
goSampling() goSampling()
} }
else -> { else -> {
startActivity<HomeActivity>() startActivity<HomeActivity>()
}
} }
} }
} }
private fun goSampling() { private fun goSampling() {
appViewModel.loadSeasoning {
if (it.isEmpty()) {
//无调料信息打开采样历史列表页面,点击设置配置调料信息
startActivity<SamplingListActivity>()
// finish()
return@loadSeasoning
}
appViewModel.countCookFood(cookMode = 1) { count ->
if (count > 0) {
//有烹饪中数据打开采样历史列表页面
startActivity<SamplingListActivity>()
// finish()
return@countCookFood
}
//无烹饪数据打开新增采样页面
startActivity<DishSamplingActivity> {
putExtra(DishSamplingActivity.PAGE_FROM, DishSamplingActivity.HOME)
}
// finish()
}
}
}
private fun countDown() {
lifecycleScope.launch { lifecycleScope.launch {
val slots = appViewModel.loadSeasoningSlot() flow {
if (slots.isEmpty()) { (2 downTo 1).forEach {
// 无调料信息,打开采样历史列表页面,点击设置配置调料信息 delay(1000)
startActivity<SamplingModeActivity>() emit(it)
return@launch }
} }.collect {
val count = appViewModel.countCookFood(cookMode = 1) // 倒计时结束执行跳转
if (count > 0) { if (it == 1) {
// 有烹饪中数据,打开采样历史列表页面 startNextPage()
startActivity<SamplingModeActivity>() //保留初始化页面,不关闭
return@launch // finish()
} }
// 无烹饪数据,打开新增采样页面
startActivity<PrepareFoodActivity> {
putExtra(PrepareFoodActivity.PAGE_FROM, PrepareFoodActivity.HOME)
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
countdownHandler.removeCallbacksAndMessages(null)
WeightUtil.stopContinuousRead() WeightUtil.stopContinuousRead()
ScaleServiceManager.stop()
super.onDestroy() super.onDestroy()
} }
/**
* 初始化控件事件
*/
private fun initViews() {
// 连接网络按钮点击事件
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200 // 旋转周期 1.2 秒
repeatCount = RotateAnimation.INFINITE // 无限循环
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
// 有网络时隐藏网络检测区域,直接跳转
binding.llNetwork.gone()
startNextPage(withDelay = false)
return
}
binding.llNetwork.visible()
startCountdown()
}
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
countdownHandler.removeCallbacks(countdownTask)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
// binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visible()
binding.llNetworkButton.gone()
binding.tvCountdown.text = "60秒"
// 无网络时才启动加载动画
startLoadingAnimation()
scheduleCountdown()
}
/**
* 定时更新倒计时
* 使用 SystemClock.elapsedRealtime() 确保精确计时
*/
private fun scheduleCountdown() {
countdownHandler.removeCallbacks(countdownTask)
countdownHandler.postDelayed(countdownTask, 100)
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
countdownHandler.removeCallbacks(countdownTask)
startNextPage()
}
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
// binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.gone()
binding.llNetworkButton.visible()
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent) {}
}
private fun addBackKeyListener() {
onBackPressedDispatcher.addCallback(this) {
// 禁用返回键,不执行任何操作
}
}
} }

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