diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ec4b4b..6d19293 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { compileSdk = 34 defaultConfig { - applicationId = "com.shuwei.intelligent.shelves" + applicationId = "com.sw.scalefusion.shelf" minSdk = 25 //noinspection EditedTargetSdkVersion,ExpiredTargetSdkVersion targetSdk = 30 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a4a1aa7..f6181be 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ android:theme="@style/Theme.IntelligentShelves" android:usesCleartextTraffic="true"> @@ -46,6 +46,10 @@ + + () - 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() -//// finish() -//// } -//// } -//// } -//// is UiState.Error -> toast(message = state.msg) -//// else -> {} -//// } -//// } -//// } -// } -// } -// } - - private fun checkConfigData(data: String): Boolean { - if (data.isBlank()) { - return false - } - val config = data.toObject() - 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() - startActivity() - 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) - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/net/HttpManager.kt b/app/src/main/java/com/shuwei/intelligent/shelves/net/HttpManager.kt index 83672ed..97da647 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/net/HttpManager.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/net/HttpManager.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.util.Log import com.shuwei.intelligent.shelves.BuildConfig import com.shuwei.intelligent.shelves.GlobalData +import com.sw.scalefusion.shelf.net.ApiServiceV3 import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor @@ -48,6 +49,14 @@ val apiServiceV2: ApiServiceV2 = Retrofit.Builder() .build() .create(ApiServiceV2::class.java) +/** V3 接口专用实例,与 V2 共用 cabinetClient(同款拦截器) */ +val apiServiceV3: ApiServiceV3 = Retrofit.Builder() + .baseUrl(GlobalData.appBaseUrl) + .client(HttpManager.instance.cabinetClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(ApiServiceV3::class.java) + class HttpManager private constructor() { val client: OkHttpClient by lazy { OkHttpClient.Builder() diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ProtocolConstants.kt b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ProtocolConstants.kt index fecfc95..b7c1308 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/serial/ProtocolConstants.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/serial/ProtocolConstants.kt @@ -168,6 +168,22 @@ object ProtocolConstants { "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 + /** 激活指令map,key为设备号,value为激活码 */ val ACTIVE_MAP = mutableMapOf( //原410货柜 diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/utils/BootReceiver.kt b/app/src/main/java/com/shuwei/intelligent/shelves/utils/BootReceiver.kt index 2b026e2..6e32685 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/utils/BootReceiver.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/utils/BootReceiver.kt @@ -3,7 +3,7 @@ package com.shuwei.intelligent.shelves.utils import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.shuwei.intelligent.shelves.activity.InitActivity +import com.sw.scalefusion.shelf.InitActivity import kotlin.jvm.java class BootReceiver : BroadcastReceiver() { diff --git a/app/src/main/java/com/shuwei/intelligent/shelves/utils/GridLayoutTool.kt b/app/src/main/java/com/shuwei/intelligent/shelves/utils/GridLayoutTool.kt index 9bb305e..5fa90b1 100644 --- a/app/src/main/java/com/shuwei/intelligent/shelves/utils/GridLayoutTool.kt +++ b/app/src/main/java/com/shuwei/intelligent/shelves/utils/GridLayoutTool.kt @@ -7,6 +7,7 @@ import com.shuwei.intelligent.shelves.App import com.shuwei.intelligent.shelves.model.ShelfModel import com.shuwei.intelligent.shelves.model.ShelfModelV2 import com.shuwei.intelligent.shelves.serial.ProtocolConstants +import com.sw.scalefusion.shelf.model.SlotModel object GridLayoutTool { @@ -69,6 +70,20 @@ object GridLayoutTool { return source.sortedBy { it.slotNo?.toIntOrNull()?:0 } } + fun initListSortV3(rows: Int, columns: Int, source: List): List { + val map = source.associateBy { it.slotNo.toIntOrNull() ?: 0 } + if (rows != 5) return source + return when (columns) { + 3 -> ProtocolConstants.device3ColumnsOrder.mapNotNull { map[it] } + 2 -> ProtocolConstants.device2ColumnsOrder.mapNotNull { map[it] } + else -> source + } + } + + fun submitListSortV3(source: List): List { + return source.sortedBy { it.slotNo.toIntOrNull() ?: 0 } + } + /** * 提交接口list顺序 */ diff --git a/app/src/main/java/com/sw/scalefusion/shelf/HomeV3Activity.kt b/app/src/main/java/com/sw/scalefusion/shelf/HomeV3Activity.kt new file mode 100644 index 0000000..04b57dc --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/HomeV3Activity.kt @@ -0,0 +1,357 @@ +package com.sw.scalefusion.shelf + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DefaultItemAnimator +import com.shuwei.intelligent.shelves.App +import com.shuwei.intelligent.shelves.R +import com.shuwei.intelligent.shelves.base.BaseActivity +import com.shuwei.intelligent.shelves.databinding.ActivityHomeV3Binding +import com.shuwei.intelligent.shelves.dialog.CommonDialog +import com.shuwei.intelligent.shelves.model.ClearShelfEvent +import com.shuwei.intelligent.shelves.model.SendWeightEvent +import com.shuwei.intelligent.shelves.net.Loading +import com.shuwei.intelligent.shelves.serial.ProtocolConstants +import com.shuwei.intelligent.shelves.serial.ProtocolConstants.C_TEMP_CMD +import com.shuwei.intelligent.shelves.serial.ProtocolConstants.START_TEMP_CTRL_CMD +import com.shuwei.intelligent.shelves.serial.ScaleManager +import com.shuwei.intelligent.shelves.utils.GridLayoutTool +import com.shuwei.intelligent.shelves.utils.IntervalExecutor +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 {} + + override val enableRightStatusMenu: Boolean = true + + private val list: MutableList = mutableListOf() + + 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() + + 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 + val pos = list.indexOfFirst { + it.slotNo.filter { c -> c.isDigit() }.toIntOrNull() == shelfNo + } + val model = list.getOrNull(pos) ?: return@forEach + model.weight = weight.grams / 1000.0 + shelfAdapter.notifyItemChanged(pos) + log("getWeightInfo: slotNo=${model.slotNo}, realWeight=${weight.grams}, temp=${data.temperature}") + EventBus.getDefault().post(SendWeightEvent(shelfNo, weight.grams)) + } + } + .onTempReport { frame -> + log("onTempReport: $frame") + updateLeftStatus(deviceName) + } + .onLog { message -> log(message) } + .start { data -> + lastDataReceivedTime = System.currentTimeMillis() + noDataWarningDialog?.takeIf { it.isShowing }?.dismiss() + log("receiveSerialPortData: $data") + } + log("onCreate: openState=$ok") + } + } + + @SuppressLint("NotifyDataSetChanged") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityHomeV3Binding.inflate(layoutInflater) + EventBus.getDefault().register(this) + setBackground() + setContentView(binding.root) + onBackPressedDispatcher.addCallback(this) {} + + updateLeftStatus("") + + initScaleManager() + + binding.root.postDelayed({ + sendCmd(START_TEMP_CTRL_CMD) + sendCmd(C_TEMP_CMD) + }, 5000) + + saveGoodsTask() + overdueTask() + + viewModel.slots( + onLoading = { showProgress() }, + onSuccess = { record -> updateUI(record) }, + onError = { msg -> showError(msg) } + ) + } + + private fun showProgress() { + Loading.show(this) + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateUI(record: SlotRecord?) { + binding.include.root.gone() + window?.decorView?.postDelayed({ Loading.dismiss() }, 500) + + if (record == null) { + loadEmptyView() + return + } + deviceName = record.deviceName + cabinetType = record.cabinetType + cabinetId = record.cabinetId + canteenId = record.canteenId + updateLeftStatus(deviceName) + val slots = record.slots ?: mutableListOf() + if (slots.isEmpty()) { + loadEmptyView() + return + } + +// val columns = if (ProtocolConstants.device3Columns.contains(App.deviceId)) 3 else 2 +// val rows = if (slots.size % columns == 0) slots.size / columns else 5 + val columns = record.verticalCount + val rows = record.horizontalRows + log("updateUI: slots=${slots.size}, rows=$rows, columns=$columns") + + list.clear() + list.addAll(GridLayoutTool.initListSortV3(rows = rows, columns = columns, source = slots)) + + if (binding.rvShelf.adapter == null) { + initRecyclerView(columns) + } else { + 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 + + 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) + }) { 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 + list[position] = model + shelfAdapter.notifyItemChanged(position) + } + + private fun initRecyclerView(columns: Int) { + val layoutManager = GridLayoutTool.getLayoutManagerV2(this, columns = columns) + 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() + shelfAdapter.notifyDataSetChanged() + binding.include?.let { + it.root.visible() + it.root.setOnClickListener { + viewModel.slots( + onLoading = { showProgress() }, + onSuccess = { record -> updateUI(record) }, + onError = { msg -> showError(msg) } + ) + } + it.ivEmptyIcon.setImageResource(R.drawable.ic_empty_white) + it.tvEmptyContent.setTextColor(Color.WHITE) + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun clearEmptyShelf(event: ClearShelfEvent) { + val index = list.indexOfFirst { + it.slotNo?.filter { c -> c.isDigit() }?.toIntOrNull() == event.shelfNo + } + if (index < 0) 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(10 * 1000L, 2 * 60 * 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) { +// 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 = GridLayoutTool.submitListSortV3(list) + viewModel.syncWeight( + SyncWeightRequest( + cabinetType = cabinetType, + cabinetId = cabinetId, + slotList = submitList.map { + SlotWeight(slotNo = it.slotNo, weight = it.weight ?: 0.0) + } + ) + ) + } +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/InitActivity.kt b/app/src/main/java/com/sw/scalefusion/shelf/InitActivity.kt new file mode 100644 index 0000000..440fe2e --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/InitActivity.kt @@ -0,0 +1,177 @@ +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 = "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.LOCAL_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() + 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) + } +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/ShelfV3Activity.kt b/app/src/main/java/com/sw/scalefusion/shelf/ShelfV3Activity.kt new file mode 100644 index 0000000..4505848 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/ShelfV3Activity.kt @@ -0,0 +1,320 @@ +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.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.PendingInboundItem +import com.sw.scalefusion.shelf.model.PendingInboundRequest +import com.sw.scalefusion.shelf.model.PutSlotRequest +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 YYYY_MM_DD__EEEE_HH_MM_SS = "yyyy年MM月dd日 EEEE***HH:mm:ss" + } + + 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 = mutableListOf() + private val optionAdapter by lazy { + PendingInboundAdapter(optionList).apply { + setOnDebouncedItemClick { _, _, position -> + optionList.forEachIndexed { index, option -> option.isSelected = index == position } + notifyDataSetChanged() + binding.tvFoodName.text = optionList[position].materName + KeyboardUtil.hideKeyboard(this@ShelfV3Activity.window.decorView) + } + } + } + + private var cabinetType: Int = 0 + private var canteenId: Long = 0 + private var cabinetId: String = "" + private var slotNo: 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) ?: "" + + binding.tvShelfName.text = slotNo + + 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 + val weightG = model.weight ?: 0.0 + realWeight = (weightG * 1000.0).toInt() + binding.tvFoodWeight.text = if (weightG >= 1.0) "%.3f千克".format(weightG) + else "${realWeight}克" + 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 = "-" + + 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 + viewModel.put( + request = PutSlotRequest( + cabinetType = cabinetType, + cabinetId = cabinetId, + slotNo = slotNo, + materId = item.materId , + materName = item.materName, + traceCode = item.traceCode, + weight = shelfModel?.weight ?: 0.0 + ), + onLoading = { showProgress() }, + onSuccess = onSuccess@{ inboundNo -> + Loading.dismiss() +// if (inboundNo.isNullOrBlank()) { +// toast("入库失败") +// return@onSuccess +// } + val resultModel = item.toSlotModel(model).also { +// it.inboundNo = inboundNo + } + val result = Intent().apply { + putExtra(SHELF_MODEL, resultModel) + } + setResult(RESULT_OK, result) + finish() + }, + onError = { msg -> showError(msg) } + ) + } + } + + private fun loadList() { + viewModel.pendingInbound( + request = PendingInboundRequest( + cabinetType = shelfModel?.cabinetType ?: 0, + cabinetId = shelfModel?.cabinetId ?: "", + canteenId = canteenId + ), + onLoading = { showProgress() }, + onSuccess = { items -> updateOptionList(items) }, + onError = { msg -> showError(msg) } + ) + } + + private fun showProgress() { + Loading.show(this) + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateOptionList(items: List?) { + 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 { + 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 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 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") + binding.tvFoodWeight.text = if (weight < 1000) "${weight}克" + else "%.3f千克".format(weight / 1000.0) + startTime = System.currentTimeMillis() + window.decorView.postDelayed({ Loading.dismiss() }, 1000) + } +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/adapter/PendingInboundAdapter.kt b/app/src/main/java/com/sw/scalefusion/shelf/adapter/PendingInboundAdapter.kt new file mode 100644 index 0000000..ba993e3 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/adapter/PendingInboundAdapter.kt @@ -0,0 +1,39 @@ +package com.sw.scalefusion.shelf.adapter + +import android.content.Context +import android.content.res.ColorStateList +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.toColorInt +import com.chad.library.adapter4.BaseQuickAdapter +import com.chad.library.adapter4.viewholder.QuickViewHolder +import com.shuwei.intelligent.shelves.R +import com.shuwei.intelligent.shelves.databinding.ListItemSearchBinding +import com.sw.scalefusion.shelf.model.PendingInboundItem + +class PendingInboundAdapter(list: MutableList) : + BaseQuickAdapter(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: PendingInboundItem?) { + item ?: return + val isSelected = item.isSelected + holder.binding.btnFoodName.run { + text = item.materName + setTextColor(getColor(if (isSelected) R.color.shelf_name_blue else R.color.black999)) + strokeColor = ColorStateList.valueOf( + (if (isSelected) "#4969F5" else "#E6E6E6").toColorInt() + ) + } + } + + private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id) +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/adapter/ShelfV3Adapter.kt b/app/src/main/java/com/sw/scalefusion/shelf/adapter/ShelfV3Adapter.kt new file mode 100644 index 0000000..353e287 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/adapter/ShelfV3Adapter.kt @@ -0,0 +1,71 @@ +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.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) : + BaseQuickAdapter(list) { + + 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 + val binding = holder.binding + + // 格口编号 + binding.tvShelfName.text = item.slotNo.padStart(2, '0') + +// // 临期预警(slotStatus=2)显示过期图标 +// if (item.slotStatus.toInt() == 2) binding.ivStaleFood.visible() +// else binding.ivStaleFood.gone() + + val weightG = item.weight ?: 0.0 + binding.tvFoodWeight.run { + setTextColor(getColor(R.color.food_weight_orange)) + text = if (abs(weightG) >= 1.0) "%.3f千克".format(weightG) + else "${(weightG * 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 = "-" + } + 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 + } + } + + private fun getColor(@ColorRes id: Int) = ContextCompat.getColor(context, id) +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/model/CommonRequest.kt b/app/src/main/java/com/sw/scalefusion/shelf/model/CommonRequest.kt new file mode 100644 index 0000000..00fc421 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/model/CommonRequest.kt @@ -0,0 +1,23 @@ +package com.sw.scalefusion.shelf.model + +data class PendingInboundRequest( + var cabinetType: Int, + var canteenId: Long, + var cabinetId: String +) + +data class PutSlotRequest( + val cabinetType: Int, + val cabinetId: String, + val slotNo: String, + val materId: Long, + val materName: String, + val traceCode: String? = null, + val weight: Double +) + +data class SlotDetailRequest( + var cabinetType: Int, + var cabinetId: String, + var slotNo: String +) diff --git a/app/src/main/java/com/sw/scalefusion/shelf/model/PendingInboundItem.kt b/app/src/main/java/com/sw/scalefusion/shelf/model/PendingInboundItem.kt new file mode 100644 index 0000000..4cc89f7 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/model/PendingInboundItem.kt @@ -0,0 +1,32 @@ +package com.sw.scalefusion.shelf.model + +import com.shuwei.intelligent.shelves.utils.DateTimeUtil +import java.util.Date + +/** + * /pending-inbound 接口响应 — 待入柜物料 + */ +data class PendingInboundItem( + val id: Long = 0, + val materId: Long = 0, + val materName: String = "", + val traceCode: String? = null, + val weight: Double = 0.0, + val inboundNo: String = "", + val inboundTime: String? = null, + val vegTypeId:Int, + val vegTypeName:String, + val spec:String, + 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 + ) +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/model/SlotModel.kt b/app/src/main/java/com/sw/scalefusion/shelf/model/SlotModel.kt new file mode 100644 index 0000000..48f36a3 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/model/SlotModel.kt @@ -0,0 +1,22 @@ +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, + val vegTypeId: String? = null, + //weight单位千克 + var weight: Double? = 0.0, + var storeTime: String? = null, +): Serializable diff --git a/app/src/main/java/com/sw/scalefusion/shelf/model/SlotRecord.kt b/app/src/main/java/com/sw/scalefusion/shelf/model/SlotRecord.kt new file mode 100644 index 0000000..480c983 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/model/SlotRecord.kt @@ -0,0 +1,12 @@ +package com.sw.scalefusion.shelf.model + +data class SlotRecord( + val cabinetType: Int, + val cabinetId: String, + val deviceName: String, + val canteenId: Long, + val canteenName: String, + val horizontalRows: Int, + val verticalCount: Int, + val slots: List? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/sw/scalefusion/shelf/model/SyncWeightRequest.kt b/app/src/main/java/com/sw/scalefusion/shelf/model/SyncWeightRequest.kt new file mode 100644 index 0000000..d553f53 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/model/SyncWeightRequest.kt @@ -0,0 +1,15 @@ +package com.sw.scalefusion.shelf.model + +/** + * /sync-weight 接口请求体 + */ +data class SyncWeightRequest( + val cabinetType: Int, + val cabinetId: String, + val slotList: List +) + +data class SlotWeight( + val slotNo: String, + val weight: Double +) diff --git a/app/src/main/java/com/sw/scalefusion/shelf/net/ApiResponse.kt b/app/src/main/java/com/sw/scalefusion/shelf/net/ApiResponse.kt new file mode 100644 index 0000000..95cc9b1 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/net/ApiResponse.kt @@ -0,0 +1,9 @@ +package com.sw.scalefusion.shelf.net + +data class ApiResponse( + val code: String, + val msg: String? = "", + val data: T? = null +) { + fun isSuccess(): Boolean = ("00000" == code) +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/scalefusion/shelf/net/ApiServiceV3.kt b/app/src/main/java/com/sw/scalefusion/shelf/net/ApiServiceV3.kt new file mode 100644 index 0000000..65aa6f8 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/net/ApiServiceV3.kt @@ -0,0 +1,37 @@ +package com.sw.scalefusion.shelf.net + +import com.shuwei.intelligent.shelves.App +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.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 + + /** 待入柜列表 */ + @POST("/nutrition/neglect/mater-cabinet/pending-inbound") + suspend fun pendingInbound(@Body request: PendingInboundRequest): ApiResponse?> + + /** 全量同步重量 */ + @POST("/nutrition/neglect/mater-cabinet/sync-weight") + suspend fun syncWeight(@Body request: SyncWeightRequest): ApiResponse + + /** 格口入柜 */ + @POST("/nutrition/neglect/mater-cabinet/put") + suspend fun put(@Body request: PutSlotRequest): ApiResponse + + /** 格口详情 */ + @POST("/nutrition/neglect/mater-cabinet/slot-detail") + suspend fun slotDetail(@Body request: SlotDetailRequest): ApiResponse +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/net/BaseRepository.kt b/app/src/main/java/com/sw/scalefusion/shelf/net/BaseRepository.kt new file mode 100644 index 0000000..ab73d58 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/net/BaseRepository.kt @@ -0,0 +1,48 @@ +package com.sw.scalefusion.shelf.net + +import android.util.Log +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 safeApiCall(apiCall: suspend () -> ApiResponse): ApiResponse { + return try { + apiCall() + } catch (e: Exception) { + Log.e("BaseRepository","safeApiCall Exception: ${e.stackTraceToString()}") + when (e) { + is HttpException -> { + ApiResponse(code = "${e.code()}", msg = 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 ?: "无错误信息"}") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sw/scalefusion/shelf/net/NetViewModelV3.kt b/app/src/main/java/com/sw/scalefusion/shelf/net/NetViewModelV3.kt new file mode 100644 index 0000000..b5387d3 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/net/NetViewModelV3.kt @@ -0,0 +1,83 @@ +package com.sw.scalefusion.shelf.net + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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?) -> Unit, + onError: (String) -> Unit = {} + ) { + viewModelScope.launch { + onLoading() + val response = repository.pendingInbound(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 ?: "请求失败") + } + } +} diff --git a/app/src/main/java/com/sw/scalefusion/shelf/net/ShelfRepository.kt b/app/src/main/java/com/sw/scalefusion/shelf/net/ShelfRepository.kt new file mode 100644 index 0000000..6f9d420 --- /dev/null +++ b/app/src/main/java/com/sw/scalefusion/shelf/net/ShelfRepository.kt @@ -0,0 +1,34 @@ +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.PendingInboundItem +import com.sw.scalefusion.shelf.model.PendingInboundRequest +import com.sw.scalefusion.shelf.model.PutSlotRequest +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 = + safeApiCall { apiServiceV3.slots(cabinetId) } + + /** 待入柜列表 */ + suspend fun pendingInbound(request: PendingInboundRequest): ApiResponse?> = + safeApiCall { apiServiceV3.pendingInbound(request) } + + /** 全量同步重量 */ + suspend fun syncWeight(request: SyncWeightRequest): ApiResponse = + safeApiCall { apiServiceV3.syncWeight(request) } + + /** 格口入柜 */ + suspend fun put(request: PutSlotRequest): ApiResponse = + safeApiCall { apiServiceV3.put(request) } + + /** 格口详情 */ + suspend fun slotDetail(request: SlotDetailRequest): ApiResponse = + safeApiCall { apiServiceV3.slotDetail(request) } +} diff --git a/app/src/main/res/layout/activity_home_v3.xml b/app/src/main/res/layout/activity_home_v3.xml new file mode 100644 index 0000000..f2120e4 --- /dev/null +++ b/app/src/main/res/layout/activity_home_v3.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_shelf_v3.xml b/app/src/main/res/layout/activity_shelf_v3.xml new file mode 100644 index 0000000..44997d9 --- /dev/null +++ b/app/src/main/res/layout/activity_shelf_v3.xml @@ -0,0 +1,272 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_item_shelf_v3.xml b/app/src/main/res/layout/list_item_shelf_v3.xml new file mode 100644 index 0000000..a62e90f --- /dev/null +++ b/app/src/main/res/layout/list_item_shelf_v3.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test_list_transform.kt b/test_list_transform.kt new file mode 100644 index 0000000..24979ec --- /dev/null +++ b/test_list_transform.kt @@ -0,0 +1,76 @@ +// 测试列表转换逻辑 +data class TestModel(var deviceId: String = "", val value: Int) + +fun testTransform(tempList: List): List { + val list = mutableListOf() + 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") +} diff --git a/货柜设备端-API文档.md b/货柜设备端-API文档.md new file mode 100644 index 0000000..e390bef --- /dev/null +++ b/货柜设备端-API文档.md @@ -0,0 +1,241 @@ +# 货柜设备端 — 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. **格口结构**:一个格口即一条数据库记录,响应为扁平对象,无嵌套