From 97a3054db206dd2d6bc675b58644f603e861ec73 Mon Sep 17 00:00:00 2001 From: dongliang Date: Tue, 28 Apr 2026 20:57:17 +0930 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=80=83=E5=8B=A4=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=88NFC=EF=BC=89=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于新方法论(11层源码分析+独立评审)重新开发考勤打卡功能: - 首页下拉展开打卡面板,点击按钮开启NFC贴卡打卡 - 支持上班打卡、下班打卡、撤销打卡 - NFC超时自动关闭,音效反馈(4种planId) - MQTT type=5 上下班状态推送处理 - 按钮状态矩阵:onPunchState×offPunchState决定显示 新增: AttendanceStatus / PunchApi / PunchViewModel / PunchPanelView 修改: NetworkModule / HomeFragment / fragment_home.xml 删除: AttendanceInfo(被AttendanceStatus替代) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../watch/data/punch/AttendanceStatus.kt | 20 ++ .../xiaoqu/watch/data/task/AttendanceInfo.kt | 14 - .../java/com/xiaoqu/watch/di/NetworkModule.kt | 7 + .../com/xiaoqu/watch/network/api/PunchApi.kt | 25 ++ .../com/xiaoqu/watch/network/api/TaskApi.kt | 5 - .../com/xiaoqu/watch/ui/home/HomeFragment.kt | 202 ++++++++++++- .../xiaoqu/watch/ui/punch/PunchPanelView.kt | 178 +++++++++++ .../xiaoqu/watch/ui/punch/PunchViewModel.kt | 277 ++++++++++++++++++ app/src/main/res/drawable/bg_btn_primary.xml | 16 + .../main/res/drawable/bg_btn_secondary.xml | 16 + app/src/main/res/layout/fragment_home.xml | 56 ++-- app/src/main/res/layout/view_punch_panel.xml | 114 +++++++ app/src/main/res/navigation/nav_main.xml | 6 - 13 files changed, 881 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/xiaoqu/watch/data/punch/AttendanceStatus.kt delete mode 100644 app/src/main/java/com/xiaoqu/watch/data/task/AttendanceInfo.kt create mode 100644 app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt create mode 100644 app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt create mode 100644 app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt create mode 100644 app/src/main/res/drawable/bg_btn_primary.xml create mode 100644 app/src/main/res/drawable/bg_btn_secondary.xml create mode 100644 app/src/main/res/layout/view_punch_panel.xml diff --git a/app/src/main/java/com/xiaoqu/watch/data/punch/AttendanceStatus.kt b/app/src/main/java/com/xiaoqu/watch/data/punch/AttendanceStatus.kt new file mode 100644 index 0000000..7a4a365 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/data/punch/AttendanceStatus.kt @@ -0,0 +1,20 @@ +package com.xiaoqu.watch.data.punch + +/** + * 考勤状态(对应 GET /watchTask/myCurrentAttendance 响应) + * + * 按钮显示规则: + * - onPunchState=0 → 显示"上班打卡" + * - onPunchState=1, offPunchState=0 → 显示"下班打卡" + * - onPunchState=1, offPunchState=1 → 显示"撤销打卡" + "下班打卡" + */ +data class AttendanceStatus( + /** 上班打卡状态: 0=未打卡, 1=已打卡 */ + val onPunchState: Int = 0, + /** 下班打卡状态: 0=未打卡, 1=已打卡 */ + val offPunchState: Int = 0, + /** 上班打卡时间 */ + val actualOnTime: String? = null, + /** 下班打卡时间 */ + val actualOffTime: String? = null +) diff --git a/app/src/main/java/com/xiaoqu/watch/data/task/AttendanceInfo.kt b/app/src/main/java/com/xiaoqu/watch/data/task/AttendanceInfo.kt deleted file mode 100644 index a4f2664..0000000 --- a/app/src/main/java/com/xiaoqu/watch/data/task/AttendanceInfo.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.xiaoqu.watch.data.task - -import com.google.gson.annotations.SerializedName - -/** - * 考勤状态信息 - * 对应 watchTask/myCurrentAttendance API 返回 - */ -data class AttendanceInfo( - /** 工作状态 (0=工作中) */ - @SerializedName("workStatus") val workStatus: Int = 0, - /** 考勤状态 (3=未上班) */ - @SerializedName("workAtStatus") val workAtStatus: Int = 0 -) diff --git a/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt b/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt index 49e5471..bb37484 100644 --- a/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt +++ b/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt @@ -6,6 +6,7 @@ import com.xiaoqu.watch.network.EnvConfig import com.xiaoqu.watch.network.SignatureInterceptor import com.xiaoqu.watch.network.UnbindInterceptor import com.xiaoqu.watch.network.api.CommonApi +import com.xiaoqu.watch.network.api.PunchApi import com.xiaoqu.watch.network.api.TaskApi import dagger.Module import dagger.Provides @@ -69,4 +70,10 @@ object NetworkModule { fun provideTaskApi(retrofit: Retrofit): TaskApi { return retrofit.create(TaskApi::class.java) } + + @Provides + @Singleton + fun providePunchApi(retrofit: Retrofit): PunchApi { + return retrofit.create(PunchApi::class.java) + } } diff --git a/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt b/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt new file mode 100644 index 0000000..dbc6bb8 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt @@ -0,0 +1,25 @@ +package com.xiaoqu.watch.network.api + +import com.xiaoqu.watch.data.punch.AttendanceStatus +import com.xiaoqu.watch.network.ApiResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * 考勤打卡 API 接口 + */ +interface PunchApi { + + /** 查询当前考勤状态 */ + @GET("watchTask/myCurrentAttendance") + suspend fun getAttendance(): ApiResponse + + /** NFC 考勤打卡(上班/下班) */ + @POST("watchTask/nfcOnAndOffPunch") + suspend fun nfcPunch(@Body params: HashMap): ApiResponse + + /** 撤销打卡 */ + @POST("watchTask/revokePunch") + suspend fun revokePunch(@Body params: HashMap): ApiResponse +} diff --git a/app/src/main/java/com/xiaoqu/watch/network/api/TaskApi.kt b/app/src/main/java/com/xiaoqu/watch/network/api/TaskApi.kt index 100de6b..74d4b80 100644 --- a/app/src/main/java/com/xiaoqu/watch/network/api/TaskApi.kt +++ b/app/src/main/java/com/xiaoqu/watch/network/api/TaskApi.kt @@ -1,6 +1,5 @@ package com.xiaoqu.watch.network.api -import com.xiaoqu.watch.data.task.AttendanceInfo import com.xiaoqu.watch.data.task.TaskDetail import com.xiaoqu.watch.data.task.TaskItem import com.xiaoqu.watch.data.task.TaskStatistics @@ -19,10 +18,6 @@ interface TaskApi { @GET("watchTask/statisticsNew") suspend fun getStatistics(): ApiResponse - /** 查询当前考勤状态 */ - @GET("watchTask/myCurrentAttendance") - suspend fun getAttendance(): ApiResponse - /** 获取任务列表(按状态筛选) */ @POST("watchTask/queryTaskIds") suspend fun getTaskIds(@Body params: HashMap): ApiResponse> diff --git a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt index 2746f7f..6412603 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt @@ -1,12 +1,18 @@ package com.xiaoqu.watch.ui.home import android.os.Bundle +import android.view.GestureDetector import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.TextView +import android.widget.Toast import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.xiaoqu.watch.BuildConfig import com.xiaoqu.watch.R @@ -19,6 +25,10 @@ import com.xiaoqu.watch.network.ApiResult import com.xiaoqu.watch.network.api.TaskApi import com.xiaoqu.watch.network.safeApiCall import com.xiaoqu.watch.ui.common.BaseFragment +import com.xiaoqu.watch.ui.punch.PunchPanelView +import com.xiaoqu.watch.ui.punch.PunchResult +import com.xiaoqu.watch.ui.punch.PunchViewModel +import com.xiaoqu.watch.ui.widget.QuConfirmDialog import com.xiaoqu.watch.ui.widget.StatusBarView import com.xiaoqu.watch.util.DateUtil import dagger.hilt.android.AndroidEntryPoint @@ -29,8 +39,9 @@ import timber.log.Timber import javax.inject.Inject /** - * 首页 Fragment(ViewPager2 容器) + * 首页 Fragment(ViewPager2 容器 + 考勤打卡面板) * Page 0 = 设置页,Page 1 = 主页(默认显示) + * 下拉手势展开打卡面板 */ @AndroidEntryPoint class HomeFragment : BaseFragment() { @@ -40,9 +51,16 @@ class HomeFragment : BaseFragment() { @Inject lateinit var eventBus: EventBus @Inject lateinit var taskApi: TaskApi + /** 考勤打卡 ViewModel */ + private val punchViewModel: PunchViewModel by viewModels() + // ===== 固定状态栏(不随 ViewPager 滑动) ===== private lateinit var statusBar: StatusBarView + // ===== 打卡面板 ===== + private lateinit var punchPanel: PunchPanelView + private var confirmDialog: QuConfirmDialog? = null + // ===== 主页 View 引用 ===== private lateinit var tvClock: TextView private lateinit var tvDate: TextView @@ -59,6 +77,9 @@ class HomeFragment : BaseFragment() { private var debugTapCount = 0 private var lastTapTime = 0L + // ===== 下拉手势检测 ===== + private lateinit var gestureDetector: GestureDetector + override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding { return FragmentHomeBinding.inflate(inflater, container, false) } @@ -66,9 +87,12 @@ class HomeFragment : BaseFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 绑定固定状态栏(不随 ViewPager 滑动) + // 绑定固定状态栏 statusBar = binding.statusBar + // 初始化打卡面板 + initPunchPanel() + // 创建两个页面 View val inflater = LayoutInflater.from(requireContext()) val configPage = inflater.inflate(R.layout.page_config, null) @@ -81,12 +105,15 @@ class HomeFragment : BaseFragment() { // 设置 ViewPager2 val adapter = HomePagerAdapter(listOf(configPage, mainPage)) binding.viewPager.adapter = adapter - binding.viewPager.setCurrentItem(1, false) // 默认显示主页 + binding.viewPager.setCurrentItem(1, false) // 初始化页面数据 initMainPage() initConfigPage() + // 初始化下拉手势 + initGestureDetector() + // 启动时钟定时器 startClockUpdater() @@ -95,6 +122,130 @@ class HomeFragment : BaseFragment() { // 监听 MQTT 事件 observeEvents() + + // 监听打卡状态 + observePunchState() + } + + // ===== 打卡面板 ===== + + /** 初始化打卡面板 */ + private fun initPunchPanel() { + punchPanel = binding.punchPanel + confirmDialog = QuConfirmDialog(binding.dialogContainer) + + // 上班打卡 + punchPanel.onPunchInClick = { + punchViewModel.startPunch(0) + } + + // 下班打卡 + punchPanel.onPunchOutClick = { + punchViewModel.startPunch(1) + } + + // 撤销打卡 + punchPanel.onRevokeClick = { + // 弹出确认弹窗 + showRevokeConfirm() + } + + // 面板关闭时恢复 ViewPager2 滑动 + punchPanel.onDismiss = { + binding.viewPager.isUserInputEnabled = true + } + } + + /** 显示撤销打卡确认弹窗 */ + private fun showRevokeConfirm() { + confirmDialog?.showText( + text = "确定撤销打卡?", + onConfirm = { + punchViewModel.revokePunch() + } + ) + } + + /** 监听打卡状态变化 */ + private fun observePunchState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + punchViewModel.uiState.collect { state -> + // 更新面板按钮 + punchPanel.updateButtons(state) + + // 处理打卡结果(一次性事件) + state.punchResult?.let { result -> + punchViewModel.consumePunchResult() + when (result) { + PunchResult.SUCCESS -> { + Toast.makeText(requireContext(), "打卡成功", Toast.LENGTH_SHORT).show() + // 延迟收回面板 + viewLifecycleOwner.lifecycleScope.launch { + delay(1000) + punchPanel.dismiss() + } + // 刷新首页统计 + fetchStatistics() + } + PunchResult.REVOKE_SUCCESS -> { + Toast.makeText(requireContext(), "撤销成功", Toast.LENGTH_SHORT).show() + } + PunchResult.FAIL -> { + Toast.makeText( + requireContext(), + state.errorMessage ?: "操作失败", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + } + } + + /** 展开打卡面板 */ + private fun showPunchPanel() { + if (punchPanel.isShowing) return + // 禁用 ViewPager2 滑动 + binding.viewPager.isUserInputEnabled = false + // 查询考勤状态 + punchViewModel.fetchAttendance() + // 展开面板 + punchPanel.show() + } + + // ===== 下拉手势 ===== + + /** 初始化下拉手势检测器 */ + private fun initGestureDetector() { + gestureDetector = GestureDetector(requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + // 只在主页(page=1)且面板未展开时响应下拉 + if (binding.viewPager.currentItem != 1) return false + if (punchPanel.isShowing) return false + + // 下拉:velocityY > 0 且垂直速度大于水平速度 + if (velocityY > 500 && Math.abs(velocityY) > Math.abs(velocityX)) { + showPunchPanel() + return true + } + return false + } + }) + + // 在 ViewPager2 上设置触摸监听 + binding.viewPager.getChildAt(0)?.setOnTouchListener { _, event -> + gestureDetector.onTouchEvent(event) + false // 不消费事件,让 ViewPager2 正常处理 + } } // ===== 主页 ===== @@ -129,6 +280,10 @@ class HomeFragment : BaseFragment() { val info = DateUtil.getDateInfo() tvClock.text = DateUtil.formatTimeShort() tvDate.text = "${info.month}月${info.day}日 ${info.week}" + // 同步更新打卡面板时钟 + if (punchPanel.isShowing) { + punchPanel.updateClock() + } } /** 每秒更新时钟 */ @@ -172,7 +327,7 @@ class HomeFragment : BaseFragment() { } } - /** 初始化设置页数据(直接用 inflate 好的 configPageView) */ + /** 初始化设置页数据 */ private fun initConfigPage() { // 用户信息 val userName = userPrefs.userName @@ -186,7 +341,7 @@ class HomeFragment : BaseFragment() { mobile } - // 设备信息(直接操作已 inflate 的 View,不依赖 ViewPager2 的 ViewHolder) + // 设备信息 configPageView.findViewById(R.id.tvModel)?.text = "${devicePrefs.brand} ${devicePrefs.model}" configPageView.findViewById(R.id.tvOsVersion)?.text = @@ -213,8 +368,7 @@ class HomeFragment : BaseFragment() { if (debugTapCount >= 6) { debugTapCount = 0 Timber.d("调试模式已开启") - android.widget.Toast.makeText(requireContext(), "调试模式已开启", android.widget.Toast.LENGTH_SHORT).show() - // TODO: 打开调试页面 + Toast.makeText(requireContext(), "调试模式已开启", Toast.LENGTH_SHORT).show() } } @@ -225,7 +379,7 @@ class HomeFragment : BaseFragment() { viewLifecycleOwner.lifecycleScope.launch { eventBus.events.collect { event -> when (event) { - // 电量变化:更新固定状态栏 + // 电量变化 is AppEvent.BatteryChanged -> { statusBar.updateBattery(event.level, event.isCharging) } @@ -248,9 +402,15 @@ class HomeFragment : BaseFragment() { findNavController().navigate(R.id.action_home_to_bind) } 4 -> { - // 工作状态变更 + // 工作状态变更(上下班) Timber.d("首页: 收到工作状态变更") - // TODO: 考勤模块重新开发时实现 + // TODO: 解析 rawJson 中的 action 字段 + // 暂时使用事件触发 + } + 5 -> { + // 上下班状态推送 + Timber.d("首页: 收到上下班状态推送") + handleMqttWorkState(event.rawJson) } } } @@ -260,14 +420,32 @@ class HomeFragment : BaseFragment() { } } + /** + * 处理 MQTT type=5 上下班状态推送 + * rawJson 格式: {"messageType":5, "action":0/1, ...} + * action=0 → 下班(屏幕变暗) + * action=1 → 上班(屏幕变亮) + */ + private fun handleMqttWorkState(rawJson: String) { + try { + val json = org.json.JSONObject(rawJson) + val action = json.optInt("action", -1) + if (action == 0 || action == 1) { + val isWorking = action == 1 + punchViewModel.handleWorkStateChange(isWorking) + } + } catch (e: Exception) { + Timber.w(e, "解析 MQTT 工作状态消息失败") + } + } + /** 跳转到任务列表(传 tableStatus 参数) */ private fun navigateToTaskList(tableStatus: Int) { - // 防止重复导航:只有当前在 homeFragment 时才跳转 + // 防止重复导航 val currentDest = findNavController().currentDestination?.id if (currentDest != R.id.homeFragment) return val bundle = bundleOf("tableStatus" to tableStatus) findNavController().navigate(R.id.action_home_to_taskList, bundle) } - } diff --git a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt new file mode 100644 index 0000000..02ffca5 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt @@ -0,0 +1,178 @@ +package com.xiaoqu.watch.ui.punch + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.animation.DecelerateInterpolator +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.xiaoqu.watch.R +import com.xiaoqu.watch.util.DateUtil + +/** + * 考勤打卡面板(嵌入首页,覆盖在 ViewPager2 上方) + * + * 交互方式: + * - 调用 show() 展开面板 + * - 点击遮罩区域收回 + * - 打卡完成后自动收回 + * + * 按钮显示规则(来自源码分析,已评审修正): + * - onPunchState=0 → "上班打卡" + * - onPunchState=1, offPunchState=0 → "下班打卡" + * - onPunchState=1, offPunchState=1 → "撤销打卡" + "下班打卡" + */ +class PunchPanelView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + // 面板内部 View 引用 + private val overlay: View + private val panelContent: LinearLayout + private val tvPunchTime: TextView + private val tvPunchDate: TextView + private val tvNfcHint: TextView + private val btnPunchIn: TextView + private val btnPunchOut: TextView + private val btnRevoke: TextView + + /** 面板是否正在显示 */ + var isShowing = false + private set + + /** 按钮点击回调 */ + var onPunchInClick: (() -> Unit)? = null + var onPunchOutClick: (() -> Unit)? = null + var onRevokeClick: (() -> Unit)? = null + /** 面板关闭回调(用于通知 HomeFragment 恢复 ViewPager2 滑动) */ + var onDismiss: (() -> Unit)? = null + + init { + // 加载布局 + LayoutInflater.from(context).inflate(R.layout.view_punch_panel, this, true) + + // 默认隐藏 + visibility = GONE + + // 绑定 View + overlay = findViewById(R.id.overlay) + panelContent = findViewById(R.id.panelContent) + tvPunchTime = findViewById(R.id.tvPunchTime) + tvPunchDate = findViewById(R.id.tvPunchDate) + tvNfcHint = findViewById(R.id.tvNfcHint) + btnPunchIn = findViewById(R.id.btnPunchIn) + btnPunchOut = findViewById(R.id.btnPunchOut) + btnRevoke = findViewById(R.id.btnRevoke) + + // 点击遮罩收回面板 + overlay.setOnClickListener { dismiss() } + + // 按钮点击 + btnPunchIn.setOnClickListener { onPunchInClick?.invoke() } + btnPunchOut.setOnClickListener { onPunchOutClick?.invoke() } + btnRevoke.setOnClickListener { onRevokeClick?.invoke() } + } + + /** 展开面板 */ + fun show() { + if (isShowing) return + isShowing = true + + // 更新时间 + updateClock() + + // 显示并播放动画 + visibility = VISIBLE + panelContent.translationY = -panelContent.height.toFloat().coerceAtLeast(300f) + panelContent.animate() + .translationY(0f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()) + .start() + + overlay.alpha = 0f + overlay.animate() + .alpha(1f) + .setDuration(200) + .start() + } + + /** 收回面板 */ + fun dismiss() { + if (!isShowing) return + isShowing = false + + // 收回动画 + panelContent.animate() + .translationY(-panelContent.height.toFloat().coerceAtLeast(300f)) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()) + .withEndAction { + visibility = GONE + tvNfcHint.visibility = GONE + onDismiss?.invoke() + } + .start() + + overlay.animate() + .alpha(0f) + .setDuration(200) + .start() + } + + /** + * 更新按钮显示状态 + * @param state 考勤状态 + */ + fun updateButtons(state: PunchUiState) { + val onState = state.onPunchState + val offState = state.offPunchState + + // 重置所有按钮 + btnPunchIn.visibility = GONE + btnPunchOut.visibility = GONE + btnRevoke.visibility = GONE + + when { + // 未上班打卡 → 显示"上班打卡" + onState == 0 -> { + btnPunchIn.visibility = VISIBLE + } + // 已上班,已下班 → 显示"撤销打卡" + "下班打卡" + onState == 1 && offState == 1 -> { + btnRevoke.visibility = VISIBLE + btnPunchOut.visibility = VISIBLE + } + // 已上班,未下班 → 仅"下班打卡" + onState == 1 && offState == 0 -> { + btnPunchOut.visibility = VISIBLE + } + } + + // NFC 扫描中 → 显示提示 + if (state.isNfcScanning) { + tvNfcHint.visibility = VISIBLE + // 扫描中禁用按钮 + btnPunchIn.isEnabled = false + btnPunchOut.isEnabled = false + btnRevoke.isEnabled = false + } else { + tvNfcHint.visibility = GONE + btnPunchIn.isEnabled = true + btnPunchOut.isEnabled = true + btnRevoke.isEnabled = true + } + } + + /** 更新时钟显示 */ + fun updateClock() { + val info = DateUtil.getDateInfo() + tvPunchTime.text = DateUtil.formatTimeShort() + tvPunchDate.text = "${info.month}月${info.day}日 ${info.week}" + } +} diff --git a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt new file mode 100644 index 0000000..6f29baf --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt @@ -0,0 +1,277 @@ +package com.xiaoqu.watch.ui.punch + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.xiaoqu.watch.device.nfc.NfcController +import com.xiaoqu.watch.device.screen.ScreenController +import com.xiaoqu.watch.device.sensor.VibrationController +import com.xiaoqu.watch.device.sensor.VibrationDefaults +import com.xiaoqu.watch.network.ApiResult +import com.xiaoqu.watch.network.api.PunchApi +import com.xiaoqu.watch.network.safeApiCall +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * 考勤打卡 ViewModel + * 管理 NFC 打卡流程、考勤状态查询、撤销打卡 + */ +@HiltViewModel +class PunchViewModel @Inject constructor( + private val punchApi: PunchApi, + private val nfcController: NfcController, + private val vibrationController: VibrationController, + private val screenController: ScreenController +) : ViewModel() { + + companion object { + /** NFC 自动关闭超时(毫秒),可被服务端下发覆盖 */ + private const val DEFAULT_NFC_TIMEOUT_MS = 10_000L + /** planId 常量 */ + private const val PLAN_PUNCH_SUCCESS = 4 + private const val PLAN_PUNCH_FAIL = 7 + private const val PLAN_NFC_OPEN = 8 + private const val PLAN_NFC_CLOSE = 9 + } + + /** NFC 超时时间(毫秒),可通过 MQTT type=4 更新 */ + var nfcTimeoutMs: Long = DEFAULT_NFC_TIMEOUT_MS + + private val _uiState = MutableStateFlow(PunchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** NFC 超时协程 */ + private var nfcTimeoutJob: Job? = null + + /** 查询当前考勤状态 */ + fun fetchAttendance() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val result = safeApiCall { punchApi.getAttendance() } + when (result) { + is ApiResult.Success -> { + val data = result.data + if (data != null) { + _uiState.update { + it.copy( + isLoading = false, + onPunchState = data.onPunchState, + offPunchState = data.offPunchState, + actualOnTime = data.actualOnTime, + actualOffTime = data.actualOffTime + ) + } + } else { + _uiState.update { it.copy(isLoading = false) } + } + } + is ApiResult.Error -> { + Timber.w("查询考勤状态失败: ${result.message}") + _uiState.update { it.copy(isLoading = false) } + } + is ApiResult.NetworkError -> { + Timber.w(result.exception, "查询考勤状态网络异常") + _uiState.update { it.copy(isLoading = false) } + } + } + } + } + + /** + * 开启 NFC 开始打卡 + * @param punchType 0=上班打卡, 1=下班打卡 + */ + fun startPunch(punchType: Int) { + // 防重复触发 + if (_uiState.value.isNfcScanning) return + + Timber.d("考勤: 开始NFC打卡, punchType=$punchType") + _uiState.update { it.copy(isNfcScanning = true, scanningPunchType = punchType) } + + // 1. 开启 NFC + 音效 + nfcController.open() + playFeedback(PLAN_NFC_OPEN) + + // 2. 开始扫描,读到卡号后回调 + nfcController.startScan { nfcId -> + Timber.d("考勤: NFC读到卡号 $nfcId") + nfcTimeoutJob?.cancel() + handleNfcResult(nfcId, punchType) + } + + // 3. 超时自动关闭 + nfcTimeoutJob = viewModelScope.launch { + delay(nfcTimeoutMs) + Timber.d("考勤: NFC超时自动关闭") + closeNfc() + _uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1) } + } + } + + /** 取消 NFC 扫描(用户手动取消) */ + fun cancelNfcScan() { + if (!_uiState.value.isNfcScanning) return + Timber.d("考勤: 手动取消NFC扫描") + nfcTimeoutJob?.cancel() + closeNfc() + _uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1) } + } + + /** NFC 读到卡号后处理 */ + private fun handleNfcResult(nfcId: String, punchType: Int) { + // 关闭 NFC + closeNfc() + + // 调用打卡 API + viewModelScope.launch { + _uiState.update { it.copy(isNfcScanning = false) } + + val params = hashMapOf( + "nfcId" to nfcId, + "punchType" to punchType + ) + val result = safeApiCall { punchApi.nfcPunch(params) } + + when (result) { + is ApiResult.Success -> { + Timber.d("考勤: 打卡成功") + playFeedback(PLAN_PUNCH_SUCCESS) + // 刷新考勤状态 + fetchAttendance() + // 通知打卡成功(UI 用于收回面板等) + _uiState.update { + it.copy(punchResult = PunchResult.SUCCESS, scanningPunchType = -1) + } + // 屏幕亮度:上班亮、下班暗 + if (punchType == 1) { + screenController.turnOff() + } else { + screenController.turnOn() + } + } + is ApiResult.Error -> { + Timber.w("考勤: 打卡失败 - ${result.message}") + playFeedback(PLAN_PUNCH_FAIL) + _uiState.update { + it.copy( + punchResult = PunchResult.FAIL, + errorMessage = result.message, + scanningPunchType = -1 + ) + } + } + is ApiResult.NetworkError -> { + Timber.w(result.exception, "考勤: 打卡网络异常") + playFeedback(PLAN_PUNCH_FAIL) + _uiState.update { + it.copy( + punchResult = PunchResult.FAIL, + errorMessage = "网络异常", + scanningPunchType = -1 + ) + } + } + } + } + } + + /** 撤销打卡 */ + fun revokePunch() { + viewModelScope.launch { + val result = safeApiCall { punchApi.revokePunch(hashMapOf()) } + when (result) { + is ApiResult.Success -> { + Timber.d("考勤: 撤销打卡成功") + playFeedback(PLAN_PUNCH_SUCCESS) + // 刷新状态 + fetchAttendance() + // 恢复屏幕亮度 + screenController.turnOn() + _uiState.update { it.copy(punchResult = PunchResult.REVOKE_SUCCESS) } + } + is ApiResult.Error -> { + Timber.w("考勤: 撤销失败 - ${result.message}") + _uiState.update { + it.copy(punchResult = PunchResult.FAIL, errorMessage = result.message) + } + } + is ApiResult.NetworkError -> { + Timber.w(result.exception, "考勤: 撤销网络异常") + _uiState.update { + it.copy(punchResult = PunchResult.FAIL, errorMessage = "网络异常") + } + } + } + } + } + + /** 处理 MQTT 上下班状态变更(type=5) */ + fun handleWorkStateChange(isWorking: Boolean) { + Timber.d("考勤: MQTT工作状态变更, isWorking=$isWorking") + if (isWorking) { + screenController.turnOn() + } else { + screenController.turnOff() + } + // 刷新考勤状态 + fetchAttendance() + } + + /** 消费打卡结果(UI 读取后重置) */ + fun consumePunchResult() { + _uiState.update { it.copy(punchResult = null, errorMessage = null) } + } + + /** 关闭 NFC 硬件 + 播放关闭音效 */ + private fun closeNfc() { + nfcController.stopScan() + nfcController.close() + playFeedback(PLAN_NFC_CLOSE) + } + + /** 播放震动+音效反馈 */ + private fun playFeedback(planId: Int) { + val pattern = VibrationDefaults.getPattern(planId) + if (pattern != null) { + vibrationController.executePattern(pattern) + } + } + + override fun onCleared() { + super.onCleared() + // 清理 NFC 资源 + nfcTimeoutJob?.cancel() + if (nfcController.isOpen()) { + nfcController.stopScan() + nfcController.close() + } + } +} + +/** 打卡面板 UI 状态 */ +data class PunchUiState( + val isLoading: Boolean = false, + val onPunchState: Int = 0, + val offPunchState: Int = 0, + val actualOnTime: String? = null, + val actualOffTime: String? = null, + val isNfcScanning: Boolean = false, + val scanningPunchType: Int = -1, + val punchResult: PunchResult? = null, + val errorMessage: String? = null +) + +/** 打卡操作结果(一次性事件) */ +enum class PunchResult { + SUCCESS, + FAIL, + REVOKE_SUCCESS +} diff --git a/app/src/main/res/drawable/bg_btn_primary.xml b/app/src/main/res/drawable/bg_btn_primary.xml new file mode 100644 index 0000000..f696915 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_primary.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_secondary.xml b/app/src/main/res/drawable/bg_btn_secondary.xml new file mode 100644 index 0000000..6f8455e --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_secondary.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index be7702a..1d3feb9 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,26 +1,46 @@ - - + + android:background="@color/background"> - - + + android:layout_height="match_parent" + android:orientation="vertical" + android:paddingStart="21dp" + android:paddingTop="27dp" + android:paddingEnd="21dp"> - - + + + + + + + + + + android:layout_height="match_parent" /> - + + + + diff --git a/app/src/main/res/layout/view_punch_panel.xml b/app/src/main/res/layout/view_punch_panel.xml new file mode 100644 index 0000000..51f3636 --- /dev/null +++ b/app/src/main/res/layout/view_punch_panel.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 8f8d0e4..b6948a5 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -59,12 +59,6 @@ - - -