Author SHA1 Message Date
mazengfei b5a9522fa7 新增2列设备 2026-05-26 16:58:59 +08:00
78 changed files with 2971 additions and 2574 deletions
+1 -3
View File
@@ -16,7 +16,7 @@ android {
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.sw.scalefusion.shelf" applicationId = "com.shuwei.intelligent.shelves"
minSdk = 25 minSdk = 25
//noinspection EditedTargetSdkVersion,ExpiredTargetSdkVersion //noinspection EditedTargetSdkVersion,ExpiredTargetSdkVersion
targetSdk = 30 targetSdk = 30
@@ -87,8 +87,6 @@ dependencies {
implementation("org.greenrobot:eventbus:3.3.1") implementation("org.greenrobot:eventbus:3.3.1")
implementation("com.google.android.flexbox:flexbox:3.0.0")
// implementation("io.github.jeremyliao:live-event-bus-x:1.8.0") // implementation("io.github.jeremyliao:live-event-bus-x:1.8.0")
// implementation("com.hoho:android-usb-serial:1.3.0") // implementation("com.hoho:android-usb-serial:1.3.0")
// implementation("io.github.jeadyx:jserialport:1.5") // implementation("io.github.jeadyx:jserialport:1.5")
+8 -4
View File
@@ -26,7 +26,7 @@
android:theme="@style/Theme.IntelligentShelves" android:theme="@style/Theme.IntelligentShelves"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity <activity
android:name="com.sw.scalefusion.shelf.InitActivity" android:name=".activity.InitActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity"> tools:ignore="DiscouragedApi,LockedOrientationActivity">
@@ -36,11 +36,15 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="com.shuwei.intelligent.shelves.activity.LogActivity" /> <activity
<activity android:name="com.sw.scalefusion.shelf.HomeV3Activity" /> android:name=".activity.HomeActivity"
<activity android:name="com.sw.scalefusion.shelf.ShelfV3Activity" android:screenOrientation="portrait"/>
<activity
android:name=".activity.ShelfActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:ignore="DiscouragedApi,LockedOrientationActivity" /> tools:ignore="DiscouragedApi,LockedOrientationActivity" />
<!-- <activity android:name="com.shuwei.intelligent.shelves.activity.SettingActivity" />-->
<activity android:name="com.shuwei.intelligent.shelves.activity.LogActivity" />
<receiver <receiver
android:name=".utils.BootReceiver" android:name=".utils.BootReceiver"
@@ -5,11 +5,8 @@ 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.os.Handler
import android.os.Looper
import com.shuwei.intelligent.shelves.utils.BootReceiver import com.shuwei.intelligent.shelves.utils.BootReceiver
import com.shuwei.intelligent.shelves.utils.CrashHandler import com.shuwei.intelligent.shelves.utils.CrashHandler
import com.shuwei.intelligent.shelves.utils.FileLogger
/** /**
* @author: star * @author: star
@@ -22,22 +19,10 @@ class App : Application() {
super.onCreate() super.onCreate()
app = this app = this
CrashHandler.init(this) CrashHandler.init(this)
// 启动时立即清理一次过期日志
cleanupLogs()
// 设备长期不断电、进程不会重新启动,运行中每24小时定期清理一次
logCleanupHandler.postDelayed(logCleanupRunnable, LOG_CLEANUP_INTERVAL)
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED) val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter) registerReceiver(BootReceiver(), filter)
} }
/**
* 清理过期的日志文件:文件日志与崩溃日志均保留最近7天
*/
private fun cleanupLogs() {
FileLogger.cleanupExpiredLogs(this)
CrashHandler.getInstance()?.cleanupOldCrashReports()
}
companion object { companion object {
private lateinit var app: App private lateinit var app: App
var canteenId = "0" var canteenId = "0"
@@ -51,19 +36,6 @@ class App : Application() {
@Volatile @Volatile
private var sharedPref: SharedPreferences? = null private var sharedPref: SharedPreferences? = null
/** 日志定期清理间隔:24小时,与日志文件按天滚动对齐 */
private const val LOG_CLEANUP_INTERVAL = 24L * 60 * 60 * 1000
private val logCleanupHandler = Handler(Looper.getMainLooper())
/** 日志定期清理任务:执行后重新调度自身,形成循环 */
private val logCleanupRunnable = object : Runnable {
override fun run() {
getInstance().cleanupLogs()
logCleanupHandler.postDelayed(this, LOG_CLEANUP_INTERVAL)
}
}
fun getInstance(): App { fun getInstance(): App {
return app return app
} }
@@ -17,10 +17,9 @@ object GlobalData {
/** /**
* 具体业务 BaseUrl * 具体业务 BaseUrl
*/ */
// const val LOCAL_BASE_URL = "http://192.168.1.201:14801" const val TEST_BASE_URL = "http://192.168.1.201:14801"
const val LOCAL_BASE_URL = "http://192.168.10.101:24801" const val UAT_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081" const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
const val PROD_BASE_URL = "https://platform-api.uat.shuziweidao.com"
} }
@@ -0,0 +1,463 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.GridLayoutManager
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.adapter.ShelfAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeBinding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.model.ShelfResult
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.RespData
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.GridLayoutTool
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.copyTextToClipboard
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class HomeActivity : BaseActivity() {
companion object {}
override val enableRightStatusMenu: Boolean = true
// 货架列表数据
private val list: MutableList<ShelfModel> = mutableListOf()
// 当前选中货架下标
private var shelfIndex = 0
// 当前温度(摄氏)
private var showTemperatureC = "0"
// 当前湿度
private var showHumidity = "0"
// 仅特定 deviceId 的部署点需要发送开锁指令
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
private var deviceName = ""
private lateinit var binding: ActivityHomeBinding
private val viewModel: NetViewModel by viewModels()
// 串口协议解析器,通过回调通知本 Activity
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val shelfNo = weight.index
if (shelfNo in 1..list.size) {
list.firstOrNull { it.deviceNo == shelfNo }?.let { model ->
model.weight = weight.grams.toDouble()
val pos = list.indexOf(model)
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: deviceNo=${model.deviceNo},realWeight=${weight.grams}")
EventBus.getDefault().post(SendWeightEvent(model.deviceNo, weight.grams))
}
}
}
}
.onTempReport { _ ->
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {
}
updateLeftStatus("")
initRecyclerView()
// 初始化 ScaleManager:注册回调、打开串口、启动接收
initScaleManager()
// 网络状态监听,独立协程,与串口初始化并行执行
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.getAccessTokenUiState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success<*> -> getTokenSuccess(state.data)
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
launch {
viewModel.getShelfListUiState.collect { state ->
when (state) {
is UiState.Loading -> showProgress()
is UiState.Success<*> -> updateUI(state.data)
is UiState.Error -> showError(state.msg)
else -> {}
}
}
}
launch {
viewModel.updateOverdueStateUiState.collect { state ->
if (state is UiState.Success<*>) {
val respData = state.data as? RespData<*>
if (respData == null) {
log("updateOverdueState: 数据类型错误,期望 RespData")
return@collect
}
val shelfResult = respData.data as? ShelfResult
if (shelfResult == null) {
log("updateOverdueState: 数据类型错误,期望 ShelfResult")
return@collect
}
val items = shelfResult.containerGoodsList
if (items.isNullOrEmpty()) {
log("updateOverdueState: 货架列表为空")
return@collect
}
applyOverdueState(items)
}
}
}
}
}
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
saveGoodsTask()
overdueTask()
viewModel.getShelfList(deviceId = App.deviceId)
}
private val defDeviceList = listOf(
//原410货柜
"7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751"
)
private fun getTokenSuccess(data: RespData<*>) {
log("getTokenSuccess: $data")
data.data?.let {
App.accessToken = it.toString()
viewModel.getShelfList(deviceId = App.deviceId)
}
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(data: RespData<*>) {
binding.include?.root?.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
val shelfResult = data.data as? ShelfResult
if (shelfResult == null) {
log("updateUI: 数据类型错误,期望 ShelfResult,实际类型:${data.data?.javaClass?.simpleName}")
loadEmptyView()
return
}
deviceName = shelfResult.deviceName
updateLeftStatus(deviceName)
App.canteenId = shelfResult.placeId
var tempList = shelfResult.containerGoodsList
if (tempList.isNullOrEmpty()) {
loadEmptyView()
return
}
list.clear()
tempList = GridLayoutTool.initListSort(tempList)
list.addAll(tempList)
shelfAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
Loading.dismiss()
toast(message)
loadEmptyView()
}
private var tipDialog: CommonDialog? = null
/** 最后一次成功收到串口数据的时间戳(毫秒) */
private var lastDataReceivedTime = System.currentTimeMillis()
/** 无数据超时提示弹窗 */
private var noDataWarningDialog: CommonDialog? = null
/**
* 无数据看门狗:每30秒检查一次,若超过5分钟未收到数据则弹出提示。
* 收到数据后会自动重置计时并关闭弹窗。
*/
private val noDataWatchdogTask = object : Runnable {
override fun run() {
// 生命周期检查,防止 Activity 销毁后继续执行
if (isFinishing || isDestroyed) return
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeActivity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
}
handler.postDelayed(this, 30 * 1000L)
}
}
/**
* 用户超时无操作时关灯
*/
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
// 清理 Handler 所有回调,防止内存泄漏
handler.removeCallbacksAndMessages(null)
// 关闭并释放 Dialog 引用,防止内存泄漏
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
private val shelfAdapter by lazy {
ShelfAdapter(list).apply {
setOnItemClickListener { _, _, position ->
onShelfItemClicked(position)
}
}
}
/**
* 处理货架格子点击事件:按需开锁,然后跳转 ShelfActivity
*/
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
val openCmd = if (list[position].deviceNo in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
launch(Intent(this@HomeActivity, ShelfActivity::class.java).also {
it.putExtra(ShelfActivity.SHELF_MODEL, list[position])
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
/**
* 处理 ShelfActivity 返回结果:更新本地列表数据并同步到服务端
*/
private fun onShelfActivityResult(position: Int, intent: Intent) {
@Suppress("DEPRECATION")
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfActivity.SHELF_MODEL, ShelfModel::class.java)
} else {
intent.getSerializableExtra(ShelfActivity.SHELF_MODEL) as? ShelfModel
} ?: return
list[position].apply {
goodsId = model.goodsId
goodsName = model.goodsName
weight = model.weight
if (model.putCabinetDay.isNullOrBlank().not()) {
putCabinetDay = model.putCabinetDay
}
}
shelfAdapter.notifyItemChanged(position)
syncShelfGoodsToServer()
}
private fun initRecyclerView() {
val layoutManager = GridLayoutTool.getLayoutManager(this@HomeActivity) ?: return
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let {
if (it is DefaultItemAnimator) {
it.supportsChangeAnimations = false
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
override fun onResume() {
super.onResume()
hideStatusBar()
// 启动无数据看门狗(每30秒检查一次)
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
// 停止无数据看门狗
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
viewModel.getShelfList(deviceId = App.deviceId)
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
it.tvEmptyContent.setTextColor(Color.WHITE)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun clearEmptyShelf(event: ClearShelfEvent) {
val item = list.firstOrNull { it.deviceNo == event.shelfNo }
item?.apply {
goodsId = ""
goodsName = null
weight = 0.0
weightBak = if (event.weight <= 0.0) 0.0 else -1 * event.weight.toDouble()
createTime = null
overdueDay = null
}
shelfAdapter.notifyItemChanged(list.indexOf(item))
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job? = null
/**
* 保存定时任务
*/
private fun saveGoodsTask() {
saveTaskJob =
taskExecutor.startIntervalTaskWithInitialDelay(1 * 60 * 1000L, 5 * 60 * 1000L) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job? = null
/**
* 过期时间定时任务
*/
private fun overdueTask() {
overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
updateOverdueState()
}
}
private fun updateOverdueState() {
viewModel.updateOverdueState()
}
/**
* 将接口返回的过期状态应用到本地列表,仅在状态变化时刷新对应 item
*/
private fun applyOverdueState(items: List<ShelfModel>) {
if (items.size != list.size) return
list.forEachIndexed { index, model ->
// 接口过期状态变化后更新状态
if (items[index].overdue != model.overdue) {
model.overdue = items[index].overdue
shelfAdapter.notifyItemChanged(index)
log("更新编号为${model.deviceNo}物品:${model.goodsName}的过期状态:${model.overdue}")
}
}
}
/**
* 构建 ShelfBody 并提交到服务端(被定时任务与 ShelfActivity 返回共用)
*/
private fun syncShelfGoodsToServer() {
log("performSync显示list:${list.toJsonString()}")
val submitList = GridLayoutTool.submitListSort(list)
submitList.forEach {
if (it.goodsId.isNullOrBlank()) {
it.weight = it.weightBak
}
}
val body = ShelfBody().also {
it.deviceId = App.deviceId ?: ""
it.canteenId = App.canteenId
it.temperature = showTemperatureC
it.humidity = showHumidity
it.goodsList = submitList
}
log("performSync提交body:${body.toJsonString()}")
viewModel.saveShelfGoodsList(body)
}
}
@@ -0,0 +1,321 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.animation.RotateAnimation
import androidx.activity.viewModels
import androidx.core.graphics.toColorInt
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.model.DeviceConfigInfo
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.net.UrlConfig
import com.shuwei.intelligent.shelves.utils.AppUtil
import com.shuwei.intelligent.shelves.utils.NetworkUtils
import com.shuwei.intelligent.shelves.utils.QRCodeUtil
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.invisible
import com.shuwei.intelligent.shelves.utils.ext.startActivity
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toObject
import com.shuwei.intelligent.shelves.utils.ext.toast
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlin.collections.forEach
import kotlin.getValue
import kotlin.ranges.downTo
import kotlin.text.isBlank
class InitActivity : BaseActivity() {
companion object {
const val TAG = "InitActivity"
}
private lateinit var binding: ActivityInitBinding
private val viewModel: NetViewModel by viewModels()
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
@SuppressLint("HardwareIds")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID( this)
// App.deviceId = "4787e213-90ab-3e32-88e0-ac271a937751"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
val appBaseUrl = SpTool.baseUrl
if (!appBaseUrl.isBlank()) {
GlobalData.appBaseUrl = appBaseUrl
} else {
GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
}
App.canteenId = "0"
// var deviceId = AppUtil.getUDID(this)
// Log.d(TAG, "onCreate: deviceId=$deviceId")
//// deviceId = "39a7abdd06b3c7ab"
//// deviceId = "3ea47dc0-3cf0-3c2f-909c-265a9a65572e"
// App.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()
// countDown()
initViews()
}
private fun countDown() {
lifecycleScope.launch {
flow {
(2 downTo 1).forEach {
delay(1000)
emit(it)
}
}.collect {
// 倒计时结束执行跳转
if (it == 1) {
startActivity<HomeActivity>()
finish()
}
}
}
}
private fun initConfig() {
binding.ivQrCode.setImageBitmap(
QRCodeUtil.generateQRCode(
content = App.deviceId,
size = 200.dp
)
)
binding.btnInit.setOnClickListener {
viewModel.getDeviceToken(App.deviceId)
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
viewModel.getDeviceTokenUiState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success<*> -> {
state.data.data?.let { deviceToken ->
Log.d(TAG, "initConfig: $deviceToken")
viewModel.getDeviceConfig(
deviceId = App.deviceId,
deviceToken = deviceToken.toString()
)
}
}
is UiState.Error -> toast(state.msg)
else -> {}
}
}
}
launch {
viewModel.getDeviceConfigUiState.collect { state ->
when (state) {
is UiState.Loading -> {}
is UiState.Success<*> -> {
state.data.data?.let {
if (it is DeviceConfigInfo) {
SpTool.put(SpTool.DEVICE_CONFIG_CACHE, it.toJsonString())
GlobalData.appBaseUrl = it.appPackageUrl?:""
App.canteenId = it.canteenId?:""
startActivity<HomeActivity>()
finish()
}
}
}
is UiState.Error -> toast(message = state.msg)
else -> {}
}
}
}
}
}
}
private fun checkConfigData(data: String): Boolean {
if (data.isBlank()) {
return false
}
val config = data.toObject<DeviceConfigInfo?>()
if (config == null) {
return false
}
GlobalData.appBaseUrl = config.appPackageUrl?:""
App.canteenId = config.canteenId?:""
return true
}
/**
* 初始化控件事件
*/
private fun initViews() {
// 连接网络按钮点击事件
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
// 启动加载动画 - 持续旋转
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200 // 旋转周期 1.2 秒
repeatCount = RotateAnimation.INFINITE // 无限循环
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
* 使用 SystemClock.elapsedRealtime() 确保精确计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
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}"
scheduleCountdown()
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}, 100) // 每 100ms 检查一次,确保精确性
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeActivity
*/
private fun navigateToHome() {
startActivity<HomeActivity>()
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
/**
* 禁用返回键
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (keyCode == KeyEvent.KEYCODE_BACK) {
// 禁用返回键,不执行任何操作
true
} else {
super.onKeyDown(keyCode, event)
}
}
}
@@ -0,0 +1,66 @@
//package com.shuwei.intelligent.shelves.activity
//
//import android.os.Bundle
//import android.os.Handler
//import android.os.Looper
//import com.shuwei.intelligent.shelves.base.BaseActivity
//import com.shuwei.intelligent.shelves.databinding.ActivitySettingBinding
//import com.shuwei.intelligent.shelves.utils.KeyboardUtil
//import java.text.SimpleDateFormat
//import java.util.Date
//import java.util.Locale
//
//class SettingActivity: BaseActivity() {
//
// private lateinit var binding: ActivitySettingBinding
// override fun onCreate(savedInstanceState: Bundle?) {
// super.onCreate(savedInstanceState)
// binding = ActivitySettingBinding.inflate(layoutInflater)
// setBackground()
// setContentView(binding.root)
// binding.btnSendCmd.setOnClickListener{
// val cmd = binding.etInputCmd.text.toString().trim()
// if (cmd.isBlank()) {
// return@setOnClickListener
// }
// var record = binding.tvCmdRecord.text.toString().trim()
// record = cmd+"\n"+record
// binding.tvCmdRecord.text = record
// val realCmd = "${HomeActivity.HEADER}0F01${cmd}${HomeActivity.FOOTER}"
// sendCmd(realCmd)
// }
// binding.ivBack.setOnClickListener { finish() }
// binding.root.setOnClickListener { v ->
// KeyboardUtil.hideKeyboard(v)
// }
// }
//
//
// private fun updateDateTime() {
// val sdf = SimpleDateFormat(ShelfActivity.YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
// val dateTime = sdf.format(Date())
// val arr = dateTime.split("***")
// updateLeftStatus(arr[0])
// updateRightStatus(arr[1])
// }
//
// private val handler = Handler(Looper.getMainLooper())
// private val updateTask = object : Runnable {
// override fun run() {
// updateDateTime()
// handler.postDelayed(this, 1000)
// }
// }
//
// override fun onResume() {
// super.onResume()
// hideStatusBar()
// handler.post(updateTask)
// }
//
// override fun onPause() {
// super.onPause()
// handler.removeCallbacks(updateTask)
// }
//
//}
@@ -0,0 +1,380 @@
package com.shuwei.intelligent.shelves.activity
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.adapter.SearchAdapter
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfBinding
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.GoodsModel
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.net.NetViewModel
import com.shuwei.intelligent.shelves.net.RespData
import com.shuwei.intelligent.shelves.net.UiState
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@SuppressLint("NotifyDataSetChanged")
class ShelfActivity : BaseActivity() {
companion object {
private const val TAG = "ShelfActivity"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
const val SHELF_MODEL = "shelfModel"
const val RECEIVER_DEVICE_INFO = "receiverDeviceInfo"
const val SHELF_WEIGHT = "shelfWeight"
const val SHELF_INDEX = "shelfIndex"
const val PAGE_SIZE = 100
}
private lateinit var binding: ActivityShelfBinding
private var shelfModel: ShelfModel? = null
private var clickIndex: Int = -1
private var realWeight: Int = 0
private var pageNo: Int = 1
private val viewModel: NetViewModel by viewModels()
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfBinding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
EventBus.getDefault().register(this)
// tvFoodWeight =binding.tvFoodWeight
shelfModel = intent.getSerializableExtra(SHELF_MODEL) as ShelfModel?
shelfModel?.let {
binding.tvShelfName.text = it.shelfName
binding.tvFoodName.text = if (it.goodsName.isNullOrBlank()) "-" else it.goodsName
realWeight = it.weight?.toInt()?:0
binding.tvFoodWeight.text = if(realWeight < 1000) "${realWeight}" else "%.3f千克".format(realWeight/1000.0)
startTime = System.currentTimeMillis()
}
initRecyclerView()
binding.etInputFood.run {
addOnActionSearchListener {
if (text.isNullOrBlank()) {
toast(hint.toString())
return@addOnActionSearchListener
}
pageNo = 1
getGoodsList()
KeyboardUtil.hideKeyboard(context, this)
}
addTextChangedListener(afterTextChanged = { editable ->
if (editable.isNullOrBlank()) {
pageNo = 1
getGoodsList()
}
})
}
binding.btnConfirm.clickWithDebounce {
if (clickIndex < 0) {
toast("请选择食材")
return@clickWithDebounce
}
val currentGoodsId = list[clickIndex].goodsId ?: ""
val data = ShelfModel(
shelfName = shelfModel?.shelfName,
goodsId = currentGoodsId,
goodsName = list[clickIndex].goodsName,
weight = realWeight.toDouble(),
overdueDay = list[clickIndex].overdueDay,
// createTime = if (shelfModel?.goodsId == currentGoodsId) null else DateTimeUtil.formatDateTime(dateTime = Date())
putCabinetDay = DateTimeUtil.formatDateTime(dateTime = Date())
)
val intent = Intent().apply {
putExtra(SHELF_MODEL, data)
}
setResult(RESULT_OK, intent)
finish()
}
binding.root.setOnClickListener { v ->
KeyboardUtil.hideKeyboard(v)
}
binding.btnClearZero.setOnClickListener {
clearZero()
}
binding.btnClearEmpty.setOnClickListener {
val tempWeight = shelfModel?.weight?.toInt()?:0
clearZero()
binding.tvFoodName.text = "-"
//binding.tvFoodWeight.text = "0克"
EventBus.getDefault().post(ClearShelfEvent(shelfModel!!.deviceNo, tempWeight))
}
sendCmd(ScaleManager.buildGetStatusCmd())
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getGoodsListUiState.collect { state ->
when (state) {
is UiState.Loading -> showProgress()
is UiState.Success<*> -> updateUI(state.data)
is UiState.Error -> showError(state.msg)
else -> {}
}
}
}
}
// registerReceiver(receiver, IntentFilter(RECEIVER_DEVICE_INFO + shelfModel?.deviceNo))
binding.ivBack?.setOnClickListener { finish() }
binding.refreshLayout.let {
it.setEnableRefresh(true)
it.setEnableLoadMore(false)
it.setOnRefreshListener {
pageNo = 1
getGoodsList()
}
it.setOnLoadMoreListener { getGoodsList() }
}
getGoodsList()
// TaskManager.startDeviceTask()
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) {
it.finishRefresh(500)
} else if (binding.refreshLayout.state == RefreshState.Loading) {
it.finishLoadMore(500)
} else {
}
}
}
private fun getGoodsList() {
viewModel.getGoodsList(
canteenId = App.canteenId,
goodsName = binding.etInputFood.text.trim().toString(),
pageNo = pageNo,
pageSize = PAGE_SIZE
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(data: RespData<*>) {
binding.include?.root?.gone()
finishRefresh()
binding.root.postDelayed({ Loading.dismiss() }, 200)
Log.d(TAG, "updateUI: ${data.toJsonString()}")
val goodsList = data.data as MutableList<GoodsModel>
if (data.data.isNullOrEmpty()) {
if (pageNo == 1) {
loadEmptyView()
}
return
}
if(pageNo == 1){
list.clear()
}
// var firstOne:GoodsModel?=null
// if (pageNo == 1) {
// if (shelfModel?.goodsId.isNullOrBlank().not()) {
// firstOne = list.firstOrNull { it.goodsId == shelfModel?.goodsId }
// if (firstOne == null) {
// firstOne = GoodsModel(
// goodsId = shelfModel?.goodsId,
// goodsName = shelfModel?.goodsName,
// isClicked = true
// )
// }
// }
// list.clear()
// }
// val records = data.data.records
// if (pageNo == 1) {
// firstOne?.let { one ->
// val filterResult = records.firstOrNull{one.goodsId == it.goodsId }
// if (filterResult != null) {
// records.remove(filterResult)
// }
// clickIndex = 0
// list.add(one)
// }
// }
list.addAll(goodsList)
val enableLoadMore = goodsList.size >= PAGE_SIZE
binding.refreshLayout.setEnableLoadMore(enableLoadMore)
if (enableLoadMore) {
pageNo++
}
searchAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
finishRefresh()
Loading.dismiss()
toast(message)
if (pageNo == 1) {
loadEmptyView()
}
}
// private val receiver = object : BroadcastReceiver() {
// override fun onReceive(context: Context?, intent: Intent?) {
// intent?.let {
// if (it.action == RECEIVER_DEVICE_INFO + shelfModel?.deviceNo) {
// val weight = it.getDoubleExtra(SHELF_WEIGHT, 0.0)
// Log.d(TAG, "getDeviceInfo:onReceive: weight=$weight")
// realWeight = (weight * 1000).toInt()
// binding.tvFoodWeight.text = "${realWeight}克"
// }
// }
// }
// }
override fun onDestroy() {
// unregisterReceiver(receiver)
EventBus.getDefault().unregister(this)
// TaskManager.cancelDeviceTask()
super.onDestroy()
}
private val list: MutableList<GoodsModel> = mutableListOf()
private val searchAdapter by lazy {
SearchAdapter(list).apply {
setOnDebouncedItemClick { adapter, v, position ->
this@ShelfActivity.clickIndex = position
list.forEach { it.isClicked = false }
list[position].isClicked = true
notifyDataSetChanged()
binding.tvFoodName.text = list[position].goodsName
KeyboardUtil.hideKeyboard(v.context, v)
}
}
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfActivity, 2)
adapter = searchAdapter
}
}
/**
* 覆写时钟回调:左侧状态栏显示年月日星期,右侧状态栏显示时分秒
*/
override fun onClockTick() {
val sdf = SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
val arr = sdf.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
override fun onResume() {
super.onResume()
hideStatusBar()
}
override fun onPause() {
super.onPause()
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
searchAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener {
pageNo = 1
getGoodsList()
}
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_gray)
it.ivEmptyIcon.updateLayoutParams<LinearLayout.LayoutParams> {
val swDpValue = resources?.configuration?.smallestScreenWidthDp ?: 0
width = if (swDpValue > 600) 200.dp else 120.dp
}
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
public fun receiveWeightEvent(event: SendWeightEvent) {
if (event.shelfNo == (shelfModel?.deviceNo ?: 0)) {
val intervalTime = System.currentTimeMillis() - startTime
val weight = event.weight
log("编号${event.shelfNo}货架获取重量成功:${weight}克,数据获取间隔时间为:${intervalTime}")
binding.tvFoodWeight.text = if(weight < 1000) "${weight}" else "%.3f千克".format(weight/1000.0)
startTime = System.currentTimeMillis()
window.decorView.postDelayed({ Loading.dismiss() }, 1000)
}
}
private fun clearZero() {
lifecycleScope.launch {
val shelfNo = shelfModel?.deviceNo ?: 0
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(shelfNo)
log("编号${shelfNo}货架执行清零指令:${zeroClearingCmd}")
sendCmd(zeroClearingCmd)
// sendCmd(
// if (shelfNo in 1..5) ProtocolConstants.LEFT_SHELF_OPEN_CMD else ProtocolConstants.RIGHT_SHELF_OPEN_CMD
// )
//清零
realWeight = 0
// binding.tvFoodWeight.text = "${realWeight}克"
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
Loading.show(this@ShelfActivity)
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
KeyboardUtil.hideKeyboard(window.decorView)
}
private var startTime = 0L
}
@@ -0,0 +1,50 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.content.res.ColorStateList
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.shuwei.intelligent.shelves.model.GoodsModel
class SearchAdapter(list: MutableList<GoodsModel>) :
BaseQuickAdapter<GoodsModel, SearchAdapter.VH>(list) {
inner class VH(var binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemSearchBinding.inflate(inflater, parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: GoodsModel?) {
val binding = holder.binding
binding.btnFoodName.run {
text = " ${item!!.goodsName}"
setTextColor(
getColor(if (item.isClicked) R.color.shelf_name_blue else R.color.black999)
)
val btnStrokeColor = if (item.isClicked) "#4969F5" else "#E6E6E6"
strokeColor = ColorStateList.valueOf(btnStrokeColor.toColorInt())
}
// val bgDrawable = createCustomDrawable(
// solidColor = "#FFFFFF",
// strokeWidth = 2.dp,
// strokeColor = if (item!!.isClicked) "#4969F5" else "#E6E6E6",
// radius = 12.dp
// )
// binding.root.background = bgDrawable
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -0,0 +1,117 @@
package com.shuwei.intelligent.shelves.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfBinding
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.visible
import java.util.Date
import kotlin.math.abs
class ShelfAdapter(list: MutableList<ShelfModel>) :
BaseQuickAdapter<ShelfModel, ShelfAdapter.VH>(list) {
inner class VH(var binding: ListItemShelfBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val inflater = LayoutInflater.from(context)
val binding = ListItemShelfBinding.inflate(inflater, parent, false)
return VH(binding)
}
private fun stringToDate(createTime: String?): Date? {
if (createTime.isNullOrBlank()) {
return null
}
return DateTimeUtil.stringToDate(createTime)
}
override fun onBindViewHolder(holder: VH, position: Int, item: ShelfModel?) {
val shelfNo = item!!.deviceNo
//货架-
item.shelfName = "${if (shelfNo < 10) "0${shelfNo}" else "$shelfNo"}"
val weight = item.weight ?: 0.0
val binding = holder.binding
if (item.overdueDay.isNullOrBlank()) {
binding.ivStaleFood.gone()
} else {
runCatching {
// val shelfDate = stringToDate(item.putCabinetDay)
binding.ivStaleFood.run {
if (item.overdue == true) visible() else gone()
}
// if (shelfDate == null) {
// binding.ivStaleFood.gone()
// } else {
// val days = item.overdueDay!!.toInt()
// val resultDate = DateTimeUtil.addDays(shelfDate, days)
// if (resultDate.before(Date())) {
// binding.ivStaleFood.visible()
// } else {
// binding.ivStaleFood.gone()
// }
// }
}.onFailure {
binding.ivStaleFood.gone()
}
}
//?.setImageResource()
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
//val showWeight = if (weight == 0.toDouble()) "0克" else "%.3f千克".format(weight)
text = if(abs(weight) < 1000) "${weight.toInt()}" else "%.3f千克".format(weight/1000.0)
//"${(weight*1000).roundToInt()}克"
}
//val isBlankShelf = weight <= 0
val isBlankShelf = item.goodsName.isNullOrBlank()
if (isBlankShelf) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.run {
setTextColor(getColor(R.color.shelf_name_white))
text = item.shelfName
}
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
// binding.tvFoodWeight.run {
// setTextColor(getColor(R.color.food_weight_blue))
// text = "0千克"
// }
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.run {
setTextColor(getColor(R.color.shelf_name_blue))
text = item.shelfName
}
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.goodsName
}
// binding.tvFoodWeight.run {
// setTextColor(getColor(R.color.food_weight_orange))
//// text = "${weight/1000F}千克"
// text = "${weight}克"
// }
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.putCabinetDay
}
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -112,7 +112,7 @@ open class BaseActivity : AppCompatActivity() {
} }
// 5分钟 // 5分钟
private val TIME_OUT: Long = (1 * 60 * 1000).toLong() private val TIME_OUT: Long = (5 * 60 * 1000).toLong()
private val mHandler by lazy { Handler(Looper.getMainLooper()) } private val mHandler by lazy { Handler(Looper.getMainLooper()) }
private val mTimeoutRunnable = Runnable { private val mTimeoutRunnable = Runnable {
// 用户长时间无操作时回调,子类可覆写实现具体逻辑(如关灯) // 用户长时间无操作时回调,子类可覆写实现具体逻辑(如关灯)
@@ -39,8 +39,8 @@ class EnvSwitchDialog(
override fun initView() { override fun initView() {
// 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选 // 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选
when (GlobalData.appBaseUrl) { when (GlobalData.appBaseUrl) {
GlobalData.LOCAL_BASE_URL -> binding.rbTest.isChecked = true GlobalData.TEST_BASE_URL -> binding.rbTest.isChecked = true
GlobalData.TEST_BASE_URL -> binding.rbUat.isChecked = true GlobalData.UAT_BASE_URL -> binding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true
} }
@@ -55,8 +55,8 @@ class EnvSwitchDialog(
return@setOnClickListener return@setOnClickListener
} }
val newUrl = when (binding.rgEnv.checkedRadioButtonId) { val newUrl = when (binding.rgEnv.checkedRadioButtonId) {
binding.rbTest.id -> GlobalData.LOCAL_BASE_URL binding.rbTest.id -> GlobalData.TEST_BASE_URL
binding.rbUat.id -> GlobalData.TEST_BASE_URL binding.rbUat.id -> GlobalData.UAT_BASE_URL
binding.rbProd.id -> GlobalData.PROD_BASE_URL binding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setOnClickListener else -> return@setOnClickListener
} }
@@ -1,108 +0,0 @@
package com.shuwei.intelligent.shelves.dialog
import android.app.Dialog
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseDialog
import com.shuwei.intelligent.shelves.databinding.DialogChipSelectBinding
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.hideKeyboard
import com.shuwei.intelligent.shelves.utils.ext.toast
/**
* 带ChipGroup选择的弹窗,支持传入选项列表,回调返回选中索引
*/
class ChipSelectDialog(context: Context) : Dialog(context, R.style.DialogTheme) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DialogChipSelectBinding.inflate(LayoutInflater.from(context))
setContentView(binding.root)
setCancelable(false)
setCanceledOnTouchOutside(false)
window?.apply {
//setLayout(defWidth, defHeight)
setBackgroundDrawable(ColorDrawable())
setGravity(Gravity.CENTER)
}
binding.root.setOnClickListener { it.hideKeyboard() }
initView()
}
private lateinit var binding: DialogChipSelectBinding
/** 弹窗标题 */
var dialogTitle: String? = null
/** 选项列表 */
var items: List<String?> = emptyList()
/** 选中回调,返回选中的索引,取消时不触发 */
var onItemSelected: ((Int) -> Unit)? = null
/** 当前选中的索引,默认-1表示未选中 */
private var selectedIndex: Int = -1
fun initView() {
binding.btnCancel.setOnClickListener {
dismiss()
}
binding.btnConfirm.setOnClickListener {
if (binding.chipGroup.checkedChipId == -1) {
context.toast("请选择$dialogTitle")
return@setOnClickListener
}
onItemSelected?.invoke(selectedIndex)
dismiss()
}
}
override fun show() {
super.show()
binding.tvDialogTitle.text = dialogTitle
setupChips()
}
/**
* 动态创建Chip并添加到ChipGroup中
*/
private fun setupChips() {
binding.chipGroup.removeAllViews()
selectedIndex = -1
items.forEachIndexed { index, label ->
val chip = Chip(context).apply {
text = label
isCheckable = true
isCheckedIconVisible = false
textSize = 22f
chipStartPadding = 18.dp.toFloat()
chipEndPadding = 18.dp.toFloat()
textStartPadding = 8.dp.toFloat()
textEndPadding = 8.dp.toFloat()
chipMinHeight = 50.dp.toFloat()
setTextColor(resources.getColorStateList(R.color.chip_text_color, null))
chipBackgroundColor = resources.getColorStateList(R.color.chip_bg_color, null)
chipStrokeWidth = 1f
chipStrokeColor = resources.getColorStateList(R.color.chip_stroke_color, null)
setOnClickListener {
selectedIndex = index
}
}
binding.chipGroup.apply {
isSingleLine = false
addView(chip)
}
}
}
}
@@ -0,0 +1,6 @@
package com.shuwei.intelligent.shelves.model
class DeviceConfigInfo {
var appPackageUrl:String? = null
var canteenId:String? = null
}
@@ -0,0 +1,10 @@
package com.shuwei.intelligent.shelves.model
import java.io.Serializable
data class GoodsModel(
var goodsId: String? = null,
var goodsName: String? = null,
var overdueDay: String? = null,
var isClicked: Boolean = false
) : Serializable
@@ -0,0 +1,8 @@
package com.shuwei.intelligent.shelves.model
data class GoodsRecord(
val total: Int = 0,
val size: Int = 0,
val page: Int = 0,
val records: MutableList<GoodsModel>? = null
)
@@ -0,0 +1,11 @@
package com.shuwei.intelligent.shelves.model
class ShelfBody {
var deviceId: String = ""
var canteenId: String = ""
var temperature: String? = null
var humidity: String = "0"
var goodsList: List<ShelfModel>? = null
}
@@ -0,0 +1,21 @@
package com.shuwei.intelligent.shelves.model
import java.io.Serializable
data class ShelfModel(
var id: String? = null,
var deviceNo: Int = 0,
var shelfName: String? = null,
var goodsId: String? = "",
var goodsName: String? = null,
var deviceId: String? = null,
var weight: Double = 0.0,
var weightBak: Double = 0.0,
var createTime: String? = null,
var putCabinetDay: String? = null,
var updateTime: String? = null,
var overdueDay: String? = null,
var temperature: String? = null,
var humidity: String = "0",
var overdue:Boolean ?= false
) : Serializable
@@ -0,0 +1,8 @@
package com.shuwei.intelligent.shelves.model
data class ShelfResult(
val placeId: String = "",
val canteenName: String = "",
val deviceName: String = "",
val containerGoodsList: List<ShelfModel>? = null
)
@@ -0,0 +1,61 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.DeviceConfigInfo
import com.shuwei.intelligent.shelves.model.GoodsModel
import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.model.ShelfResult
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
interface ApiService {
/**
* device获取token
*/
@GET("/sys/getEquipmentToken")
suspend fun getDeviceToken(
@Query("qrcodeId") qrcodeId: String,
@Query("appVersion") appVersion: String = App.appVersion
): RespData<String>
/**
*获取配置信息
*/
@GET("/equipment/stEquipment/queryByEquipmentCode")
suspend fun getDeviceInfo(
@Query("equipmentCode") equipmentCode: String,
@Query("appVersion") appVersion: String = App.appVersion
): RespData<DeviceConfigInfo>
@GET
suspend fun getAccessToken(
@Url url: String = UrlConfig.GET_ACCESS_TOKEN,
@Query("qrcodeId") qrcodeId: String
): RespData<String?>
@GET
suspend fun getShelfList(
@Url url: String = UrlConfig.GET_SHELF_LIST,
@Query("deviceId") deviceId: String
): RespData<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
): RespData<MutableList<GoodsModel>?>
@POST
suspend fun saveShelfGoodsList(
@Url url: String = UrlConfig.SAVE_SHELF_GOODS_LIST,
@Body body: ShelfBody
): RespData<Any?>
}
@@ -1,21 +0,0 @@
package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.App
import okhttp3.Interceptor
import okhttp3.Response
/**
* v1.1 接口专用拦截器
* /nutrition/neglect/ 路径无需鉴权,仅注入基础请求头
*/
class CabinetRequestInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-DEVICE-CODE", App.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
.build()
return chain.proceed(request)
}
}
@@ -4,9 +4,6 @@ import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import com.shuwei.intelligent.shelves.BuildConfig import com.shuwei.intelligent.shelves.BuildConfig
import com.shuwei.intelligent.shelves.GlobalData import com.shuwei.intelligent.shelves.GlobalData
import com.sw.scalefusion.shelf.net.ApiServiceV3
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
@@ -20,32 +17,16 @@ import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import kotlin.apply import kotlin.apply
/** 动态域名拦截器:将 Retrofit 请求的 baseUrl 实时替换为 [GlobalData.appBaseUrl] */ val apiService: ApiService = Retrofit.Builder()
class DynamicBaseUrlInterceptor : Interceptor { // .baseUrl(UrlConfig.BASE_URL)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newUrl = GlobalData.appBaseUrl.toHttpUrlOrNull() ?: return chain.proceed(request)
val url = request.url.newBuilder()
.scheme(newUrl.scheme)
.host(newUrl.host)
.port(newUrl.port)
.build()
return chain.proceed(request.newBuilder().url(url).build())
}
}
/** V3 接口专用实例 */
val apiServiceV3: ApiServiceV3 = Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl) .baseUrl(GlobalData.appBaseUrl)
.client(HttpManager.instance.cabinetClient) .client(HttpManager.instance.client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(ApiServiceV3::class.java) .create(ApiService::class.java)
class HttpManager private constructor() { class HttpManager private constructor() {
val client: OkHttpClient by lazy {
/** 接口专用 OkHttpClient,注入 Content-Type、Accept、X-DEVICE-CODE、authorization */
val cabinetClient: OkHttpClient by lazy {
OkHttpClient.Builder() OkHttpClient.Builder()
.apply { .apply {
connectTimeout(30, TimeUnit.SECONDS) connectTimeout(30, TimeUnit.SECONDS)
@@ -53,14 +34,24 @@ class HttpManager private constructor() {
writeTimeout(30, TimeUnit.SECONDS) writeTimeout(30, TimeUnit.SECONDS)
sslSocketFactory(createSSLSocketFactory(), TrustAllCerts()) sslSocketFactory(createSSLSocketFactory(), TrustAllCerts())
hostnameVerifier { _, _ -> true } hostnameVerifier { _, _ -> true }
addInterceptor(DynamicBaseUrlInterceptor()) // addInterceptor(LoggingInterceptor())
addNetworkInterceptor(HttpLoggingInterceptor(logger = { addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Log.d("HttpManager", "v2 okhttp ==>${it}") Log.d("HttpManager","okhttp logger ==>${it}")
}).apply { }).apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY level =
else HttpLoggingInterceptor.Level.NONE if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}) })
addInterceptor(CabinetRequestInterceptor()) // addInterceptor { chain ->
// val request = chain.request().newBuilder()
// .header("x-access-token", App.accessToken)
// .build()
// chain.proceed(request)
// }
addInterceptor(RequestInterceptor())
} }
.build() .build()
} }
@@ -0,0 +1,149 @@
package com.shuwei.intelligent.shelves.net
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.ShelfBody
import com.shuwei.intelligent.shelves.utils.ext.toJsonString
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class NetViewModel : ViewModel() {
private val _getShelfListUiState = MutableStateFlow<UiState>(UiState.Initial)
val getShelfListUiState: StateFlow<UiState> = _getShelfListUiState
private val _getGoodsListUiState = MutableStateFlow<UiState>(UiState.Initial)
val getGoodsListUiState: StateFlow<UiState> = _getGoodsListUiState
private val _getAccessTokenUiState = MutableStateFlow<UiState>(UiState.Initial)
val getAccessTokenUiState: StateFlow<UiState> = _getAccessTokenUiState
private val _getDeviceTokenUiState = MutableStateFlow<UiState>(UiState.Initial)
val getDeviceTokenUiState: StateFlow<UiState> = _getDeviceTokenUiState
private val _getDeviceConfigUiState = MutableStateFlow<UiState>(UiState.Initial)
val getDeviceConfigUiState: StateFlow<UiState> = _getDeviceConfigUiState
private val _updateOverdueStateUiState = MutableStateFlow<UiState>(UiState.Initial)
val updateOverdueStateUiState: StateFlow<UiState> = _updateOverdueStateUiState
fun getDeviceToken(deviceId: String) {
viewModelScope.launch {
_getDeviceTokenUiState.value = UiState.Loading
runCatching {
val response = apiService.getDeviceToken(qrcodeId = deviceId)
if (response.isSuccess()) {
_getDeviceTokenUiState.value = UiState.Success(response)
} else {
_getDeviceTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getDeviceTokenUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun getDeviceConfig(deviceId: String, deviceToken: String) {
viewModelScope.launch {
_getDeviceConfigUiState.value = UiState.Loading
runCatching {
val response = apiService.getDeviceInfo(equipmentCode = deviceId)
if (response.isSuccess()) {
_getDeviceConfigUiState.value = UiState.Success(response)
} else {
_getDeviceConfigUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getDeviceConfigUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun getAccessToken(deviceId: String) {
viewModelScope.launch {
_getAccessTokenUiState.value = UiState.Loading
runCatching {
val response = apiService.getAccessToken(qrcodeId = deviceId)
if (response.isSuccess()) {
_getAccessTokenUiState.value = UiState.Success(response)
} else {
_getAccessTokenUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getAccessTokenUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun getShelfList(deviceId: String) {
viewModelScope.launch {
_getShelfListUiState.value = UiState.Loading
runCatching {
val response = apiService.getShelfList(deviceId = deviceId)
if (response.isSuccess()) {
_getShelfListUiState.value = UiState.Success(response)
} else {
_getShelfListUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getShelfListUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun updateOverdueState() {
viewModelScope.launch {
runCatching {
val response = apiService.getShelfList(deviceId = App.deviceId)
if (response.isSuccess()) {
_updateOverdueStateUiState.value = UiState.Success(response)
} else {
_updateOverdueStateUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_updateOverdueStateUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun getGoodsList(
canteenId: String = "",
goodsName: String = "",
pageNo: Int = 1,
pageSize: Int = 50,
) {
viewModelScope.launch {
_getGoodsListUiState.value = UiState.Loading
runCatching {
val response = apiService.getGoodsList(
canteenId = canteenId,
goodsName = goodsName,
pageNo = pageNo,
pageSize = pageSize
)
if (response.isSuccess()) {
_getGoodsListUiState.value = UiState.Success(response)
} else {
_getGoodsListUiState.value = UiState.Error(response.msg ?: "请求失败")
}
}.onFailure {
_getGoodsListUiState.value = UiState.Error(it.message ?: "请求异常")
}
}
}
fun saveShelfGoodsList(body: ShelfBody) {
viewModelScope.launch {
runCatching {
val resp = apiService.saveShelfGoodsList(body = body)
Log.d("NetViewModel", "saveShelfGoodsList,performSync: resp:${resp.toJsonString()}")
}.onFailure {
it.printStackTrace()
Log.d("NetViewModel", "saveShelfGoodsList异常:${it.message}")
}
}
}
}
@@ -0,0 +1,35 @@
package com.shuwei.intelligent.shelves.net
import android.text.TextUtils
import com.shuwei.intelligent.shelves.App
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
/**
* 请求拦截器
*/
class RequestInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val requestBuilder = originalRequest.newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Access-Token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJjYW50ZWVuSWQiOiJiZTE1NDgzMS0zNDY2LTNiYTItYTJlYS01NzY1MmM5MTlmZWQiLCJ0eXBlIjoiNCIsInVzZXJJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDEifQ.sN40cOC-O5WQFrF4IDUs8fFlkNdUKLbJt_rHyTsgYYM")
.header("X-DEVICE-CODE", App.deviceId)
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
val newRequest = requestBuilder.build()
return chain.proceed(newRequest)
}
// private fun getToken(originRequest: Request): String {
// val tokenParam = originRequest.header("X-Access-Token")
// if (!TextUtils.isEmpty(tokenParam)) return tokenParam!!
// // 从本地获取token的逻辑
// val spUtil = SPUtil.getInstance(context = App.getContext())
// return spUtil.get(GlobalKey.KEY_TOKEN, "") as String
//// return "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MTQ2LCJ1c2VyTmFtZSI6IjEzNjgxNDQ4ODU2IiwibmFtZSI6IuW-kOejiiIsInBhc3N3b3JkIjoiOTllOTQ1ZmVjZmZjNWIzNDI4MmUwNDRlODYyMzdjM2UxZjU5OWY5OCIsInNhbHQiOiI0NmEzMzUzYWU4OTA0MDYxYjMzODU5ZWNlYTBlMGE2NyIsInBob25lIjoiMTM2ODE0NDg4NTYiLCJzdGF0dXMiOjEsInVzZXJUeXBlIjoyLCJjcmVhdGVVc2VyTm8iOiIxNDEiLCJjcmVhdGVUaW1lIjoxNjI2OTQzNDE2MDAwLCJ1cGRhdGVVc2VyTm8iOiIxNDEiLCJ1cGRhdGVUaW1lIjoxNjI2OTQzNDE2MDAwLCJpc0RlbCI6ZmFsc2UsImVhSWQiOjk5LCJlYUlkTGlzdCI6Ijk5IiwiaXNTaG9wTWFuYWdlciI6dHJ1ZSwidXNlck5vIjoiMWY5Nzk5ZWMtODlkYi00MWYyLTk1YTEtY2UzNTA3Y2QyMTU2In0.f7wImPgBOYMV0AqRchnXGPkUWZN9dFJ9gLPsaB8uNldd21IfXLjJl8y-FiWVuVUvlwUvGpgqGDFR1JKj5H7amw"
// }
}
@@ -27,11 +27,5 @@ object UrlConfig {
var SAVE_SHELF_GOODS_LIST = var SAVE_SHELF_GOODS_LIST =
"${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/saveShelvesGoodsList" "${GlobalData.appBaseUrl}/terminal/neglect/smartShelves/app/smartShelves/app/saveShelvesGoodsList"
// v1.1 新接口
var CABINET_INIT = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/init"
var CLEAN_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/clean-package-options"
var MEAL_PACKAGE_OPTIONS = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/meal-package-options"
var CABINET_RECORD_SYNC = "${GlobalData.appBaseUrl}/nutrition/neglect/cabinet/record/sync"
} }
@@ -145,8 +145,8 @@ object ProtocolConstants {
"7a991439-3a12-3ef7-809b-c0258b839473", "7a991439-3a12-3ef7-809b-c0258b839473",
//原1楼餐厅货柜 //原1楼餐厅货柜
"4787e213-90ab-3e32-88e0-ac271a937751", "4787e213-90ab-3e32-88e0-ac271a937751",
// TODO: 测试设备---------- //新设备
"2987f0c5-5754-33e9-b00a-251db5e2e55f" "c9ffac4b-231c-352d-b553-919136c3fe1b"
) )
/** /**
@@ -168,22 +168,6 @@ object ProtocolConstants {
"505ce1b1-facc-3eb2-855a-3dadda5ba358" "505ce1b1-facc-3eb2-855a-3dadda5ba358"
) )
/**
* 设备 cabinetType 映射,用于 V3 API
* 1=毛菜柜,2=净菜柜,默认 2
*/
val deviceCabinetType = mapOf(
// 原410货柜 → 毛菜柜
"7a991439-3a12-3ef7-809b-c0258b839473" to 1,
// 原1楼餐厅货柜 → 净菜柜
"4787e213-90ab-3e32-88e0-ac271a937751" to 2,
// 新设备 → 净菜柜
"d369f9b0-1c5b-3066-ba72-988e1449cecc" to 2,
"505ce1b1-facc-3eb2-855a-3dadda5ba358" to 2,
)
fun cabinetType(): Int = deviceCabinetType[App.deviceId] ?: 2
/** 激活指令mapkey为设备号,value为激活码 */ /** 激活指令mapkey为设备号,value为激活码 */
val ACTIVE_MAP = mutableMapOf( val ACTIVE_MAP = mutableMapOf(
//原410货柜 //原410货柜
@@ -76,8 +76,8 @@ object ScaleManager {
/** 当前设备激活码,非空时收到激活请求自动应答 */ /** 当前设备激活码,非空时收到激活请求自动应答 */
private var activateCode: String = "" private var activateCode: String = ""
/** 心跳应答:心跳上报间隔(秒),默认1秒,保证重量更新实时性 */ /** 心跳应答:心跳上报间隔(秒),默认5秒 */
var heartbeatInterval: Int = 1 var heartbeatInterval: Int = 5
/** 心跳应答:开锁后未开门自动落锁时间(秒),默认6秒 */ /** 心跳应答:开锁后未开门自动落锁时间(秒),默认6秒 */
var lockTimeout: Int = 6 var lockTimeout: Int = 6
@@ -166,23 +166,10 @@ object ScaleManager {
val flow = serialPort?.read() val flow = serialPort?.read()
flow?.collect { buffer -> flow?.collect { buffer ->
val data = buffer.toHexString2().uppercase(Locale.ROOT) val data = buffer.toHexString2().uppercase(Locale.ROOT)
// IO 线程:帧拼装与解析
val parsed = tryParseCompleteFrame(data)
if (parsed != null) {
// IO 线程:发送自动应答(不占用主线程)
sendAutoResponse(parsed.first)
// IO 线程:心跳日志降频
if (parsed.first.cmd == CMD_HEARTBEAT || parsed.first.cmd == CMD_HEARTBEAT_STORE) {
logHeartbeatIfNeeded(parsed.first, parsed.second)
}
}
// 仅回调分发和 preHook 需要主线程
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
try { try {
preHook?.invoke(data) preHook?.invoke(data)
if (parsed != null) { onDataReceived(data)
invokeCallbacks(parsed.first)
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
log("串口数据处理异常: ${e.message}") log("串口数据处理异常: ${e.message}")
@@ -192,7 +179,7 @@ object ScaleManager {
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
delay(10) delay(50)
} }
} }
} }
@@ -202,117 +189,19 @@ object ScaleManager {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
/** /**
* 接收串口原始数据(公开 API,主线程安全 * 接收串口原始数据(在串口回调中调用此方法
* *
* 内部自动完成分片拼装 → 帧解析 → 自动应答 → 回调分发 * 内部自动完成分片拼装 → 帧解析 → 命令分发 → 自动应答
* *
* @param hexData 串口收到的十六进制字符串(可能为分片数据) * @param hexData 串口收到的十六进制字符串(可能为分片数据)
*/ */
fun onDataReceived(hexData: String) { fun onDataReceived(hexData: String) {
val parsed = tryParseCompleteFrame(hexData) ?: return val completeFrame = parser.appendData(hexData) ?: return
sendAutoResponse(parsed.first) val frame = parser.parseFrame(completeFrame) ?: run {
invokeCallbacks(parsed.first)
}
/**
* IO 线程安全的帧拼装与解析(纯计算,无副作用)
*
* @return Pair<ScaleFrame, rawHex> 或 null(分片未完整/解析失败)
*/
private fun tryParseCompleteFrame(hexData: String): Pair<ScaleFrame, String>? {
val completeFrame = parser.appendData(hexData) ?: return null
val frame = parser.parseFrame(completeFrame)
if (frame == null) {
log("帧解析失败,原始数据:$completeFrame") log("帧解析失败,原始数据:$completeFrame")
return
} }
return frame?.let { it to completeFrame } dispatch(frame, completeFrame)
}
/**
* IO 线程:发送协议自动应答(不阻塞主线程)
*/
private fun sendAutoResponse(frame: ScaleFrame) {
when (frame.cmd) {
CMD_HEARTBEAT, CMD_HEARTBEAT_STORE -> {
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}0102%02X%02X${firmwareHex}${FOOTER_HEX}".format(
heartbeatInterval and 0xFF, lockTimeout and 0xFF
)
sendCmd(resp)
}
CMD_UNLOCK -> {
val terminalIdHex = frame.terminalId.toAsciiHex()
val firmwareHex = frame.firmwareVersion.toAsciiHex()
sendCmd("${HEADER_HEX}020200${terminalIdHex}${firmwareHex}${FOOTER_HEX}")
}
CMD_CARD_UPLOAD -> sendCmd("${HEADER_HEX}050203${FOOTER_HEX}")
CMD_OPERATION_RECORD -> sendCmd("${HEADER_HEX}0B0203${FOOTER_HEX}")
CMD_TEMP_REPORT -> sendCmd("${HEADER_HEX}100200${FOOTER_HEX}")
CMD_POSITION_REPORT -> sendCmd("${HEADER_HEX}0E0200${FOOTER_HEX}")
CMD_TIME_SYNC -> {
val nowMs = System.currentTimeMillis()
val timeSec = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
val timeHex = "%08X".format(timeSec.toInt())
.chunked(2).reversed().joinToString("")
sendCmd("${HEADER_HEX}0C02${timeHex}${FOOTER_HEX}")
}
CMD_ACTIVATE -> {
if (activateCode.isNotBlank()) sendCmd(activateCode)
}
}
}
/**
* 主线程:仅回调分发(不含 sendCmd 和日志)
*/
private fun invokeCallbacks(frame: ScaleFrame) {
when (frame.cmd) {
CMD_ACTIVATE -> {
activateListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_HEARTBEAT, CMD_HEARTBEAT_STORE -> {
val data = parser.parseHeartbeat(frame.rawBody)
if (data != null) {
heartbeatListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_UNLOCK -> {
lockListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_GET_STATUS -> {
val data = parser.parseStatus(frame.rawBody)
if (data != null) {
statusListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_CARD_UPLOAD -> {
cardListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_OPERATION_RECORD -> {
operationListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_TIME_SYNC -> {
timeSyncListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_POSITION_REPORT -> {
positionListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
CMD_TEMP_REPORT -> {
tempReportListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
else -> {
rawListeners[frame.cmd]?.forEach { it(frame) }
}
}
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -554,28 +443,164 @@ object ScaleManager {
rawListeners.clear() rawListeners.clear()
logListeners.clear() logListeners.clear()
activateCode = "" activateCode = ""
lastHeartbeatLogSecond = 0
parser.reset() parser.reset()
} }
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// 日志与工具 // 内部命令分发与自动应答
// ---------------------------------------------------------------- // ----------------------------------------------------------------
/** 上次心跳日志时间(秒),用于降频:同一秒内不重复记录心跳日志 */ private fun dispatch(frame: ScaleFrame, rawHex: String) {
private var lastHeartbeatLogSecond: Long = 0 when (frame.cmd) {
CMD_ACTIVATE -> handleActivate(frame, rawHex)
CMD_HEARTBEAT,
CMD_HEARTBEAT_STORE -> handleHeartbeat(frame, rawHex)
CMD_UNLOCK -> handleLockFeedback(frame, rawHex)
CMD_GET_STATUS -> handleStatus(frame, rawHex)
CMD_CARD_UPLOAD -> handleCardUpload(frame, rawHex)
CMD_OPERATION_RECORD -> handleOperationRecord(frame, rawHex)
CMD_TIME_SYNC -> handleTimeSync(frame, rawHex)
CMD_POSITION_REPORT -> handlePositionReport(frame, rawHex)
CMD_TEMP_REPORT -> handleTempReport(frame, rawHex)
else -> {
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到未处理命令:0x%02X,终端ID:${frame.terminalId},原始数据:$rawHex".format(frame.cmd))
}
}
}
/** /**
* IO 线程:心跳日志降频 — 每秒最多记录一次 * 处理终端激活(CMD=0x00
* 若 activateMap 中存在当前 deviceId 对应的激活码,则自动应答;
* 否则仍回调 onActivate,由业务方自行处理
*/ */
private fun logHeartbeatIfNeeded(frame: ScaleFrame, rawHex: String) { private fun handleActivate(frame: ScaleFrame, rawHex: String) {
val nowSec = System.currentTimeMillis() / 1000 log("收到[终端激活]指令,终端ID${frame.terminalId},固件版本:${frame.firmwareVersion},原始数据:$rawHex")
if (nowSec == lastHeartbeatLogSecond) return val activeCmd = activateCode
lastHeartbeatLogSecond = nowSec if (!activeCmd.isNullOrBlank()) {
sendCmd(activeCmd)
log("激活码自动应答:$activeCmd")
}
activateListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
}
/**
* 处理心跳(CMD=0x01 / 0x81
* 自动应答:[心跳间隔] [上锁时间] [设备固件版本5字节]
*/
private fun handleHeartbeat(frame: ScaleFrame, rawHex: String) {
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}0102%02X%02X${firmwareHex}${FOOTER_HEX}".format(
heartbeatInterval and 0xFF,
lockTimeout and 0xFF
)
sendCmd(resp)
val data = parser.parseHeartbeat(frame.rawBody) val data = parser.parseHeartbeat(frame.rawBody)
if (data != null) {
heartbeatListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[心跳]指令,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex") log("收到[心跳]指令,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
} }
/**
* 处理开锁反馈(CMD=0x02,终端发起,关门后上报)
* 自动应答:[锁号00] [终端ID 15字节] [固件版本 5字节]
*/
private fun handleLockFeedback(frame: ScaleFrame, rawHex: String) {
val terminalIdHex = frame.terminalId.toAsciiHex()
val firmwareHex = frame.firmwareVersion.toAsciiHex()
val resp = "${HEADER_HEX}020200${terminalIdHex}${firmwareHex}${FOOTER_HEX}"
sendCmd(resp)
lockListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[开锁反馈]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理获取状态响应(CMD=0x03,终端响应)
* 无需应答,直接解析并分发
*/
private fun handleStatus(frame: ScaleFrame, rawHex: String) {
val data = parser.parseStatus(frame.rawBody)
if (data != null) {
statusListeners.forEach { it(frame, data) }
}
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[获取状态]响应,终端ID${frame.terminalId},秤数量:${data?.scaleCount ?: 0},原始数据:$rawHex")
}
/**
* 处理卡号上发(CMD=0x05,终端发起)
* 自动应答:成功 0x03
*/
private fun handleCardUpload(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}050203${FOOTER_HEX}"
sendCmd(resp)
cardListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[卡号上发]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理操作记录上传(CMD=0x0B,终端发起)
* 自动应答:成功 0x03
*/
private fun handleOperationRecord(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}0B0203${FOOTER_HEX}"
sendCmd(resp)
operationListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[操作记录上传]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理对时请求(CMD=0x0C,终端发起,仅以太网版本)
* 自动应答:当前 Unix 时间戳(含时区偏移),小端序4字节
*/
private fun handleTimeSync(frame: ScaleFrame, rawHex: String) {
val nowMs = System.currentTimeMillis()
val timeSec = nowMs / 1000 + TimeZone.getDefault().getOffset(nowMs) / 1000
// 取低32位,转为小端序4字节十六进制
val timeHex = "%08X".format(timeSec.toInt())
.chunked(2).reversed().joinToString("")
val resp = "${HEADER_HEX}0C02${timeHex}${FOOTER_HEX}"
sendCmd(resp)
timeSyncListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[对时]请求,终端ID${frame.terminalId},时间戳:${timeSec},应答:$resp")
}
/**
* 处理终端位置上报(CMD=0x0E,终端发起,仅4G版本)
* 自动应答:保留字节 0x00
*/
private fun handlePositionReport(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}0E0200${FOOTER_HEX}"
sendCmd(resp)
positionListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[终端位置上报]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
/**
* 处理温控信息上报(CMD=0x10,终端发起)
* 自动应答:保留字节 0x00
*/
private fun handleTempReport(frame: ScaleFrame, rawHex: String) {
val resp = "${HEADER_HEX}100200${FOOTER_HEX}"
sendCmd(resp)
tempReportListeners.forEach { it(frame) }
rawListeners[frame.cmd]?.forEach { it(frame) }
log("收到[温控信息上报]指令,终端ID${frame.terminalId},原始数据:$rawHex")
}
// ----------------------------------------------------------------
// 私有工具
// ----------------------------------------------------------------
private fun log(message: String) { private fun log(message: String) {
logListeners.forEach { it(message) } logListeners.forEach { it(message) }
} }
@@ -0,0 +1,32 @@
//package com.shuwei.intelligent.shelves.task
//
//import android.content.Context
//import android.util.Log
//import androidx.work.CoroutineWorker
//import androidx.work.WorkerParameters
//import com.shuwei.intelligent.shelves.activity.HomeActivity
//import com.shuwei.intelligent.shelves.serial.SerialPortManager
//
//class SyncDeviceTask(appContext: Context, workerParams: WorkerParameters) :
// CoroutineWorker(appContext, workerParams) {
//
// companion object {
// private const val TAG = "SyncDeviceTask"
// }
//
// override suspend fun doWork(): Result {
// return try {
// // 执行后台任务逻辑
// Log.d(TAG, "doWork: ----------SyncDeviceTask----------")
// SerialPortManager.send(HomeActivity.DEVICE_INFO_CMD)
//
// TaskManager.startDeviceTask()
//
// Result.success()
// } catch (e: Exception) {
// e.printStackTrace()
// Result.retry()
// }
// }
//
//}
@@ -0,0 +1,67 @@
//package com.shuwei.intelligent.shelves.task
//
//import android.content.Context
//import android.util.Log
//import androidx.work.CoroutineWorker
//import androidx.work.WorkerParameters
//import com.shuwei.intelligent.shelves.App
//import com.shuwei.intelligent.shelves.activity.HomeActivity
//import com.shuwei.intelligent.shelves.model.ShelfBody
//import com.shuwei.intelligent.shelves.net.apiService
//import com.shuwei.intelligent.shelves.utils.ext.toJsonString
//import kotlinx.coroutines.Dispatchers
//import kotlinx.coroutines.withContext
//
//class SyncTask(appContext: Context, workerParams: WorkerParameters) :
// CoroutineWorker(appContext, workerParams) {
//
// companion object {
// private const val TAG = "SyncTask"
// }
//
// override suspend fun doWork(): Result {
// return try {
// // 执行后台任务逻辑
// performSync()
//
// TaskManager.startTask()
//
// Result.success()
// } catch (e: Exception) {
// e.printStackTrace()
// Result.retry()
// }
// }
//
// private suspend fun performSync() {
// withContext(Dispatchers.IO) {
// val list = HomeActivity.list
// Log.d(TAG, "performSync: list:${list.toJsonString()}")
// val submitList = list.sortedBy { it.deviceNo }
// submitList.forEach {
// if (it.goodsId.isNullOrBlank()) {
// it.weight = it.weightBak
// }
// }
//// submitList.forEach {
//// it.temperature = HomeActivity.showTemperatureC
//// it.humidity = HomeActivity.showHumidity
//// }
// val body = ShelfBody().also {
// it.deviceId = App.deviceId
// it.canteenId = App.canteenId
// it.temperature = HomeActivity.showTemperatureC
// it.humidity = HomeActivity.showHumidity
// it.goodsList = submitList
// }
// Log.d(TAG, "performSync: body:${body.toJsonString()}")
// try {
// val resp = apiService.saveShelfGoodsList(body = body)
// Log.d(TAG, "performSync: resp:${resp.toJsonString()}")
// } catch (e: Exception) {
// e.printStackTrace()
// }
// }
// }
//
//}
@@ -0,0 +1,59 @@
//package com.shuwei.intelligent.shelves.task
//
//import androidx.work.ExistingWorkPolicy
//import androidx.work.OneTimeWorkRequestBuilder
//import androidx.work.WorkManager
//import com.shuwei.intelligent.shelves.App
//import java.util.concurrent.TimeUnit
//
//object TaskManager {
// const val TASK_NAME = "IntelligentShelvesSyncTask"
// const val DEVICE_TASK_NAME = "IntelligentShelvesDeviceTask"
//
// fun startTask() {
// val nextRequest = OneTimeWorkRequestBuilder<SyncTask>()
// .setInitialDelay(2, TimeUnit.MINUTES)
//// .setInitialDelay(30, TimeUnit.SECONDS)
//// .setInitialDelay(10, TimeUnit.MINUTES)
//// .setInputData(inputData)
// .build()
// WorkManager.getInstance(App.getInstance())
// .enqueueUniqueWork(TASK_NAME, ExistingWorkPolicy.REPLACE, nextRequest)
//
//// val constraints = Constraints.Builder()
//// .setRequiredNetworkType(NetworkType.CONNECTED)
//// .setRequiresBatteryNotLow(true)
//// .build()
//// val syncRequest = PeriodicWorkRequestBuilder<SyncTask>(
//// 3L, TimeUnit.MINUTES, // 最小间隔15分钟
//// 3L, TimeUnit.MINUTES // 弹性时间窗口
//// )
//// .setConstraints(constraints)
//// .build()
//// WorkManager.getInstance(App.get())
//// .enqueueUniquePeriodicWork(
//// TASK_NAME,
//// ExistingPeriodicWorkPolicy.KEEP,
//// syncRequest
//// )
// }
//
// fun cancelTask() {
// WorkManager.getInstance(App.getInstance())
// .cancelUniqueWork(TASK_NAME)
// }
//
// fun startDeviceTask() {
// val nextRequest = OneTimeWorkRequestBuilder<SyncDeviceTask>()
// .setInitialDelay(2, TimeUnit.MINUTES)
// .build()
// WorkManager.getInstance(App.getInstance())
// .enqueueUniqueWork(DEVICE_TASK_NAME, ExistingWorkPolicy.REPLACE, nextRequest)
// }
//
// fun cancelDeviceTask() {
// WorkManager.getInstance(App.getInstance())
// .cancelUniqueWork(DEVICE_TASK_NAME)
// }
//
//}
@@ -3,7 +3,7 @@ package com.shuwei.intelligent.shelves.utils
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.sw.scalefusion.shelf.InitActivity import com.shuwei.intelligent.shelves.activity.InitActivity
import kotlin.jvm.java import kotlin.jvm.java
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@@ -0,0 +1,8 @@
package com.shuwei.intelligent.shelves.utils
/**
* 命令常亮定义
*/
object Constant {
}
@@ -39,19 +39,11 @@ class CrashHandler private constructor(private val context: Context) :
synchronized(CrashHandler::class.java) { synchronized(CrashHandler::class.java) {
if (instance == null) { if (instance == null) {
instance = CrashHandler(context.applicationContext) instance = CrashHandler(context.applicationContext)
// 初始化时清理过期的崩溃日志(默认保留7天)
instance?.cleanupOldCrashReports()
} }
} }
} }
} }
/**
* 获取单例实例
* @return 已通过 init() 初始化时返回实例,否则返回 null
*/
fun getInstance(): CrashHandler? = instance
//fun getCrashReportFiles(context: Context): Array<File> { //fun getCrashReportFiles(context: Context): Array<File> {
// val crashDir = getCrashDir() // val crashDir = getCrashDir()
// return if (crashDir.exists() && crashDir.isDirectory) { // return if (crashDir.exists() && crashDir.isDirectory) {
@@ -230,8 +222,6 @@ class CrashHandler private constructor(private val context: Context) :
* 清理旧的崩溃日志 * 清理旧的崩溃日志
*/ */
fun cleanupOldCrashReports(maxAgeDays: Int = 7) { fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
// 该方法会在 Application 启动及定时任务中调用,异常必须内部消化,避免影响主流程
try {
val crashDir = getCrashDir() val crashDir = getCrashDir()
if (!crashDir.exists() || !crashDir.isDirectory) return if (!crashDir.exists() || !crashDir.isDirectory) return
@@ -243,9 +233,6 @@ class CrashHandler private constructor(private val context: Context) :
file.delete() file.delete()
} }
} }
} catch (e: Exception) {
Log.e(TAG, "清理崩溃日志失败", e)
}
} }
private fun getCrashDir():File { private fun getCrashDir():File {
@@ -0,0 +1,5 @@
package com.shuwei.intelligent.shelves.utils
fun f2C(fahrenheit: Double): Double {
return (fahrenheit - 32) * 5 / 9
}
@@ -1,122 +1,131 @@
package com.shuwei.intelligent.shelves.utils package com.shuwei.intelligent.shelves.utils
import android.content.Context import android.content.Context
import kotlinx.coroutines.* import com.shuwei.intelligent.shelves.utils.CrashHandler.Companion.CRASH_REPORTS_DIR
import kotlinx.coroutines.channels.Channel
import java.io.* import java.io.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* 文件日志记录器(异步版) * 文件日志记录器
* * 功能:保存日志到本地文件,每条数据一行,每天生成新文件
* log() 仅投递原始消息到 Channel(任意线程安全),
* 格式化与文件写入在单一后台协程顺序执行,避免 SimpleDateFormat 多线程竞争。
*/ */
class FileLogger(context: Context) { class FileLogger(private val context: Context) {
companion object { companion object {
private const val LOG_DIR = "logs"
private const val FILE_PREFIX = "log_" private const val FILE_PREFIX = "log_"
private const val FILE_EXTENSION = ".txt" private const val FILE_EXTENSION = ".txt"
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
/** 日志保留天数,超过该天数的日志文件在应用启动时清理 */ private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
private const val RETENTION_DAYS = 7L
/** 单个日志文件大小上限(100MB),超过后当天不再写入,防止异常刷屏撑爆存储 */
private const val MAX_LOG_FILE_SIZE = 100L * 1024 * 1024
/** 全局单线程日志写入 */
private val logChannel = Channel<RawEntry>(capacity = Channel.UNLIMITED)
private val writerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
writerScope.launch {
// 单协程顺序消费:SimpleDateFormat 在此线程安全
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
for (entry in logChannel) {
try {
val fileName = "${FILE_PREFIX}${dateFormat.format(Date())}${FILE_EXTENSION}"
// 单文件超过大小上限时丢弃本条日志,防止异常刷屏撑爆存储(单协程检查,无并发问题)
val file = File(entry.appContext.filesDir, fileName)
if (file.exists() && file.length() > MAX_LOG_FILE_SIZE) continue
val timestamp = timeFormat.format(Date())
val logLine = "[$timestamp] [${entry.level}] ${entry.message}\n"
val output = entry.appContext.openFileOutput(
fileName, Context.MODE_APPEND
)
BufferedWriter(OutputStreamWriter(output)).use { it.write(logLine) }
} catch (_: Exception) {
// 写入失败静默丢弃
} }
}
}
}
private data class RawEntry(
val appContext: Context,
val message: String,
val level: LogLevel
)
/** /**
* 清理过期的日志文件,保留最近 retentionDays 天 * 记录日志
* 按文件最后修改时间判断,最近仍在写入的文件不会被误删 * @param message 日志消息
* 建议在 Application 启动时调用一次 * @param level 日志级别
* @param context 上下文,用于定位 filesDir
* @param retentionDays 日志保留天数
*/ */
fun cleanupExpiredLogs(context: Context, retentionDays: Long = RETENTION_DAYS) { fun log(message: String, level: LogLevel = LogLevel.INFO) {
try { try {
val cutoff = System.currentTimeMillis() - retentionDays * 24 * 60 * 60 * 1000L val logEntry = formatLogEntry(message, level)
context.filesDir.listFiles { _, name -> writeToFile(logEntry)
name.startsWith(FILE_PREFIX) && name.endsWith(FILE_EXTENSION) } catch (e: IOException) {
}?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
} catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
} }
}
/** 持有 ApplicationContext 避免 Activity 泄漏 */
private val appContext: Context = context.applicationContext
/** /**
* 记录日志(异步,线程安全) * 格式化日志条目
*/ */
fun log(message: String, level: LogLevel = LogLevel.INFO) { private fun formatLogEntry(message: String, level: LogLevel): String {
logChannel.trySend(RawEntry(appContext, message, level)) val timestamp = timeFormat.format(Date())
return "[$timestamp] [$level] $message\n"
} }
/**
* 写入文件
*/
private fun writeToFile(logEntry: String) {
// val path = File(context.filesDir, LOG_DIR)
// if (path.exists().not()) {
// path.mkdirs()
// }
val fileName = getCurrentDateFileName()
// val file = File(path, fileName)
// if (file.exists().not()) {
// file.createNewFile()
// }
val output = context.openFileOutput(fileName, Context.MODE_APPEND)
val writer = BufferedWriter(OutputStreamWriter(output))
writer.use {
it.write(logEntry)
}
}
/**
* 获取当前日期对应的文件名
*/
private fun getCurrentDateFileName(): String {
val date = dateFormat.format(Date())
return "${FILE_PREFIX}${date}${FILE_EXTENSION}"
}
/**
* 读取指定日期的日志文件
* @param date 日期字符串,格式:yyyy-MM-dd
*/
fun readLogsByDate(date: String): List<String> { fun readLogsByDate(date: String): List<String> {
return readLogsByName("${FILE_PREFIX}${date}${FILE_EXTENSION}") val fileName = "${FILE_PREFIX}${date}${FILE_EXTENSION}"
return readLogsByName(fileName)
} }
fun readLogsByName(fileName: String): List<String> { fun readLogsByName(fileName: String): List<String> {
try { try {
val input = appContext.openFileInput(fileName) val input = context.openFileInput(fileName)
val reader = BufferedReader(InputStreamReader(input)) val reader = BufferedReader(InputStreamReader(input))
return reader.useLines { lines -> lines.toList().toMutableList() } val list: MutableList<String> = reader.useLines { lines ->
} catch (_: FileNotFoundException) { lines.toList().toMutableList()
}
// val input2 = FileInputStream(getCrashDir(context))
// val reader2 = BufferedReader(InputStreamReader(input2))
// val list2: MutableList<String> = reader2.useLines {lines ->
// lines.toList().toMutableList()
// }
// list.addAll(list2)
return list
} catch (e: FileNotFoundException) {
return emptyList() return emptyList()
} }
} }
fun getCrashDir(context: Context): File { public fun getCrashDir(context: Context):File {
var crashDir = File(context.filesDir, "crash_reports") //val crashDir = File(context.getExternalFilesDir(null), CRASH_REPORTS_DIR)
if (!crashDir.exists()) crashDir.mkdirs() var crashDir = File(context.filesDir, CRASH_REPORTS_DIR)
if (!crashDir.exists()) crashDir = File(context.cacheDir, "crash_reports") if (!crashDir.exists()) {
crashDir.mkdirs()
}
if (!(crashDir.exists())) {
crashDir = File(context.cacheDir, CRASH_REPORTS_DIR)
}
return crashDir return crashDir
} }
/**
* 获取所有日志文件列表
*/
fun getLogFiles(): List<String> { fun getLogFiles(): List<String> {
return appContext.fileList() return context.fileList()
.filter { it.startsWith(FILE_PREFIX) && it.endsWith(FILE_EXTENSION) } .filter { it.startsWith(FILE_PREFIX) && it.endsWith(FILE_EXTENSION) }
//.map { it.replace(FILE_EXTENSION, "") }
} }
enum class LogLevel { DEBUG, INFO, WARN, ERROR } /**
* 日志级别枚举
*/
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
}
} }
@@ -0,0 +1,54 @@
package com.shuwei.intelligent.shelves.utils
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.model.ShelfModel
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
object GridLayoutTool {
/**
* 设备2列,10格
*/
private val device2Columns = ProtocolConstants.device2Columns
/**
* 设备3列,15格,左侧1列竖向编号1、2、3、4、5,右侧2列5行编号为6-7、8-9、10-11、12-13、14-15
*/
private val device3Columns = ProtocolConstants.device3Columns
fun getLayoutManager(context: Context): RecyclerView.LayoutManager? {
return if (device2Columns.contains(App.deviceId)) {
GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false)
} else if (device3Columns.contains(App.deviceId)) {
GridLayoutManager(context, 3, GridLayoutManager.VERTICAL, false)
} else {
null
}
}
/**
* 初始化list显示顺序
*/
fun initListSort(source: List<ShelfModel>): List<ShelfModel> {
val map = source.associateBy { it.deviceNo }
return if (device3Columns.contains(App.deviceId)) {
// 按全局定义的显示顺序取对应数据,找不到则跳过
ProtocolConstants.device3ColumnsOrder.mapNotNull { map[it] }
} else if (device2Columns.contains(App.deviceId)) {
ProtocolConstants.device2ColumnsOrder.mapNotNull { map[it] }
} else {
source
}
}
/**
* 提交接口list顺序
*/
fun submitListSort(source: List<ShelfModel>): List<ShelfModel> {
return source.sortedBy { it.deviceNo }
}
}
@@ -0,0 +1,64 @@
package com.shuwei.intelligent.shelves.utils;
public class HexUtils {
/**
* 将有符号十六进制字符串转换为十进制整数
* @param hex 十六进制字符串(支持负数补码表示)
* @return 对应的十进制整数
*/
public static int signedHexToDec(String hex) {
// 验证输入格式
if (hex == null || hex.isEmpty()) {
throw new IllegalArgumentException("输入不能为空");
}
if (!hex.matches("[0-9A-Fa-f]+")) {
throw new IllegalArgumentException("无效的十六进制格式: " + hex);
}
// 转换为无符号长整型
long unsignedValue = Long.parseLong(hex, 16);
// 判断是否为负数(最高位为1
int bitLength = hex.length() * 4; // 每个十六进制字符对应4位
long signBitMask = 1L << (bitLength - 1);
if ((unsignedValue & signBitMask) != 0) {
// 负数:计算补码
long maxValue = (1L << bitLength) - 1;
return (int) (unsignedValue - (maxValue + 1));
} else {
// 正数:直接转换
return (int) unsignedValue;
}
}
/**
* 测试方法
*/
public static void main(String[] args) {
// 测试用例
String[] testCases = {
"FF", // -1 (8位)
"FFFE", // -2 (16位)
"FFFFFFFE", // -2 (32位)
"7F", // 127
"80", // -128
"7FFF", // 32767
"8000" // -32768
};
System.out.println("有符号十六进制转换测试:");
for (String hex : testCases) {
try {
int result = signedHexToDec(hex);
System.out.println(hex + " -> " + result);
} catch (Exception e) {
System.out.println(hex + " -> 错误: " + e.getMessage());
}
}
}
}
@@ -0,0 +1,101 @@
package com.shuwei.intelligent.shelves.utils
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
/**
* 二维码生成工具类
*/
object QRCodeUtil {
/**
* 生成二维码(默认大小)
* @param content 二维码内容
* @return 生成的二维码Bitmap
*/
@JvmOverloads
fun generateQRCode(content: String, size: Int = 500): Bitmap? {
return generateQRCode(content, size, Color.BLACK, Color.WHITE)
}
/**
* 生成二维码(自定义颜色)
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param colorCode 二维码颜色
* @param backgroundColor 背景颜色
* @return 生成的二维码Bitmap
*/
fun generateQRCode(
content: String,
size: Int,
colorCode: Int,
backgroundColor: Int
): Bitmap? {
if (content.isEmpty()) {
return null
}
return try {
val hints = mutableMapOf<EncodeHintType, Any>().apply {
put(EncodeHintType.CHARACTER_SET, "UTF-8")
put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H) // 纠错级别
put(EncodeHintType.MARGIN, 1) // 边距
}
val bitMatrix = QRCodeWriter().encode(
content,
BarcodeFormat.QR_CODE,
size,
size,
hints
)
val pixels = IntArray(size * size).apply {
for (y in 0 until size) {
for (x in 0 until size) {
this[y * size + x] = if (bitMatrix.get(x, y)) colorCode else backgroundColor
}
}
}
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
setPixels(pixels, 0, size, 0, 0, size, size)
}
} catch (e: WriterException) {
e.printStackTrace()
null
}
}
/**
* 生成带Logo的二维码
* @param content 二维码内容
* @param size 二维码边长(像素)
* @param logo Logo Bitmap
* @return 带Logo的二维码Bitmap
*/
fun generateQRCodeWithLogo(content: String, size: Int, logo: Bitmap?): Bitmap? {
val qrCode = generateQRCode(content, size) ?: return null
logo ?: return qrCode
val logoSize = size / 5 // Logo大小约为二维码的1/5
val scaledLogo = Bitmap.createScaledBitmap(logo, logoSize, logoSize, false)
val offset = (size - logoSize) / 2
return Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
val canvas = Canvas(this)
canvas.drawBitmap(qrCode, 0f, 0f, null)
canvas.drawBitmap(scaledLogo, offset.toFloat(), offset.toFloat(), null)
}
}
}
@@ -0,0 +1,57 @@
//package com.shuwei.intelligent.shelves.utils
//
//
//import android.serialport.*
////import io.github.jeadyx.jserialport.SerialPort
//import java.io.File
//import java.io.IOException
//import java.io.InputStream
//import java.io.OutputStream
//
//class SerialPortHelper(
// private val devicePath: String = "/dev/ttyS1",
// private val baudRate: Int = 115200
//) {
// private var serialPort: SerialPort? = null
// private var inputStream: InputStream? = null
// private var outputStream: OutputStream? = null
//
// // 打开串口
// fun open(): Boolean {
// return try {
// serialPort = SerialPort(File(devicePath), baudRate, 0)
// inputStream = serialPort?.inputStream
// outputStream = serialPort?.outputStream
// true
// } catch (e: IOException) {
// false
// }
// }
//
// // 发送数据
// fun send(data: ByteArray): Boolean {
// return try {
// outputStream?.write(data)
// outputStream?.flush()
// true
// } catch (e: IOException) {
// false
// }
// }
//
// // 接收数据(需在子线程调用)
// fun receive(buffer: ByteArray): Int {
// return inputStream?.read(buffer) ?: -1
// }
//
// // 关闭串口
// fun close() {
// try {
// inputStream?.close()
// outputStream?.close()
// serialPort?.close()
// } catch (e: IOException) {
// e.printStackTrace()
// }
// }
//}
@@ -0,0 +1,36 @@
package com.shuwei.intelligent.shelves.utils
import android.util.SparseArray
import androidx.core.util.containsKey
import com.shuwei.intelligent.shelves.model.ShelfModel
object StaleFoodRule {
private val shelfMap = SparseArray<ShelfModel>()
// fun load(list: MutableList<ShelfModel>) {
// if (list.isEmpty()) {
// return
// }
// list.forEach {
// shelfMap.put(it.deviceNo, it)
// }
// }
fun judge(item: ShelfModel, block:(Boolean)-> Unit = {}) {
val deviceNo = item.deviceNo
if (!shelfMap.containsKey(deviceNo)) {
//首次设置数据
shelfMap.put(deviceNo, item)
return
}
val lastItem = shelfMap.get(deviceNo)
if (lastItem.goodsId != item.goodsId) {
//柜子商品更换
shelfMap.put(deviceNo, item)
return
}
//同一产品
}
}
@@ -0,0 +1,282 @@
package com.shuwei.intelligent.shelves.utils;
//import cn.hutool.core.util.HexUtil;
//import cn.hutool.core.util.StrUtil;
//import com.chengyi.framework.utils.HexUtils;
//import lombok.Data;
//import lombok.extern.slf4j.Slf4j;
/**
* @Author: NThink
* @Date: 2021/12/10
* @Description: 鸿科HK70温控器信息消息解包
*/
public class TemperatureInfoHK70MsgBodyUnpack {
// 柜温探头温度
private Double gwttTemp;
// 蒸发探头温度
private Double zfttTemp;
// 冷凝探头温度
private Double lnttTemp;
// 停机温度
private Double tjTemp;
// 开机温度回差
private Double kjTempRange;
// 压缩机启动延时
private Integer ysjStartDelay;
// 首次通电压缩机启动延时
private Integer ysjStartDelayFirst;
// 柜温探头补偿
private Double gwttbcTemp;
// 停机温度可设置的最小值
private Double tjTempMin;
// 停机温度可设置的最大值
private Double tjTempMax;
// 压缩机最大待机时间
private Integer ysjMaxWait;
// 压缩机最短运行时间
private Integer ysjMinWork;
// 蒸发器探头状态
private Integer zfttStatus;
// 自动除霜周期
private Integer zdcszq;
// 除霜过程显示
private Integer csWorkShow;
// 除霜最长时间
private Integer cszcsj;
// 除霜中止温度
private Double cszzTemp;
// 除霜类型
private Integer csType;
// 风机运行模式
private Integer fanWorkType;
// 风机首次启动延时
private Integer fanFirstStartDelay;
// 滴水后风机启动延时
private Integer fanStartDelayAfterDrip;
// 开关机状态
private String powerStatus;
// 制冷状态
private String coolStatus;
// 除霜状态
private String csStatus;
// 滴水状态
private String dsStatus;
// 柜温探头报警
private String gwttAlarm;
// 除霜探头报警
private String csttAlarm;
// 冷凝探头报警
private String lnttAlarm;
// 终端ID
private String imei;
// 固件版本
private String version;
// 命令次数
private Integer cmdCount;
public TemperatureInfoHK70MsgBodyUnpack(String body) {
// ===================== 寄存器内容:110字节 =====================
String jcqContent = body.substring(0, 220);
// 柜温探头温度 [0, 4]
double gwttTemp = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(0, 4)) * 0.1));
// setGwttTemp(gwttTemp);
// // 蒸发探头温度 [4, 8]
// double zfttTemp = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(4, 8)) * 0.1));
// setZfttTemp(zfttTemp);
// // 冷凝探头温度 [8, 12]
// double lnttTemp = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(8, 12)) * 0.1));
// setLnttTemp(lnttTemp);
// // 停机温度 [12, 16]
// double tjTemp = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(12, 16)) * 1.0));
// setTjTemp(tjTemp);
// // 开机温度回差 [16, 20]
// double kjwdhc = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(16, 20)) * 0.1));
// setKjTempRange(kjwdhc);
// // 压缩机启动延时 [20, 24]
// int ysjqdys = Integer.parseInt(jcqContent.substring(20, 24), 16);
// setYsjStartDelay(ysjqdys);
// // 首次通电压缩机启动延时 [24, 28]
// int ysjqdysFirst = Integer.parseInt(jcqContent.substring(24, 28), 16);
// setYsjStartDelayFirst(ysjqdysFirst);
// // 柜温探头补偿 [28, 32]
// double gwttbc = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(28, 32)) * 0.1));
// setGwttbcTemp(gwttbc);
// // 停机温度可设置的最小值 [32, 36]
// double tjTempMin = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(32, 36)) * 1.0));
// setTjTempMin(tjTempMin);
// // 停机温度可设置的最大值 [36, 40]
// double tjTempMax = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(36, 40)) * 1.0));
// setTjTempMin(tjTempMax);
// // 压缩机最大待机时间 [40, 44]
// int ysjzddjsj = Integer.parseInt(jcqContent.substring(40, 44), 16);
// setYsjMaxWait(ysjzddjsj);
// // 压缩机最短运行时间 [44, 48]
// int ysjzdyxsj = Integer.parseInt(jcqContent.substring(44, 48), 16);
// setYsjMinWork(ysjzdyxsj);
// // 蒸发器探头选择 [48, 52]
// int zfqtt = Integer.parseInt(jcqContent.substring(48, 52), 16);
// setZfttStatus(zfqtt);
// // TODO 蒸发器探头补偿 [52, 56]
// // TODO 自动除霜周期计算方式 [56, 60]
// // 自动除霜周期 [60, 64]
// int zdcszq = Integer.parseInt(jcqContent.substring(60, 64), 16);
// setZdcszq(zdcszq);
// // 除霜过程显示选择 [64, 68]
// int csgcxx = Integer.parseInt(jcqContent.substring(64, 68), 16);
// setCsWorkShow(csgcxx);
// // 除霜最长时间 [68, 72]
// int cszcsj = Integer.parseInt(jcqContent.substring(68, 72), 16);
// setCszcsj(cszcsj);
// // 除霜终止温度 [72, 76]
// double cszzwd = Double.parseDouble(String.format("%.1f", HexUtils.signedHexToDec(jcqContent.substring(72, 76)) * 1.0));
// setCszzTemp(cszzwd);
// // TODO 除霜滴水时间 [76, 80]
// // TODO 除霜后柜温显示延时 [80, 84]
// // TODO 除霜启动延迟 [84, 88]
// // 除霜类型选择 [88, 92]
// int cslx = Integer.parseInt(jcqContent.substring(88, 92), 16);
// setCsType(cslx);
// // TODO 辅热提前启动时间 [92, 96]
// // TODO 强制除霜温度 [96, 100]
// // 风机运行模式 [100, 104]
// int fjyxms = Integer.parseInt(jcqContent.substring(100, 104), 16);
// setFanWorkType(fjyxms);
// // 风机首次启动延时 [104, 108]
// int fjscqdys = Integer.parseInt(jcqContent.substring(104, 108), 16);
// setFanFirstStartDelay(fjscqdys);
// // 滴水后风机启动延时 [108, 112]
// int dshfjqdys = Integer.parseInt(jcqContent.substring(108, 112), 16);
// setFanStartDelayAfterDrip(dshfjqdys);
// // TODO 风机工作最低温度 [112, 116]
// // TODO 风机工作最高温度 [116, 120]
// // TODO 风机循环开启时间 [120, 124]
// // TODO 风机循环关闭时间 [124, 128]
// // TODO 比例制冷压缩机停止时间 [128, 132]
// // TODO 比例制冷压缩机运行时间 [132, 136]
// // TODO 蜂鸣音报警 [136, 140]
// // TODO 柜温低温报警(绝对温度) [140, 144]
// // TODO 柜温高温报警(绝对温度) [144, 148]
// // TODO 柜温报警延时 [148, 152]
// // TODO 上电首次柜温报警延时 [152, 156]
// // TODO 辅助继电器功能选择 [156, 160]
// // TODO 门开关控制输出选择 [160, 164]
// // TODO 开门时蜂鸣器响应延时 [164, 168]
// // TODO 第三探头功能选择 [168, 172]
// // TODO 冷凝器高温报警启动值 [172, 176]
// // TODO 冷凝器高温报警下回差 [176, 180]
// // TODO 冷凝器高温保护启动值 [180, 184]
// // TODO 实时时钟功能开关 [不上报]
// // TODO 非营业模式开启时间 [不上报]
// // TODO 非营业模式关闭时间 [不上报]
// // TODO 非营业模式温度增量 [不上报]
// // TODO 非营业模式灯光开启时间 [不上报]
// // TODO 节假日设置 [不上报]
// // TODO 节假日非营业模式开启时间 [不上报]
// // TODO 除雾自动控制模式 [184, 188]
// // TODO 除雾自动开启时间 [188, 192]
// // TODO 除雾自动关闭时间 [192, 196]
// // TODO 开关机自动控制模式 [不上报]
// // TODO 自动开机时间 [不上报]
// // TODO 自动关机时间 [不上报]
// // TODO 单位选择 [196, 200]
// // TODO 柜温每升高 1℃显示延时 [204, 208]
// // TODO 显示分辨率选择 [208, 212]
// // TODO 机器地址 [212, 216]
// // TODO 管理员密码设定 [216, 220]
//
// // ===================== 状态位解析:4字节 =====================
// // 1.状态信息
// String statusHexStr = body.substring(220, 224);
// statusHexStr = HexUtils.reverse(statusHexStr);
// String statusBitStr = HexUtils.hexString2binaryString(statusHexStr);
// statusBitStr = StrUtil.reverse(statusBitStr);
// // 机组运行状态 0:停止 1:运行 [0, 1]
// String powerStatus = "0".equals(statusBitStr.substring(0, 1)) ? "close" : "open";
// setPowerStatus(powerStatus);
// // TODO 自检状态 0:停止 1:运行 [1, 2]
// // 压缩机运行状态 0:停止 1:运行 [2, 3]
// String coolStatus = "0".equals(statusBitStr.substring(3, 4)) ? "close" : "open";
// setCoolStatus(coolStatus);
// // 除霜状态 0:停止 1:运行 [3, 4]
// String csStatus = "0".equals(statusBitStr.substring(10, 11)) ? "close" : "open";
// setCsStatus(csStatus);
// // 滴水状态 0:关闭 1:运行中 [4, 5]
// String dsStatus = "0".equals(statusBitStr.substring(12, 13)) ? "close" : "open";
// setDsStatus(dsStatus);
// // TODO 风机状态 0:停止 1:运行 [5, 6]
// // TODO 除露状态 0:停止 1:运行 [6, 7]
// // TODO 灯光状态 0:停止 1:运行 [7, 8]
// // TODO 除霜加热或电磁阀状态 0:停止 1:运行 [8, 9]
// // TODO 辅助加热状态 0:停止 1:运行 [9, 10]
// // TODO 报警状态 0:停止 1:运行 [10, 11]
// // TODO 开门状态 0:停止 1:运行 [11, 12]
// // TODO 非营业(节能)状态 0:停止 1:运行 [12, 13]
// // TODO 节假日状态 0:停止 1:运行 [13, 14]
// // TODO 保留 0:停止 1:运行 [14, 15]
// // TODO 保留 0:停止 1:运行 [15, 16]
//
// // 2.报警信息
// String alarmHexStr = body.substring(224, 228);
// String alarmBitStr = HexUtils.hexString2binaryString(alarmHexStr);
// alarmBitStr = StrUtil.reverse(alarmBitStr);
// // 柜温探头故障 0:无报警 1:报警
// String gwttAlarm = "0".equals(alarmBitStr.substring(0, 1)) ? "normal" : "alarm";
// setGwttAlarm(gwttAlarm);
// // 除霜探头故障 0:无报警 1:报警
// String csAlarm = "0".equals(alarmBitStr.substring(1, 2)) ? "normal" : "alarm";
// setCsttAlarm(csAlarm);
// // 冷凝探头故障 0:无报警 1:报警
// String lnttAlarm = "0".equals(alarmBitStr.substring(2, 3)) ? "normal" : "alarm";
// setLnttAlarm(lnttAlarm);
// // TODO 柜温高温报警 0:无报警 1:报警
// // TODO 柜温低温报警 0:无报警 1:报警
// // TODO 冷凝高温报警 0:无报警 1:报警
// // TODO 冷凝保护报警 0:无报警 1:报警
// // TODO 门打开报警 0:无报警 1:报警
// // TODO 时钟故障报警 0:无报警 1:报警
//
// // 终端ID 15字节
// setImei(HexUtil.decodeHexStr(body.substring(228, 258)));
// // 固件版本 5字节
// setVersion(HexUtil.decodeHexStr(body.substring(258, 268)));
// // 命令次数
// setCmdCount(Integer.parseInt(body.substring(268, 270), 16));
}
public static void main(String[] args) {
// String reverse = HexUtils.reverse("8000");
// System.out.println(reverse);
}
}
@@ -0,0 +1,70 @@
package com.shuwei.intelligent.shelves.utils.ext
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import androidx.core.graphics.toColorInt
interface DrawableDsl {
fun shape(init: ShapeDsl.() -> Unit)
}
interface ShapeDsl {
fun solidColor(color: Int)
fun stroke(width: Int, color: Int)
fun cornerRadius(radius: Float)
}
class DrawableBuilder : DrawableDsl {
val drawables = mutableListOf<ShapeDrawable>()
override fun shape(init: ShapeDsl.() -> Unit) {
val shape = ShapeDrawable()
init(shape)
drawables.add(shape)
}
}
class ShapeDrawable : ShapeDsl {
var color: Int? = null
var strokeWidth: Int? = null
var strokeColor: Int? = null
var cornerRadius: Float? = null
override fun solidColor(color: Int) {
this.color = color
}
override fun stroke(width: Int, color: Int) {
this.strokeWidth = width
this.strokeColor = color
}
override fun cornerRadius(radius: Float) {
this.cornerRadius = radius
}
}
fun createCustomDrawable(
solidColor: String,
strokeWidth: Int,
strokeColor: String,
radius: Int
): Drawable {
val builder = DrawableBuilder()
builder.shape {
solidColor(solidColor.toColorInt())
stroke(strokeWidth, strokeColor.toColorInt())
cornerRadius(radius.toFloat())
}
return convertToDrawable(builder.drawables[0])
}
fun convertToDrawable(shapeDrawable: ShapeDrawable): Drawable {
val drawable = GradientDrawable()
shapeDrawable.color?.let { drawable.setColor(it) }
shapeDrawable.cornerRadius?.let { drawable.cornerRadius = it }
shapeDrawable.strokeWidth?.let { drawable.setStroke(it, shapeDrawable.strokeColor ?: Color.BLACK) }
return drawable
}
@@ -0,0 +1,24 @@
package com.shuwei.intelligent.shelves.utils.ext
import android.text.SpannableStringBuilder
import android.text.Spanned
inline fun buildSpannableString(builderAction: SpannableStringBuilder.() -> Unit): SpannableStringBuilder {
return SpannableStringBuilder().apply(builderAction)
}
fun SpannableStringBuilder.appendText(text: String, vararg spans: Any): SpannableStringBuilder {
val start = length
append(text)
spans.forEach { span ->
setSpan(span, start, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
return this
}
fun SpannableStringBuilder.withSpan(span: Any, block: SpannableStringBuilder.() -> Unit) {
val start = length
block()
setSpan(span, start, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
@@ -1,477 +0,0 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DefaultItemAnimator
import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayoutManager
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityHomeV3Binding
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.ClearShelfEvent
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ProtocolConstants
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD
import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.IntervalExecutor
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.ShelfV3Adapter
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SlotWeight
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class HomeV3Activity : BaseActivity() {
companion object {
/** 重量同步后台间隔(秒) */
private const val SYNC_INTERVAL_SECONDS = 30L
/** 首次同步延迟(秒) */
private const val SYNC_INITIAL_DELAY_SECONDS = 10L
}
override val enableRightStatusMenu: Boolean = true
private val list: MutableList<SlotModel> = mutableListOf()
/** 秤号(slotNo 数字部分)→ list 索引 的快速查找表,心跳回调中 O(1) 定位 */
private val scaleIndexToPosition = mutableMapOf<Int, Int>()
private var shelfIndex = 0
private fun isNeedOpenLock() = App.deviceId == "4787e213-90ab-3e32-88e0-ac271a937751"
private var deviceName = ""
private var cabinetType: Int = 0
private var cabinetId: String = ""
private var canteenId: Long = 0
private lateinit var binding: ActivityHomeV3Binding
private val viewModel: NetViewModelV3 by viewModels()
/** 记录每台秤的上次重量(克),用于跳过无变化的 UI 刷新和日志 */
private val lastWeightGrams = mutableMapOf<Int, Int>()
private fun initScaleManager() {
lifecycleScope.launch {
val ok = ScaleManager
.init(activateCode = ProtocolConstants.ACTIVE_MAP[App.deviceId] ?: "")
.onHeartbeat { _, data ->
data.weights.forEach { weight ->
val pos = scaleIndexToPosition[weight.index] ?: return@forEach
// 重量不变则跳过 UI 刷新、日志和 EventBus,避免主线程无效计算
if (lastWeightGrams[weight.index] == weight.grams) return@forEach
lastWeightGrams[weight.index] = weight.grams
val model = list[pos]
model.weight = weight.grams / 1000.0
shelfAdapter.notifyItemChanged(pos)
log("getWeightInfo: slotNo=${model.slotNo}, realWeight=${weight.grams}, temp=${data.temperature}")
EventBus.getDefault().post(SendWeightEvent(weight.index, weight.grams))
}
}
.onTempReport { frame ->
log("onTempReport: $frame")
updateLeftStatus(deviceName)
}
.onLog { message -> log(message) }
.start { data ->
lastDataReceivedTime = System.currentTimeMillis()
noDataWarningDialog?.takeIf { it.isShowing }?.dismiss()
log("receiveSerialPortData: $data")
}
log("onCreate: openState=$ok")
}
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeV3Binding.inflate(layoutInflater)
EventBus.getDefault().register(this)
setBackground()
setContentView(binding.root)
onBackPressedDispatcher.addCallback(this) {}
updateLeftStatus("")
initScaleManager()
binding.root.postDelayed({
sendCmd(START_TEMP_CTRL_CMD)
sendCmd(C_TEMP_CMD)
}, 5000)
saveGoodsTask()
overdueTask()
initRefreshLayout()
refreshSlots()
}
/** 配置格口列表下拉刷新 */
private fun initRefreshLayout() {
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { refreshSlots(fromPull = true) }
}
}
/**
* 拉取格口列表并刷新 UI。
* @param fromPull true 表示由下拉刷新触发:不弹 Loading,失败时保留现有格口数据仅提示
*/
private fun refreshSlots(fromPull: Boolean = false) {
viewModel.slots(
onLoading = { if (!fromPull) showProgress() },
onSuccess = { record ->
if (fromPull) finishRefresh()
updateUI(record)
},
onError = { msg ->
if (fromPull) {
finishRefresh()
// 列表已有数据时刷新失败仅提示,保留现有格口展示;无数据则走空视图重试
if (list.isEmpty()) showError(msg) else toast(msg)
} else {
showError(msg)
}
}
)
}
/** 结束下拉刷新动画 */
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) it.finishRefresh(500)
}
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateUI(record: SlotRecord?) {
binding.include.root.gone()
window?.decorView?.postDelayed({ Loading.dismiss() }, 500)
if (record == null) {
loadEmptyView()
return
}
deviceName = record.deviceName
cabinetType = record.cabinetType
cabinetId = record.cabinetId
canteenId = record.canteenId
updateLeftStatus(deviceName)
val slots = record.slots ?: mutableListOf()
if (slots.isEmpty()) {
loadEmptyView()
return
}
// val columns = if (ProtocolConstants.device3Columns.contains(App.deviceId)) 3 else 2
// val rows = if (slots.size % columns == 0) slots.size / columns else 5
// val columns = record.verticalCount
// val rows = record.horizontalRows
val rows = record.verticalCount
val columns = record.horizontalRows
log("updateUI: slots=${slots.size}, rows=$rows, columns=$columns")
list.clear()
list.addAll(reorderSlotsByDeviceLayout(slots, columns, rows))
buildScaleIndexMap()
val displayMetrics = resources.displayMetrics
// 减去 RecyclerView + item 自身的 margin
shelfAdapter.itemWidth = (displayMetrics.widthPixels - 24.dp) / columns - 24.dp
shelfAdapter.itemHeight = (displayMetrics.heightPixels - 68.dp) / rows - 24.dp
if (binding.rvShelf.adapter == null) {
initRecyclerView()
} else {
shelfAdapter.notifyDataSetChanged()
}
}
/**
* 按设备物理布局重排格口列表。
*
* API 返回的 slots 按 slotNo 升序排列。FlexboxLayoutManager(FlexDirection.COLUMN)
* 按列填充(先填满第一列 5 行,再换第二列),3 列设备需要重排才能正确显示:
*
* 期望显示: 列填充需要的列表序:
* [1] [6] [7] [1, 2, 3, 4, 5, ← 第1列
* [2] [8] [9] 6, 8, 10, 12, 14, ← 第2列
* [3][10] [11] 7, 9, 11, 13, 15] ← 第3列
* [4][12] [13]
* [5][14] [15]
*
* 2 列设备 API 原序 [1..10] 列填充后恰好正确,无需重排。
*
* @param slots API 返回的格口列表(按 slotNo 升序)
* @param columns 列数(API horizontalRows
* @param rows 行数(API verticalCount
*/
private fun reorderSlotsByDeviceLayout(
slots: List<SlotModel>,
columns: Int,
rows: Int
): List<SlotModel> {
// 2 列及以下无需重排,API 原序列填充后恰好正确
if (columns <= 2) return ArrayList(slots)
// 按 slotNo 数字部分建立查找表
val slotMap = HashMap<Int, SlotModel>(slots.size)
for (slot in slots) {
val num = slot.slotNo.filter { it.isDigit() }.toIntOrNull() ?: continue
slotMap[num] = slot
}
val result = ArrayList<SlotModel>(slots.size)
for (displayCol in 0 until columns) {
for (displayRow in 0 until rows) {
// 物理布局 → FlexDirection.COLUMN 列填充所需的列表位置
val slotNo = when (displayCol) {
0 -> displayRow + 1 // 左列: 1,2,3,4,5
1 -> rows + 1 + displayRow * 2 // 中列: 6,8,10,12,14
else -> rows + 2 + displayRow * 2 // 右列: 7,9,11,13,15
}
slotMap[slotNo]?.let { result.add(it) }
}
}
return result
}
/** 预建秤号→列表索引映射表,供心跳回调 O(1) 查找 */
private fun buildScaleIndexMap() {
scaleIndexToPosition.clear()
list.forEachIndexed { pos, model ->
val scaleNo = model.slotNo.filter { it.isDigit() }.toIntOrNull() ?: return@forEachIndexed
scaleIndexToPosition[scaleNo] = pos
}
}
private fun showError(message: String) {
Loading.dismiss()
toast(message)
loadEmptyView()
}
private var tipDialog: CommonDialog? = null
private var lastDataReceivedTime = System.currentTimeMillis()
private var noDataWarningDialog: CommonDialog? = null
private val noDataWatchdogTask = object : Runnable {
override fun run() {
if (isFinishing || isDestroyed) return
val elapsed = System.currentTimeMillis() - lastDataReceivedTime
if (elapsed >= 5 * 60 * 1000L) {
if (noDataWarningDialog == null) {
noDataWarningDialog = CommonDialog(this@HomeV3Activity).apply {
dialogTitle = "设备提示"
dialogContent =
"长时间未收到数据,若无法开门或秤重量不更新情况,请考虑断电重启设备"
}
}
if (noDataWarningDialog?.isShowing == false) {
noDataWarningDialog?.show()
}
}
handler.postDelayed(this, 30 * 1000L)
}
}
override fun onUserInactive() {
switchLight(false)
}
override fun onDestroy() {
handler.removeCallbacksAndMessages(null)
tipDialog?.dismiss()
tipDialog = null
noDataWarningDialog?.dismiss()
noDataWarningDialog = null
runBlocking { ScaleManager.release() }
saveTaskJob?.cancel()
overdueTaskJob?.cancel()
EventBus.getDefault().unregister(this)
super.onDestroy()
}
private val shelfAdapter by lazy {
ShelfV3Adapter(list).apply {
setOnItemClickListener { _, _, position ->
onShelfItemClicked(position)
}
}
}
private fun onShelfItemClicked(position: Int) {
shelfIndex = position
lifecycleScope.launch {
if (isNeedOpenLock()) {
val slotNum = list[position].slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val openCmd = if (slotNum in 1..5)
ScaleManager.buildOpenLock1Cmd()
else
ScaleManager.buildOpenLock2Cmd()
ScaleManager.sendCmd(openCmd)
}
val model = list[position]
launch(Intent(this@HomeV3Activity, ShelfV3Activity::class.java).also {
it.putExtra(ShelfV3Activity.EXTRA_CABINET_TYPE, model.cabinetType)
it.putExtra(ShelfV3Activity.EXTRA_CABINET_ID, model.cabinetId)
it.putExtra(ShelfV3Activity.EXTRA_SLOT_NO, model.slotNo)
it.putExtra(ShelfV3Activity.EXTRA_CANTEEN_ID, canteenId)
it.putExtra(ShelfV3Activity.EXTRA_MATER_NAME, model.materName)
it.putExtra(ShelfV3Activity.EXTRA_VEG_TYPE_NAME, model.vegTypeName)
}) { result ->
result?.let { onShelfActivityResult(position, it) }
}
}
}
@Suppress("DEPRECATION")
private fun onShelfActivityResult(position: Int, intent: Intent) {
val model = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL, SlotModel::class.java)
} else {
intent.getSerializableExtra(ShelfV3Activity.SHELF_MODEL) as? SlotModel
} ?: return
val weight = list[position].weight
model.weight = weight
list[position] = model
shelfAdapter.notifyItemChanged(position)
}
private fun initRecyclerView() {
val layoutManager = FlexboxLayoutManager(this).apply {
flexDirection = FlexDirection.COLUMN
flexWrap = FlexWrap.WRAP
}
binding.rvShelf.let {
it.layoutManager = layoutManager
it.adapter = shelfAdapter
it.itemAnimator.let { animator ->
if (animator is DefaultItemAnimator) {
animator.supportsChangeAnimations = false
}
}
}
}
private val handler = Handler(Looper.getMainLooper())
override fun onResume() {
super.onResume()
hideStatusBar()
handler.postDelayed(noDataWatchdogTask, 30 * 1000L)
}
override fun onPause() {
super.onPause()
handler.removeCallbacks(noDataWatchdogTask)
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
list.clear()
scaleIndexToPosition.clear()
shelfAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener { refreshSlots() }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white)
it.tvEmptyContent.setTextColor(Color.WHITE)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun clearEmptyShelf(event: ClearShelfEvent) {
val index = scaleIndexToPosition[event.shelfNo] ?: return
list[index] = list[index].copy(materName = "")
shelfAdapter.notifyItemChanged(index)
}
private val taskExecutor by lazy { IntervalExecutor() }
private var saveTaskJob: Job? = null
private fun saveGoodsTask() {
saveTaskJob =
taskExecutor.startIntervalTaskWithInitialDelay(
SYNC_INITIAL_DELAY_SECONDS * 1000L,
SYNC_INTERVAL_SECONDS * 1000L
) {
syncShelfGoodsToServer()
}
}
private var overdueTaskJob: Job? = null
private fun overdueTask() {
// overdueTaskJob = taskExecutor.startIntervalTaskWithInitialDelay(5 * 1000L, 5 * 60 * 1000L) {
// viewModel.slots()
// }
}
// private fun applyOverdueState(items: List<RecordItem>) {
// if (items.size != list.size) return
// items.forEachIndexed { index, newItem ->
// val oldItem = list[index]
// if (oldItem.slotStatus != newItem.slotStatus) {
// list[index] = newItem
// shelfAdapter.notifyItemChanged(index)
// log("更新格口 ${newItem.slotNo} 物料:${newItem.materName} 的状态:${newItem.slotStatus}")
// }
// }
// }
/** 全量同步格口重量 */
private fun syncShelfGoodsToServer() {
if (list.isEmpty()) return
val submitList = list.sortedBy { it.slotNo.toIntOrNull() ?: 0 }
viewModel.syncWeight(
SyncWeightRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotList = submitList.map {
SlotWeight(slotNo = it.slotNo, weight = it.weight ?: 0.0)
}
)
)
}
}
@@ -1,178 +0,0 @@
package com.sw.scalefusion.shelf
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.animation.RotateAnimation
import androidx.activity.addCallback
import androidx.core.graphics.toColorInt
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.GlobalData
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityInitBinding
import com.shuwei.intelligent.shelves.utils.AppUtil
import com.shuwei.intelligent.shelves.utils.NetworkUtils
import com.shuwei.intelligent.shelves.utils.SpTool
import com.shuwei.intelligent.shelves.utils.ext.invisible
import com.shuwei.intelligent.shelves.utils.ext.startActivity
class InitActivity : BaseActivity() {
companion object {
const val TAG = "InitActivity"
}
private lateinit var binding: ActivityInitBinding
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L
private var lastNetworkCheckTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
App.deviceId = AppUtil.getUDID(this)
// TODO: 测试设备号
// App.deviceId = "d369f9b0-1c5b-3066-ba72-988e1449cecc"
// App.deviceId = "25b777a9-6298-30d3-a5f8-3b3eef2a50f7"
// App.deviceId = "505ce1b1-facc-3eb2-855a-3dadda5ba358"
Log.d(TAG, "onCreate: deviceId = ${App.deviceId}")
SpTool.put(SpTool.DEVICE_ID, App.deviceId)
// val appBaseUrl = SpTool.baseUrl
// if (!appBaseUrl.isBlank()) {
// GlobalData.appBaseUrl = appBaseUrl
// } else {
// GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
// }
GlobalData.appBaseUrl = GlobalData.TEST_BASE_URL
App.canteenId = "0"
binding.ivQrCode.invisible()
binding.btnInit.invisible()
initViews()
// 禁用返回键
onBackPressedDispatcher.addCallback(this) {}
}
/**
* 初始化控件事件
*/
private fun initViews() {
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200
repeatCount = RotateAnimation.INFINITE
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llNetwork.setBackgroundColor(Color.TRANSPARENT)
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
showNetworkButton()
}
}
}, 100)
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeV3Activity
*/
private fun navigateToHome() {
startActivity<HomeV3Activity>()
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llNetwork.setBackgroundColor("#5C77F7".toColorInt())
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
}
@@ -1,376 +0,0 @@
package com.sw.scalefusion.shelf
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.widget.LinearLayout
import androidx.activity.viewModels
import androidx.core.view.updateLayoutParams
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.chad.library.adapter4.util.setOnDebouncedItemClick
import com.scwang.smart.refresh.layout.constant.RefreshState
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.base.BaseActivity
import com.shuwei.intelligent.shelves.databinding.ActivityShelfV3Binding
import com.shuwei.intelligent.shelves.dialog.ChipSelectDialog
import com.shuwei.intelligent.shelves.dialog.CommonDialog
import com.shuwei.intelligent.shelves.model.SendWeightEvent
import com.shuwei.intelligent.shelves.net.Loading
import com.shuwei.intelligent.shelves.serial.ScaleManager
import com.shuwei.intelligent.shelves.utils.KeyboardUtil
import com.shuwei.intelligent.shelves.utils.ext.addOnActionSearchListener
import com.shuwei.intelligent.shelves.utils.ext.clickWithDebounce
import com.shuwei.intelligent.shelves.utils.ext.dp
import com.shuwei.intelligent.shelves.utils.ext.gone
import com.shuwei.intelligent.shelves.utils.ext.toast
import com.shuwei.intelligent.shelves.utils.ext.visible
import com.sw.scalefusion.shelf.adapter.PendingInboundAdapter
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.net.NetViewModelV3
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@SuppressLint("NotifyDataSetChanged")
class ShelfV3Activity : BaseActivity() {
companion object {
const val SHELF_MODEL = "shelfModelV3"
const val EXTRA_CABINET_TYPE = "cabinetType"
const val EXTRA_CABINET_ID = "cabinetId"
const val EXTRA_CANTEEN_ID = "canteenId"
const val EXTRA_SLOT_NO = "slotNo"
const val EXTRA_MATER_NAME = "materName"
const val EXTRA_VEG_TYPE_NAME = "vegTypeName"
const val YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss"
}
/** 缓存的日期格式化器,避免 onClockTick 每秒 new 对象 */
private val clockFormatter by lazy {
SimpleDateFormat(YYYY_MM_DD__EEEE_HH_MM_SS, Locale.CHINA)
}
private lateinit var binding: ActivityShelfV3Binding
private var shelfModel: SlotModel? = null
private var realWeight: Int = 0
private var startTime = 0L
private val viewModel: NetViewModelV3 by viewModels()
private val optionList: MutableList<FoodItem> = mutableListOf()
private val optionAdapter by lazy {
PendingInboundAdapter(optionList).apply {
setOnDebouncedItemClick { _, _, position ->
val vegTypeList = getItem(position)?.vegTypeList ?: emptyList()
if (vegTypeList.isEmpty()) {
selectCategory(position)
return@setOnDebouncedItemClick
}
val categoryList = vegTypeList.map { it.vegTypeName }
if (categoryList.isEmpty()) {
CommonDialog(this@ShelfV3Activity).apply {
dialogTitle = "净菜选择"
dialogContent = "暂无净菜类别,请联系管理员添加,或稍后重试"
show()
}
return@setOnDebouncedItemClick
}
showChipSelectDialog(categoryList) { index ->
getItem(position)?.typeSelectedIndex = index
selectCategory(position)
}
}
}
}
private fun selectCategory(position: Int) {
optionList.forEachIndexed { index, option -> option.isSelected = index == position }
optionAdapter.notifyDataSetChanged()
binding.tvFoodName.text = optionList[position].materName
val index = optionList[position].typeSelectedIndex
val typeList = optionList[position].vegTypeList ?: emptyList()
val selectType = typeList.getOrNull(index)?.vegTypeName ?: ""
binding.tvVegTypeName.text = selectType
KeyboardUtil.hideKeyboard(this@ShelfV3Activity.window.decorView)
}
private var cabinetType: Int = 0
private var canteenId: Long = 0
private var cabinetId: String = ""
private var slotNo: String = ""
private var materName: String = ""
private var vegTypeName: String = ""
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityShelfV3Binding.inflate(layoutInflater)
setBackground()
setContentView(binding.root)
EventBus.getDefault().register(this)
cabinetType = intent.getIntExtra(EXTRA_CABINET_TYPE, 0)
canteenId = intent.getLongExtra(EXTRA_CANTEEN_ID, 0)
cabinetId = intent.getStringExtra(EXTRA_CABINET_ID) ?: ""
slotNo = intent.getStringExtra(EXTRA_SLOT_NO) ?: ""
materName = intent.getStringExtra(EXTRA_MATER_NAME) ?: ""
vegTypeName = intent.getStringExtra(EXTRA_VEG_TYPE_NAME) ?: ""
binding.tvShelfName.text = slotNo
binding.tvFoodName.text = materName.ifBlank { "-" }
binding.tvVegTypeName.text = vegTypeName
initRecyclerView()
initSearchBox()
initButtons()
sendCmd(ScaleManager.buildGetStatusCmd())
viewModel.slotDetail(
request = SlotDetailRequest(cabinetType, cabinetId, slotNo),
onLoading = { showProgress() },
onSuccess = onSuccess@{ model ->
if (model == null) return@onSuccess
shelfModel = model
updateUI(model)
loadList()
},
onError = { msg -> showError(msg) }
)
}
/** 从 slotDetail 响应更新 UI */
private fun updateUI(model: SlotModel) {
binding.tvFoodName.text = if (model.materName.isNullOrBlank()) "-" else model.materName
binding.tvVegTypeName.text = model.vegTypeName
val weightG = model.weight ?: 0.0
realWeight = (weightG * 1000.0).toInt()
// 负漂移抑制:-5克以内的负读数显示时归零(realWeight 保持真实值)
val displayWeight = if (realWeight in -5..0) 0 else realWeight
binding.tvFoodWeight.text = if (weightG >= 1.0) "%.3f千克".format(weightG)
else "${displayWeight}"
startTime = System.currentTimeMillis()
}
private fun initRecyclerView() {
binding.rvSearch.run {
layoutManager = GridLayoutManager(this@ShelfV3Activity, 2)
adapter = optionAdapter
}
binding.refreshLayout.run {
setEnableRefresh(true)
setEnableLoadMore(false)
setOnRefreshListener { loadList() }
}
}
private fun initSearchBox() {
binding.etInputFood.run {
addOnActionSearchListener {
loadList()
KeyboardUtil.hideKeyboard(context, this)
}
addTextChangedListener(afterTextChanged = { editable ->
if (editable.isNullOrBlank()) loadList()
})
}
binding.root.setOnClickListener { KeyboardUtil.hideKeyboard(it) }
}
private fun initButtons() {
binding.ivBack.setOnClickListener { finish() }
binding.btnClearZero.setOnClickListener {
Loading.show(this@ShelfV3Activity)
clearZero()
window.decorView.postDelayed({ Loading.dismiss() }, 10000)
}
binding.btnClearEmpty.setOnClickListener { v ->
clearZero()
binding.tvFoodName.text = "-"
binding.tvVegTypeName.text = ""
Loading.show(this@ShelfV3Activity)
v.postDelayed({
Loading.dismiss()
val emptyModel = shelfModel?.copy(
materName = "",
materId = null,
weight = null,
traceCode = null,
storeTime = null,
inboundNo = null
)
val result = Intent().apply {
putExtra(SHELF_MODEL, emptyModel)
}
setResult(RESULT_OK, result)
finish()
}, 3000)
}
binding.btnConfirm.clickWithDebounce {
val item = optionList.firstOrNull { it.isSelected }
if (item == null) {
toast("请选择食材")
return@clickWithDebounce
}
val model = shelfModel ?: return@clickWithDebounce
val vegType = item.vegTypeList?.getOrNull(item.typeSelectedIndex)
viewModel.put(
request = PutSlotRequest(
cabinetType = cabinetType,
cabinetId = cabinetId,
slotNo = slotNo,
materId = item.materId,
materName = item.materName,
weight = shelfModel?.weight ?: 0.0
).also {
it.vegTypeId = vegType?.vegTypeId
},
onLoading = { showProgress() },
onSuccess = onSuccess@{ inboundNo ->
Loading.dismiss()
// if (inboundNo.isNullOrBlank()) {
// toast("入库失败")
// return@onSuccess
// }
val resultModel = item.toSlotModel(model).also {
// it.inboundNo = inboundNo
it.vegTypeId = vegType?.vegTypeId
it.vegTypeName = vegType?.vegTypeName
}
val result = Intent().apply {
putExtra(SHELF_MODEL, resultModel)
}
setResult(RESULT_OK, result)
finish()
},
onError = { msg -> showError(msg) }
)
}
}
private fun loadList() {
viewModel.searchFood(
request = SearchFoodRequest(
cabinetType = shelfModel?.cabinetType ?: 0,
keyword = binding.etInputFood.text.toString().trim()
),
onLoading = { showProgress() },
onSuccess = { items -> updateOptionList(items) },
onError = { msg -> showError(msg) }
)
}
private fun showProgress() {
Loading.show(this)
}
@SuppressLint("NotifyDataSetChanged")
private fun updateOptionList(items: List<FoodItem>?) {
finishRefresh()
binding.include.root.gone()
binding.root.postDelayed({ Loading.dismiss() }, 200)
if (items.isNullOrEmpty()) {
loadEmptyView(); return
}
optionList.clear()
optionList.addAll(items)
optionAdapter.notifyDataSetChanged()
}
private fun showError(message: String) {
finishRefresh()
Loading.dismiss()
toast(message)
loadEmptyView()
}
@SuppressLint("NotifyDataSetChanged")
private fun loadEmptyView() {
optionList.clear()
optionAdapter.notifyDataSetChanged()
binding.include?.let {
it.root.visible()
it.root.setOnClickListener { loadList() }
it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_gray)
it.ivEmptyIcon.updateLayoutParams<LinearLayout.LayoutParams> {
val swDpValue = resources?.configuration?.smallestScreenWidthDp ?: 0
width = if (swDpValue > 600) 200.dp else 120.dp
}
}
binding.refreshLayout.run {
setEnableRefresh(false)
setEnableLoadMore(false)
}
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (it.state == RefreshState.Refreshing) it.finishRefresh(500)
else if (it.state == RefreshState.Loading) it.finishLoadMore(500)
}
}
private fun clearZero() {
lifecycleScope.launch {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: 0
val zeroClearingCmd = ScaleManager.buildScaleZeroCmd(slotNum)
log("格口${shelfModel?.slotNo}执行清零指令:$zeroClearingCmd")
sendCmd(zeroClearingCmd)
realWeight = 0
sendCmd(ScaleManager.buildGetStatusCmd())
startTime = System.currentTimeMillis()
}
KeyboardUtil.hideKeyboard(window.decorView)
}
override fun onClockTick() {
val arr = clockFormatter.format(Date()).split("***")
updateLeftStatus(arr[0])
updateRightStatus(arr[1])
}
override fun onResume() {
super.onResume()
hideStatusBar()
}
override fun onDestroy() {
EventBus.getDefault().unregister(this)
super.onDestroy()
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun receiveWeightEvent(event: SendWeightEvent) {
val slotNum = shelfModel?.slotNo?.filter { it.isDigit() }?.toIntOrNull() ?: return
if (event.shelfNo != slotNum) return
val intervalTime = System.currentTimeMillis() - startTime
val weight = event.weight
log("格口${shelfModel?.slotNo}获取重量:${weight}克,间隔:${intervalTime}ms")
// 负漂移抑制:-5克以内的负读数显示时归零
val displayWeight = if (weight in -5..0) 0 else weight
binding.tvFoodWeight.text = if (displayWeight < 1000) "${displayWeight}"
else "%.3f千克".format(displayWeight / 1000.0)
startTime = System.currentTimeMillis()
window.decorView.postDelayed({ Loading.dismiss() }, 1000)
}
private fun showChipSelectDialog(categoryList: List<String?>, callback: (Int) -> Unit) {
ChipSelectDialog(this).apply {
dialogTitle = "净菜分类"
items = categoryList
onItemSelected = callback
show()
}
}
}
@@ -1,49 +0,0 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding
import com.sw.scalefusion.shelf.model.FoodItem
class PendingInboundAdapter(list: MutableList<FoodItem>) :
BaseQuickAdapter<FoodItem, PendingInboundAdapter.VH>(list) {
inner class VH(val binding: ListItemSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FoodItem?) {
item ?: return
val isSelected = item.isSelected
val binding = holder.binding
// 名称与编码:选中蓝字,未选中灰字
val nameColor = getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999)
binding.tvFoodName.run {
text = item.materName
setTextColor(nameColor)
}
binding.tvMaterCode.run {
text = item.materCode
setTextColor(nameColor)
}
// 选中蓝边框,未选中灰边框
binding.cardFood.strokeColor =
(if (isSelected) "#4969F5" else "#E6E6E6").toColorInt()
// 仅今日已验收的食材显示右上角绿色圆点
binding.viewAcceptedDot.visibility =
if (item.todayAccepted == true) View.VISIBLE else View.GONE
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -1,85 +0,0 @@
package com.sw.scalefusion.shelf.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.google.android.flexbox.FlexboxLayoutManager
import com.shuwei.intelligent.shelves.R
import com.shuwei.intelligent.shelves.databinding.ListItemShelfV3Binding
import com.sw.scalefusion.shelf.model.SlotModel
import kotlin.math.abs
class ShelfV3Adapter(list: MutableList<SlotModel>) :
BaseQuickAdapter<SlotModel, ShelfV3Adapter.VH>(list) {
/** 每个格口 item 的高度(像素),由 Activity 动态计算 */
var itemHeight: Int = 0
/** 每个格口 item 的宽度(像素),由 Activity 动态计算 */
var itemWidth: Int = 0
inner class VH(val binding: ListItemShelfV3Binding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemShelfV3Binding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SlotModel?) {
item ?: return
// 动态设置 item 宽高
val lp = holder.itemView.layoutParams as? FlexboxLayoutManager.LayoutParams
lp?.apply {
if (itemWidth > 0) width = itemWidth
if (itemHeight > 0) height = itemHeight
}
val binding = holder.binding
// 格口编号
binding.tvShelfName.text = item.slotNo.padStart(2, '0')
val weightG = item.weight ?: 0.0
// 负漂移抑制:空秤零点可能出现 -5克以内的负读数,显示时归零
val displayG = if (weightG in -0.005..0.0) 0.0 else weightG
binding.tvFoodWeight.run {
setTextColor(getColor(R.color.food_weight_orange))
text = if (abs(displayG) >= 1.0) "%.3f千克".format(displayG)
else "${(displayG * 1000.0).toInt()}"
}
val isBlank = item.materName.isNullOrBlank()
if (isBlank) {
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_blue))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_white))
binding.tvFoodName.run {
setTextColor(getColor(R.color.white))
text = ""
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.food_weight_blue))
text = "-"
}
binding.tvFoodType.text = ""
return
}
binding.layoutCard.setCardBackgroundColor(getColor(R.color.bg_card_white))
binding.tvShelfName.setTextColor(getColor(R.color.shelf_name_blue))
binding.tvFoodName.run {
setTextColor(getColor(R.color.food_name_black))
text = item.materName
}
binding.tvStoreDate.run {
setTextColor(getColor(R.color.black999))
text = item.storeTime
}
binding.tvFoodType.text = item.vegTypeName
binding.tvFoodType.setTextColor(getColor(R.color.black666))
}
private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id)
}
@@ -1,29 +0,0 @@
package com.sw.scalefusion.shelf.model
data class PendingInboundRequest(
var cabinetType: Int,
var canteenId: Long,
var cabinetId: String
)
data class SearchFoodRequest(
var cabinetType: Int,
var keyword: String? = null
)
data class PutSlotRequest(
val cabinetType: Int? = null,
val cabinetId: String? = null,
val slotNo: String? = null,
val materId: Long? = null,
var vegTypeId: Long? = null,
val materName: String? = null,
val traceCode: String? = null,
val weight: Double? = null,
)
data class SlotDetailRequest(
var cabinetType: Int,
var cabinetId: String,
var slotNo: String
)
@@ -1,29 +0,0 @@
package com.sw.scalefusion.shelf.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.util.Date
data class FoodItem(
var materId:Long? = null,
var materName:String? = null,
/** 食材编码(一级分类+二级分类+自编码,全局唯一) */
var materCode:String? = null,
/** 今日是否在验收秤有验收记录 */
var todayAccepted:Boolean? = null,
var materUrl:String? = null,
var vegTypeList:List<FoodType>? = null,
var isSelected:Boolean = false,
var typeSelectedIndex:Int = -1
) {
/** 转换为 SlotModel,保留原格口的 slotNo/cabinetId 等 */
fun toSlotModel(model: SlotModel) = model.copy(
materId = materId ,
materName = materName,
storeTime = DateTimeUtil.formatDateTime(dateTime = Date()),
)
}
data class FoodType(
var vegTypeId:Long? = null,
var vegTypeName:String? = null
)
@@ -1,32 +0,0 @@
package com.sw.scalefusion.shelf.model
import com.shuwei.intelligent.shelves.utils.DateTimeUtil
import java.util.Date
/**
* /pending-inbound 接口响应 — 待入柜物料
*/
data class PendingInboundItem(
val id: Long = 0,
val materId: Long = 0,
val materName: String = "",
val traceCode: String? = null,
val weight: Double = 0.0,
val inboundNo: String = "",
val inboundTime: String? = null,
val vegTypeId: Long? = null,
val vegTypeName: String? = null,
val spec: String? = null,
var isSelected: Boolean = false
) {
/** 转换为 SlotModel,保留原格口的 slotNo/cabinetId 等 */
fun toSlotModel(model: SlotModel) = model.copy(
materId = materId ,
materName = materName,
traceCode = traceCode,
inboundNo = inboundNo,
storeTime = DateTimeUtil.formatDateTime(dateTime = Date()),
// weight = weight
)
}
@@ -1,23 +0,0 @@
package com.sw.scalefusion.shelf.model
import java.io.Serializable
/**
* /slots 接口响应模型 — 格口分组数据
*/
data class SlotModel(
val id: String = "",
val slotNo: String = "",
val cabinetId: String,
val cabinetType: Int,
val hasContent: Boolean = false,
var inboundNo: String? = null,
val materId: Long? = null,
val materName: String? = null,
val traceCode: String? = null,
var vegTypeId: Long? = null,
var vegTypeName: String? = null,
//weight单位千克
var weight: Double? = 0.0,
var storeTime: String? = null,
): Serializable
@@ -1,12 +0,0 @@
package com.sw.scalefusion.shelf.model
data class SlotRecord(
val cabinetType: Int,
val cabinetId: String,
val deviceName: String,
val canteenId: Long,
val canteenName: String,
val horizontalRows: Int,
val verticalCount: Int,
val slots: List<SlotModel>? = null
)
@@ -1,15 +0,0 @@
package com.sw.scalefusion.shelf.model
/**
* /sync-weight 接口请求体
*/
data class SyncWeightRequest(
val cabinetType: Int,
val cabinetId: String,
val slotList: List<SlotWeight>
)
data class SlotWeight(
val slotNo: String,
val weight: Double
)
@@ -1,9 +0,0 @@
package com.sw.scalefusion.shelf.net
data class ApiResponse<T>(
val code: String,
val msg: String? = "",
val data: T? = null
) {
fun isSuccess(): Boolean = ("00000" == code)
}
@@ -1,43 +0,0 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface ApiServiceV3 {
/** 格口列表 */
@GET("/nutrition/neglect/mater-cabinet/init")
suspend fun slots(@Query("cabinetId") cabinetId: String = App.deviceId): ApiResponse<SlotRecord?>
/** 待入柜列表 */
@POST("/nutrition/neglect/mater-cabinet/pending-inbound")
suspend fun pendingInbound(@Body request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?>
/** 搜索净菜、毛菜 */
@POST("/nutrition/neglect/mater-cabinet/ingredients")
suspend fun searchFood(@Body request: SearchFoodRequest): ApiResponse<List<FoodItem>?>
/** 全量同步重量 */
@POST("/nutrition/neglect/mater-cabinet/sync-weight")
suspend fun syncWeight(@Body request: SyncWeightRequest): ApiResponse<Any?>
/** 格口入柜 */
@POST("/nutrition/neglect/mater-cabinet/put")
suspend fun put(@Body request: PutSlotRequest): ApiResponse<String?>
/** 格口详情 */
@POST("/nutrition/neglect/mater-cabinet/slot-detail")
suspend fun slotDetail(@Body request: SlotDetailRequest): ApiResponse<SlotModel?>
}
@@ -1,66 +0,0 @@
package com.sw.scalefusion.shelf.net
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonParseException
import retrofit2.HttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.net.ssl.SSLHandshakeException
abstract class BaseRepository {
suspend fun <T> safeApiCall(apiCall: suspend () -> ApiResponse<T>): ApiResponse<T> {
return try {
apiCall()
} catch (e: Exception) {
Log.e("BaseRepository","safeApiCall Exception: ${e.stackTraceToString()}")
when (e) {
is HttpException -> {
// 优先解析错误响应体中后台返回的业务 msg,解析失败再回退到 HTTP 状态短语
val bodyMsg = e.parseApiMessage()
ApiResponse(code = "${e.code()}", msg = bodyMsg ?: e.message())
}
is SocketTimeoutException -> {
ApiResponse(code = "-2", msg = "请求超时: ${e.message}")
}
is ConnectException -> {
ApiResponse(code = "-3", msg = "连接失败: ${e.message}")
}
is SSLHandshakeException -> {
ApiResponse(code = "-4", msg = "SSL握手失败: ${e.message}")
}
is JsonParseException -> {
ApiResponse(code = "-5", msg = "JSON解析错误: ${e.message}")
}
is IOException -> {
ApiResponse(code = "-6", msg = "网络IO错误: ${e.message}")
}
else -> {
ApiResponse(code = "-1", msg = "未知错误: ${e.message ?: "无错误信息"}")
}
}
}
}
/**
* 从 HttpException 的错误响应体中解析后台返回的业务 msg。
* 后端业务错误通常以非 2xx 状态码返回,body 仍为 {"code":...,"msg":...,"data":...} 结构,
* 此处提取 msg 供上层 toast 直接展示;解析失败返回 null,由调用方回退到 HTTP 状态短语。
*/
private fun HttpException.parseApiMessage(): String? {
return try {
val raw = response()?.errorBody()?.string() ?: return null
val parsed = Gson().fromJson(raw, ApiResponse::class.java)
parsed?.msg
} catch (_: Exception) {
null
}
}
}
@@ -1,95 +0,0 @@
package com.sw.scalefusion.shelf.net
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
import kotlinx.coroutines.launch
class NetViewModelV3 : ViewModel() {
private val repository = ShelfRepository()
/** 格口列表 */
fun slots(
onLoading: () -> Unit = {},
onSuccess: (SlotRecord?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slots()
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 待入柜列表 */
fun pendingInbound(
request: PendingInboundRequest,
onLoading: () -> Unit = {},
onSuccess: (List<PendingInboundItem>?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.pendingInbound(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 搜索净菜、毛菜 */
fun searchFood(request: SearchFoodRequest, onLoading: () -> Unit = {}, onSuccess: (List<FoodItem>?) -> Unit, onError: (String) -> Unit = {}) {
viewModelScope.launch {
onLoading()
val response = repository.searchFood(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 格口详情 */
fun slotDetail(
request: SlotDetailRequest,
onLoading: () -> Unit = {},
onSuccess: (SlotModel?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.slotDetail(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
/** 全量同步重量(无回调,fire-and-forget */
fun syncWeight(request: SyncWeightRequest) {
viewModelScope.launch {
repository.syncWeight(request)
}
}
/** 格口入柜(无回调,fire-and-forget */
fun put(
request: PutSlotRequest,
onLoading: () -> Unit = {},
onSuccess: (String?) -> Unit,
onError: (String) -> Unit = {}
) {
viewModelScope.launch {
onLoading()
val response = repository.put(request)
if (response.isSuccess()) onSuccess(response.data)
else onError(response.msg ?: "请求失败")
}
}
}
@@ -1,39 +0,0 @@
package com.sw.scalefusion.shelf.net
import com.shuwei.intelligent.shelves.App
import com.shuwei.intelligent.shelves.net.apiServiceV3
import com.sw.scalefusion.shelf.model.FoodItem
import com.sw.scalefusion.shelf.model.PendingInboundItem
import com.sw.scalefusion.shelf.model.PendingInboundRequest
import com.sw.scalefusion.shelf.model.PutSlotRequest
import com.sw.scalefusion.shelf.model.SearchFoodRequest
import com.sw.scalefusion.shelf.model.SlotDetailRequest
import com.sw.scalefusion.shelf.model.SlotModel
import com.sw.scalefusion.shelf.model.SlotRecord
import com.sw.scalefusion.shelf.model.SyncWeightRequest
class ShelfRepository : BaseRepository() {
/** 格口列表 */
suspend fun slots(cabinetId: String = App.deviceId): ApiResponse<SlotRecord?> =
safeApiCall { apiServiceV3.slots(cabinetId) }
/** 待入柜列表 */
suspend fun pendingInbound(request: PendingInboundRequest): ApiResponse<List<PendingInboundItem>?> =
safeApiCall { apiServiceV3.pendingInbound(request) }
/** 搜索净菜、毛菜 */
suspend fun searchFood(request: SearchFoodRequest): ApiResponse<List<FoodItem>?> =
safeApiCall { apiServiceV3.searchFood(request) }
/** 全量同步重量 */
suspend fun syncWeight(request: SyncWeightRequest): ApiResponse<Any?> =
safeApiCall { apiServiceV3.syncWeight(request) }
/** 格口入柜 */
suspend fun put(request: PutSlotRequest): ApiResponse<String?> =
safeApiCall { apiServiceV3.put(request) }
/** 格口详情 */
suspend fun slotDetail(request: SlotDetailRequest): ApiResponse<SlotModel?> =
safeApiCall { apiServiceV3.slotDetail(request) }
}
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bg_card_blue" android:state_checked="true" />
<item android:color="@color/white" android:state_checked="false" />
</selector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bg_card_blue" android:state_checked="false" />
<item android:color="@android:color/transparent" android:state_checked="true" />
</selector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_checked="true" />
<item android:color="@color/bg_card_blue" android:state_checked="false" />
</selector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/white" android:state_pressed="false" />
<item android:drawable="@color/gray_edit" android:state_pressed="true" />
</selector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:innerRadiusRatio="3"
android:thicknessRatio="8"
android:useLevel="false">
<gradient
android:type="sweep"
android:startColor="#FF6B6B"
android:centerColor="#4ECDC4"
android:endColor="#FF6B6B"
android:angle="0" />
</shape>
@@ -0,0 +1,7 @@
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item android:id="@android:id/mask">
<color android:color="@android:color/white" />
</item>
</ripple>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#90000000"/>
<corners android:radius="5dp"/>
</shape>
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:background="@color/bg_page">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvShelf"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
android:overScrollMode="never"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
tools:listitem="@layout/list_item_shelf"/>
<include
android:id="@+id/include"
android:layout_width="match_parent"
android:layout_height="match_parent"
layout="@layout/layout_empty_view"
android:visibility="gone"/>
</FrameLayout>
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:background="@color/bg_page">
<!-- 格口列表支持下拉刷新:重新拉取 /init 格口数据 -->
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:srlAccentColor="@color/white"
app:srlPrimaryColor="@color/bg_page"
app:srlEnableLastTime="false" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvShelf"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
android:overScrollMode="never"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
tools:listitem="@layout/list_item_shelf_v3"/>
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<include
android:id="@+id/include"
android:layout_width="match_parent"
android:layout_height="match_parent"
layout="@layout/layout_empty_view"
android:visibility="gone"/>
</FrameLayout>
@@ -74,16 +74,6 @@
android:textStyle="bold" android:textStyle="bold"
tools:text="金针菇" /> tools:text="金针菇" />
<TextView
android:id="@+id/tvVegTypeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/food_name_black"
android:textSize="22sp"
tools:text="金针菇" />
<Space <Space
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="0dp"
@@ -139,25 +129,22 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"> android:orientation="vertical">
<!-- 搜索框 -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="80dp" android:layout_height="80dp"
android:layout_marginStart="48dp" android:layout_marginStart="48dp"
android:layout_marginTop="36dp" android:layout_marginTop="48dp"
android:layout_marginEnd="48dp" android:layout_marginEnd="48dp"
android:background="@drawable/shape_search" android:background="@drawable/shape_search"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal">
android:visibility="visible">
<ImageView <ImageView
android:layout_width="32dp" android:layout_width="32dp"
android:layout_height="32dp" android:layout_height="32dp"
android:layout_marginStart="24dp" android:layout_marginStart="24dp"
android:layout_marginEnd="24dp" android:layout_marginEnd="24dp"
android:src="@drawable/ic_search_food" android:src="@drawable/ic_search_food" />
tools:ignore="ContentDescription" />
<EditText <EditText
android:id="@+id/etInputFood" android:id="@+id/etInputFood"
@@ -166,7 +153,7 @@
android:layout_marginEnd="24dp" android:layout_marginEnd="24dp"
android:background="@null" android:background="@null"
android:fontFamily="sans-serif-medium" android:fontFamily="sans-serif-medium"
android:hint="输入名称搜索" android:hint="输入食材名称"
android:imeOptions="actionSearch" android:imeOptions="actionSearch"
android:inputType="text" android:inputType="text"
android:maxLines="1" android:maxLines="1"
@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="500dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 标题 -->
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="40dp"
android:gravity="center"
android:textColor="#ff141428"
android:textSize="32sp"
android:textStyle="bold" />
<!-- ChipGroup 选择区域 -->
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginHorizontal="30dp"
android:layout_marginVertical="50dp"
app:chipSpacingHorizontal="30dp"
app:chipSpacingVertical="18dp"
app:selectionRequired="false"
app:singleSelection="true" />
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#FFDCDCF0" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_pressed"
android:text="取消"
android:textColor="@android:color/darker_gray"
android:textSize="24sp" />
<View
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="#FFDCDCF0" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_pressed"
android:text="确定"
android:textColor="@color/bg_card_blue"
android:textSize="24sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
@@ -38,7 +38,7 @@
android:layout_height="72dp" android:layout_height="72dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingStart="16dp" android:paddingStart="16dp"
android:text="本地环境" android:text="测试环境"
android:textColor="#ff141428" android:textColor="#ff141428"
android:textSize="28sp" /> android:textSize="28sp" />
@@ -48,7 +48,7 @@
android:layout_height="72dp" android:layout_height="72dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingStart="16dp" android:paddingStart="16dp"
android:text="测试环境" android:text="UAT 环境"
android:textColor="#ff141428" android:textColor="#ff141428"
android:textSize="28sp" /> android:textSize="28sp" />
+36 -48
View File
@@ -1,60 +1,48 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- 搜索食材列表 item:食材名 + 编码副行,今日已验收时右上角显示绿色圆点 --> <!--<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"-->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" <!-- xmlns:tools="http://schemas.android.com/tools"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="80dp"-->
<!-- android:layout_margin="12dp">-->
<!-- <TextView-->
<!-- android:id="@+id/btnFoodName"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_gravity="center"-->
<!-- android:ellipsize="end"-->
<!-- android:gravity="center"-->
<!-- android:maxLines="1"-->
<!-- android:paddingStart="5dp"-->
<!-- android:paddingEnd="5dp"-->
<!-- android:fontFamily="sans-serif-medium"-->
<!-- android:textColor="@color/black999"-->
<!-- android:textSize="30sp"-->
<!-- tools:text="土豆丝" />-->
<!--</FrameLayout>-->
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/cardFood" android:id="@+id/btnFoodName"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="96dp" android:layout_height="80dp"
android:layout_margin="12dp" android:layout_margin="12dp"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeColor="#E6E6E6"
app:strokeWidth="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="5dp"
android:paddingTop="10dp"
android:paddingEnd="5dp">
<TextView
android:id="@+id/tvFoodName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:fontFamily="sans-serif-medium" android:fontFamily="sans-serif-medium"
android:gravity="center" android:gravity="center"
android:insetTop="0dp"
android:insetBottom="0dp"
android:maxLines="1" android:maxLines="1"
app:paddingStart="5dp"
app:paddingEnd="5dp"
android:insetRight="0dp"
android:insetLeft="0dp"
android:paddingHorizontal="8dp"
android:textColor="@color/black999" android:textColor="@color/black999"
android:textSize="30sp" android:textSize="30sp"
tools:text="土豆丝土豆丝土豆丝" /> app:cornerRadius="12dp"
app:strokeWidth="2dp"
tools:text="土豆丝土豆丝土豆丝土豆丝土豆丝土豆丝"
/>
<TextView
android:id="@+id/tvMaterCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="1"
android:textColor="@color/black999"
android:textSize="16sp"
tools:text="0102003" />
</LinearLayout>
<!-- 今日已验收标识圆点,默认隐藏,由 Adapter 控制显示 -->
<View
android:id="@+id/viewAcceptedDot"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="top|end"
android:layout_margin="10dp"
android:background="@drawable/shape_accepted_dot"
android:visibility="gone"
tools:visibility="visible" />
</com.google.android.material.card.MaterialCardView>
@@ -3,8 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layoutCard" android:id="@+id/layoutCard"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="216dp"
android:layout_margin="12dp" android:layout_margin="12dp"
app:cardBackgroundColor="@color/bg_card_blue" app:cardBackgroundColor="@color/bg_card_blue"
app:cardCornerRadius="12dp" app:cardCornerRadius="12dp"
@@ -38,28 +38,15 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false"
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="30sp" android:textSize="30sp"
android:textStyle="bold" android:textStyle="bold"
tools:text="空" /> tools:text="空" />
<TextView
android:id="@+id/tvFoodType"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:ellipsize="end"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="20sp"
tools:text="切块" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="0.8" /> android:layout_weight="0.833" />
<TextView <TextView
android:id="@+id/tvFoodWeight" android:id="@+id/tvFoodWeight"
@@ -75,7 +62,7 @@
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="0.9" /> android:layout_weight="0.933" />
<TextView <TextView
android:id="@+id/tvStoreDate" android:id="@+id/tvStoreDate"
-2
View File
@@ -17,9 +17,7 @@
<color name="food_name_black">#0A143C</color> <color name="food_name_black">#0A143C</color>
<color name="food_weight_blue">#C8C8FF</color> <color name="food_weight_blue">#C8C8FF</color>
<color name="food_weight_orange">#FF6400</color> <color name="food_weight_orange">#FF6400</color>
<color name="accepted_green">#34C77B</color>
<color name="black999">#999999</color> <color name="black999">#999999</color>
<color name="black666">#666666</color>
<color name="white_f6">#F6F6F6</color> <color name="white_f6">#F6F6F6</color>
<color name="gray_edit">#E6EBF0</color> <color name="gray_edit">#E6EBF0</color>
</resources> </resources>
+2 -2
View File
@@ -4,5 +4,5 @@
# Location of the SDK. This is only used by Gradle. # Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the # For customization when using a Version Control System, please read the
# header note. # header note.
#Thu Jul 02 16:04:28 CST 2026 #Tue Jan 13 10:38:32 CST 2026
sdk.dir=C\:\\Users\\MZF\\AppData\\Local\\Android\\Sdk sdk.dir=C\:\\Users\\HUAWEI\\AppData\\Local\\Android\\Sdk
-76
View File
@@ -1,76 +0,0 @@
// 测试列表转换逻辑
data class TestModel(var deviceId: String = "", val value: Int)
fun testTransform(tempList: List<TestModel>): List<TestModel> {
val list = mutableListOf<TestModel>()
val deviceId = "test-device"
val leftSize = (tempList.size + 1) / 2 // 左列长度(向上取整)
val rightSize = tempList.size / 2 // 右列长度(向下取整)
repeat(rightSize) { index ->
list.add(tempList[index].also { it.deviceId = deviceId })
list.add(tempList[leftSize + index].also { it.deviceId = deviceId })
}
// 处理奇数长度:左列比右列多一个
if (tempList.size % 2 != 0) {
list.add(tempList[rightSize].also { it.deviceId = deviceId })
}
return list
}
fun main() {
// 测试用例 1: 奇数 5 个元素
println("=== 测试 1: 奇数 [1,2,3,4,5] ===")
val test1 = listOf(1, 2, 3, 4, 5).map { TestModel(value = it) }
val result1 = testTransform(test1)
println("输入: ${test1.map { it.value }}")
println("输出: ${result1.map { it.value }}")
println("期望: [1, 4, 2, 5, 3]")
println("匹配: ${result1.map { it.value } == listOf(1, 4, 2, 5, 3)}\n")
// 测试用例 2: 偶数 6 个元素
println("=== 测试 2: 偶数 [1,2,3,4,5,6] ===")
val test2 = listOf(1, 2, 3, 4, 5, 6).map { TestModel(value = it) }
val result2 = testTransform(test2)
println("输入: ${test2.map { it.value }}")
println("输出: ${result2.map { it.value }}")
println("期望: [1, 4, 2, 5, 3, 6]")
println("匹配: ${result2.map { it.value } == listOf(1, 4, 2, 5, 3, 6)}\n")
// 测试用例 3: 偶数 10 个元素(实际业务场景)
println("=== 测试 3: 偶数 [1,2,3,4,5,6,7,8,9,10] ===")
val test3 = (1..10).map { TestModel(value = it) }
val result3 = testTransform(test3)
println("输入: ${test3.map { it.value }}")
println("输出: ${result3.map { it.value }}")
println("期望: [1, 6, 2, 7, 3, 8, 4, 9, 5, 10]")
println("匹配: ${result3.map { it.value } == listOf(1, 6, 2, 7, 3, 8, 4, 9, 5, 10)}\n")
// 测试用例 4: 奇数 7 个元素
println("=== 测试 4: 奇数 [1,2,3,4,5,6,7] ===")
val test4 = (1..7).map { TestModel(value = it) }
val result4 = testTransform(test4)
println("输入: ${test4.map { it.value }}")
println("输出: ${result4.map { it.value }}")
println("期望: [1, 5, 2, 6, 3, 7, 4]")
println("匹配: ${result4.map { it.value } == listOf(1, 5, 2, 6, 3, 7, 4)}\n")
// 测试用例 5: 边界情况 2 个元素
println("=== 测试 5: 边界 [1,2] ===")
val test5 = listOf(1, 2).map { TestModel(value = it) }
val result5 = testTransform(test5)
println("输入: ${test5.map { it.value }}")
println("输出: ${result5.map { it.value }}")
println("期望: [1, 2]")
println("匹配: ${result5.map { it.value } == listOf(1, 2)}\n")
// 测试用例 6: 边界情况 1 个元素
println("=== 测试 6: 边界 [1] ===")
val test6 = listOf(1).map { TestModel(value = it) }
val result6 = testTransform(test6)
println("输入: ${test6.map { it.value }}")
println("输出: ${result6.map { it.value }}")
println("期望: [1]")
println("匹配: ${result6.map { it.value } == listOf(1)}\n")
}
-241
View File
@@ -1,241 +0,0 @@
# 货柜设备端 — API 文档
> Controller: `NutCabinetController`(毛菜柜/净菜柜通用,通过 `cabinetType` 区分)
> 路径前缀: `/neglect/mater-cabinet`Nacos 白名单,无需 Sa-Token
> 日期: 2026-06-24
---
## 一、货柜初始化
设备端首次加载调用,后端从 `nut_terminal.device_type` 推导毛菜/净菜类型,懒创建空置格口,返回柜体元信息 + 全量格口。
```
GET /neglect/mater-cabinet/init?cabinetId=CAB-001
```
**参数**`cabinetId` — 必填,智能柜编号(即 `nut_terminal.device_code`
**响应**
```json
{
"cabinetType": 1, // 1=毛菜柜,2=净菜柜(后端推导)
"cabinetId": "CAB-001",
"deviceName": "毛菜柜-A区",
"canteenId": 100,
"canteenName": "第一食堂",
"horizontalRows": 4,
"verticalCount": 5,
"slots": [
{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
},
{
"id": null,
"slotNo": "2",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": false,
"weight": null,
"storeTime": null
}
]
}
```
**说明**
- `cabinetType` 由后端根据 `nut_terminal.device_type` 推导(`raw_cabinet`→1 / `clean_cabinet`→2),前端无需传
- `hasContent=true` 时有存放数据,业务字段(inboundNo/materId 等)有值
- `hasContent=false` 时空置,业务字段均为 null
- 格口按 slotNo 数值升序排列
- 每次调用均幂等:格口数不足时自动补建,已足则跳过
---
## 二、格口入柜
```
POST /neglect/mater-cabinet/put
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 1=毛菜柜,2=净菜柜
"cabinetId": "CAB-001", // 必填 — 智能柜编号
"slotNo": "1", // 必填 — 格口编号
"materId": 10001, // 必填 — 食材id
"materName": "菠菜", // 必填 — 食材名称
"traceCode": "TC20260601", // 选填 — 溯源码(净菜柜用于精确匹配)
"weight": 5.2 // 必填 — 入柜重量(kg)
}
```
**说明**
- 后端按食材+重量匹配对应入库记录(选重量最接近的一条),将其标记为"已入柜"
- 传 traceCode 时先按溯源码精确过滤,再按重量匹配
- 格口必须已由 `/init` 预置,否则报错"格口未初始化"
- 格口已有物品时拒绝入柜
- 格口行 UPDATE,流水表 INSERT
**响应**
```json
{ "code": 200, "data": "IN20260623001", "msg": "操作成功" }
```
---
## 三、格口列表(全部格口)
```
POST /neglect/mater-cabinet/slots
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 1=毛菜柜,2=净菜柜
"cabinetId": "CAB-001" // 必填 — 智能柜编号
}
```
**响应** — 与 `/init` 返回的 `slots` 字段结构完全相同
```json
[{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
}]
```
---
## 四、待入柜列表
```
POST /neglect/mater-cabinet/pending-inbound
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 货柜类型
"cabinetId": "CAB-001", // 必填 — 智能柜编号(DTO 校验要求,实际查询用 canteenId)
"canteenId": 100 // 必填 — 食堂id
}
```
**响应**
```json
[{
"id": 1,
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"weight": 10.0,
"inboundNo": "IN20260623001",
"inboundTime": "2026-06-23 09:00:00",
"vegTypeId": 10,
"vegTypeName": "净菜包",
"spec": "500g"
}]
```
---
## 五、格口详情
```
POST /neglect/mater-cabinet/slot-detail
```
**请求体**
```json
{
"cabinetType": 1, // 必填
"cabinetId": "CAB-001", // 必填
"slotNo": "1" // 必填
}
```
**响应** — 与 `/init` 返回的 `slots` 元素结构相同
```json
{
"id": 1,
"slotNo": "1",
"cabinetId": "CAB-001",
"cabinetType": 1,
"hasContent": true,
"inboundNo": "IN20260623001",
"materId": 10001,
"materName": "菠菜",
"traceCode": "TC20260601",
"vegTypeId": 10,
"weight": 5.2,
"storeTime": "2026-06-23 10:30:00"
}
```
---
## 六、全量同步格口重量
```
POST /neglect/mater-cabinet/sync-weight
```
**请求体**
```json
{
"cabinetType": 1, // 必填 — 货柜类型
"cabinetId": "CAB-001", // 必填 — 智能柜编号
"slotList": [
{ "slotNo": "1", "weight": 5.1 },
{ "slotNo": "2", "weight": 3.0 },
{ "slotNo": "3", "weight": 0 }
]
}
```
**说明**
- 终端定时上报整柜各格口当前重量,后端比对变化写流水
- `weight: 0` 表示空格口,空置格口不产生重量变化流水
- 建议上报所有格口(含空格口),不要只报有变化的格口
**响应**
```json
{ "code": 200, "msg": "操作成功" }
```
---
## 七、前端注意事项
1. **调用顺序**:设备端启动后先调 `/init` 获取柜体元信息(行列数、食堂名)+ 全量格口。后续定时刷新格口状态走 `/sync-weight`
2. **`cabinetType`**`/init` **无需传**,后端从 `nut_terminal.device_type` 推导;其余接口仍需传(毛菜柜=1,净菜柜=2)
3. **`deviceType` / `deviceCode`**:由后端自动从请求头 `X-DEVICE-CODE` 填充,前端无需传
4. **sync-weight 的 `slotList`**:上报所有格口(含空格口 weight=0),不要只报有变化的格口
5. **格口编号**:毛菜柜和净菜柜的 slotNo 均为数字字符串("1", "2", …),最大编号 = horizontalRows × verticalCount
6. **格口结构**:一个格口即一条数据库记录,响应为扁平对象,无嵌套