feat: 考勤打卡模块(NFC)重新开发

基于新方法论(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) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-28 20:57:17 +09:30
parent 98de3f0b46
commit 97a3054db2
13 changed files with 881 additions and 55 deletions

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -6,6 +6,7 @@ import com.xiaoqu.watch.network.EnvConfig
import com.xiaoqu.watch.network.SignatureInterceptor import com.xiaoqu.watch.network.SignatureInterceptor
import com.xiaoqu.watch.network.UnbindInterceptor import com.xiaoqu.watch.network.UnbindInterceptor
import com.xiaoqu.watch.network.api.CommonApi import com.xiaoqu.watch.network.api.CommonApi
import com.xiaoqu.watch.network.api.PunchApi
import com.xiaoqu.watch.network.api.TaskApi import com.xiaoqu.watch.network.api.TaskApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -69,4 +70,10 @@ object NetworkModule {
fun provideTaskApi(retrofit: Retrofit): TaskApi { fun provideTaskApi(retrofit: Retrofit): TaskApi {
return retrofit.create(TaskApi::class.java) return retrofit.create(TaskApi::class.java)
} }
@Provides
@Singleton
fun providePunchApi(retrofit: Retrofit): PunchApi {
return retrofit.create(PunchApi::class.java)
}
} }

View File

@@ -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<AttendanceStatus>
/** NFC 考勤打卡(上班/下班) */
@POST("watchTask/nfcOnAndOffPunch")
suspend fun nfcPunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
/** 撤销打卡 */
@POST("watchTask/revokePunch")
suspend fun revokePunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
}

View File

@@ -1,6 +1,5 @@
package com.xiaoqu.watch.network.api 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.TaskDetail
import com.xiaoqu.watch.data.task.TaskItem import com.xiaoqu.watch.data.task.TaskItem
import com.xiaoqu.watch.data.task.TaskStatistics import com.xiaoqu.watch.data.task.TaskStatistics
@@ -19,10 +18,6 @@ interface TaskApi {
@GET("watchTask/statisticsNew") @GET("watchTask/statisticsNew")
suspend fun getStatistics(): ApiResponse<TaskStatistics> suspend fun getStatistics(): ApiResponse<TaskStatistics>
/** 查询当前考勤状态 */
@GET("watchTask/myCurrentAttendance")
suspend fun getAttendance(): ApiResponse<AttendanceInfo>
/** 获取任务列表(按状态筛选) */ /** 获取任务列表(按状态筛选) */
@POST("watchTask/queryTaskIds") @POST("watchTask/queryTaskIds")
suspend fun getTaskIds(@Body params: HashMap<String, Any>): ApiResponse<List<TaskItem>> suspend fun getTaskIds(@Body params: HashMap<String, Any>): ApiResponse<List<TaskItem>>

View File

@@ -1,12 +1,18 @@
package com.xiaoqu.watch.ui.home package com.xiaoqu.watch.ui.home
import android.os.Bundle import android.os.Bundle
import android.view.GestureDetector
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.BuildConfig import com.xiaoqu.watch.BuildConfig
import com.xiaoqu.watch.R 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.api.TaskApi
import com.xiaoqu.watch.network.safeApiCall import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.ui.common.BaseFragment 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.ui.widget.StatusBarView
import com.xiaoqu.watch.util.DateUtil import com.xiaoqu.watch.util.DateUtil
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -29,8 +39,9 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* 首页 FragmentViewPager2 容器) * 首页 FragmentViewPager2 容器 + 考勤打卡面板
* Page 0 = 设置页Page 1 = 主页(默认显示) * Page 0 = 设置页Page 1 = 主页(默认显示)
* 下拉手势展开打卡面板
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class HomeFragment : BaseFragment<FragmentHomeBinding>() { class HomeFragment : BaseFragment<FragmentHomeBinding>() {
@@ -40,9 +51,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
@Inject lateinit var taskApi: TaskApi @Inject lateinit var taskApi: TaskApi
/** 考勤打卡 ViewModel */
private val punchViewModel: PunchViewModel by viewModels()
// ===== 固定状态栏(不随 ViewPager 滑动) ===== // ===== 固定状态栏(不随 ViewPager 滑动) =====
private lateinit var statusBar: StatusBarView private lateinit var statusBar: StatusBarView
// ===== 打卡面板 =====
private lateinit var punchPanel: PunchPanelView
private var confirmDialog: QuConfirmDialog? = null
// ===== 主页 View 引用 ===== // ===== 主页 View 引用 =====
private lateinit var tvClock: TextView private lateinit var tvClock: TextView
private lateinit var tvDate: TextView private lateinit var tvDate: TextView
@@ -59,6 +77,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
private var debugTapCount = 0 private var debugTapCount = 0
private var lastTapTime = 0L private var lastTapTime = 0L
// ===== 下拉手势检测 =====
private lateinit var gestureDetector: GestureDetector
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding { override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
return FragmentHomeBinding.inflate(inflater, container, false) return FragmentHomeBinding.inflate(inflater, container, false)
} }
@@ -66,9 +87,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// 绑定固定状态栏(不随 ViewPager 滑动) // 绑定固定状态栏
statusBar = binding.statusBar statusBar = binding.statusBar
// 初始化打卡面板
initPunchPanel()
// 创建两个页面 View // 创建两个页面 View
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
val configPage = inflater.inflate(R.layout.page_config, null) val configPage = inflater.inflate(R.layout.page_config, null)
@@ -81,12 +105,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// 设置 ViewPager2 // 设置 ViewPager2
val adapter = HomePagerAdapter(listOf(configPage, mainPage)) val adapter = HomePagerAdapter(listOf(configPage, mainPage))
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
binding.viewPager.setCurrentItem(1, false) // 默认显示主页 binding.viewPager.setCurrentItem(1, false)
// 初始化页面数据 // 初始化页面数据
initMainPage() initMainPage()
initConfigPage() initConfigPage()
// 初始化下拉手势
initGestureDetector()
// 启动时钟定时器 // 启动时钟定时器
startClockUpdater() startClockUpdater()
@@ -95,6 +122,130 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// 监听 MQTT 事件 // 监听 MQTT 事件
observeEvents() 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<FragmentHomeBinding>() {
val info = DateUtil.getDateInfo() val info = DateUtil.getDateInfo()
tvClock.text = DateUtil.formatTimeShort() tvClock.text = DateUtil.formatTimeShort()
tvDate.text = "${info.month}${info.day}${info.week}" tvDate.text = "${info.month}${info.day}${info.week}"
// 同步更新打卡面板时钟
if (punchPanel.isShowing) {
punchPanel.updateClock()
}
} }
/** 每秒更新时钟 */ /** 每秒更新时钟 */
@@ -172,7 +327,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
} }
} }
/** 初始化设置页数据(直接用 inflate 好的 configPageView */ /** 初始化设置页数据 */
private fun initConfigPage() { private fun initConfigPage() {
// 用户信息 // 用户信息
val userName = userPrefs.userName val userName = userPrefs.userName
@@ -186,7 +341,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
mobile mobile
} }
// 设备信息(直接操作已 inflate 的 View不依赖 ViewPager2 的 ViewHolder // 设备信息
configPageView.findViewById<TextView>(R.id.tvModel)?.text = configPageView.findViewById<TextView>(R.id.tvModel)?.text =
"${devicePrefs.brand} ${devicePrefs.model}" "${devicePrefs.brand} ${devicePrefs.model}"
configPageView.findViewById<TextView>(R.id.tvOsVersion)?.text = configPageView.findViewById<TextView>(R.id.tvOsVersion)?.text =
@@ -213,8 +368,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
if (debugTapCount >= 6) { if (debugTapCount >= 6) {
debugTapCount = 0 debugTapCount = 0
Timber.d("调试模式已开启") Timber.d("调试模式已开启")
android.widget.Toast.makeText(requireContext(), "调试模式已开启", android.widget.Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "调试模式已开启", Toast.LENGTH_SHORT).show()
// TODO: 打开调试页面
} }
} }
@@ -225,7 +379,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
eventBus.events.collect { event -> eventBus.events.collect { event ->
when (event) { when (event) {
// 电量变化:更新固定状态栏 // 电量变化
is AppEvent.BatteryChanged -> { is AppEvent.BatteryChanged -> {
statusBar.updateBattery(event.level, event.isCharging) statusBar.updateBattery(event.level, event.isCharging)
} }
@@ -248,9 +402,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
findNavController().navigate(R.id.action_home_to_bind) findNavController().navigate(R.id.action_home_to_bind)
} }
4 -> { 4 -> {
// 工作状态变更 // 工作状态变更(上下班)
Timber.d("首页: 收到工作状态变更") Timber.d("首页: 收到工作状态变更")
// TODO: 考勤模块重新开发时实现 // TODO: 解析 rawJson 中的 action 字段
// 暂时使用事件触发
}
5 -> {
// 上下班状态推送
Timber.d("首页: 收到上下班状态推送")
handleMqttWorkState(event.rawJson)
} }
} }
} }
@@ -260,14 +420,32 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
} }
} }
/**
* 处理 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 参数) */ /** 跳转到任务列表(传 tableStatus 参数) */
private fun navigateToTaskList(tableStatus: Int) { private fun navigateToTaskList(tableStatus: Int) {
// 防止重复导航:只有当前在 homeFragment 时才跳转 // 防止重复导航
val currentDest = findNavController().currentDestination?.id val currentDest = findNavController().currentDestination?.id
if (currentDest != R.id.homeFragment) return if (currentDest != R.id.homeFragment) return
val bundle = bundleOf("tableStatus" to tableStatus) val bundle = bundleOf("tableStatus" to tableStatus)
findNavController().navigate(R.id.action_home_to_taskList, bundle) findNavController().navigate(R.id.action_home_to_taskList, bundle)
} }
} }

View File

@@ -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}"
}
}

View File

@@ -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<PunchUiState> = _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<String, Any>(
"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
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 主按钮背景(深蓝色圆角) -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#FF1565C0" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FF1E88E5" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 次按钮背景(深灰色圆角) -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#FF424242" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FF333333" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>

View File

@@ -1,26 +1,46 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- 首页容器:固定状态栏 + ViewPager2 左右滑动 --> <!-- 首页容器:固定状态栏 + ViewPager2 + 打卡面板覆盖层 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/background" android:background="@color/background">
android:orientation="vertical"
android:paddingStart="21dp"
android:paddingTop="27dp"
android:paddingEnd="21dp">
<!-- 固定状态栏 --> <!-- 主内容区 -->
<com.xiaoqu.watch.ui.widget.StatusBarView <LinearLayout
android:id="@+id/statusBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="24dp" android:layout_height="match_parent"
android:layout_marginBottom="3dp" /> android:orientation="vertical"
android:paddingStart="21dp"
android:paddingTop="27dp"
android:paddingEnd="21dp">
<!-- ViewPager2 --> <!-- 固定状态栏 -->
<androidx.viewpager2.widget.ViewPager2 <com.xiaoqu.watch.ui.widget.StatusBarView
android:id="@+id/viewPager" android:id="@+id/statusBar"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginBottom="3dp" />
<!-- ViewPager2 -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
<!-- 考勤打卡面板(覆盖层,默认隐藏) -->
<com.xiaoqu.watch.ui.punch.PunchPanelView
android:id="@+id/punchPanel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="match_parent" />
android:layout_weight="1" />
</LinearLayout> <!-- 弹窗容器(最顶层,用于 QuTipDialog -->
<FrameLayout
android:id="@+id/dialogContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>

View File

@@ -0,0 +1,114 @@
<?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"
android:clickable="true"
android:focusable="true">
<!-- 半透明遮罩(点击收回面板) -->
<View
android:id="@+id/overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#80000000" />
<!-- 面板内容区 -->
<LinearLayout
android:id="@+id/panelContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="@color/background"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="28dp"
android:paddingTop="27dp"
android:paddingEnd="28dp"
android:paddingBottom="20dp">
<!-- 时间显示(大字体,老年用户) -->
<TextView
android:id="@+id/tvPunchTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="00:00"
android:textColor="@color/text_primary"
android:textSize="48sp" />
<!-- 日期显示 -->
<TextView
android:id="@+id/tvPunchDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="1月1日 星期一"
android:textColor="@color/text_secondary"
android:textSize="16sp" />
<!-- NFC 扫描提示(默认隐藏) -->
<TextView
android:id="@+id/tvNfcHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="请将手表贴近打卡信标"
android:textColor="@color/warning"
android:textSize="18sp"
android:visibility="gone" />
<!-- 按钮区域 -->
<LinearLayout
android:id="@+id/buttonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:orientation="horizontal">
<!-- 上班打卡按钮 -->
<TextView
android:id="@+id/btnPunchIn"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_primary"
android:gravity="center"
android:text="上班打卡"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:visibility="gone" />
<!-- 下班打卡按钮 -->
<TextView
android:id="@+id/btnPunchOut"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_primary"
android:gravity="center"
android:text="下班打卡"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:visibility="gone" />
<!-- 撤销打卡按钮 -->
<TextView
android:id="@+id/btnRevoke"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_btn_secondary"
android:gravity="center"
android:text="撤销打卡"
android:textColor="@color/text_secondary"
android:textSize="18sp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -59,12 +59,6 @@
<argument android:name="taskId" android:defaultValue="0L" app:argType="long" /> <argument android:name="taskId" android:defaultValue="0L" app:argType="long" />
</fragment> </fragment>
<!-- 打卡页 -->
<fragment
android:id="@+id/punchFragment"
android:name="com.xiaoqu.watch.ui.punch.PunchFragment"
android:label="打卡" />
<!-- 设备信息 --> <!-- 设备信息 -->
<fragment <fragment
android:id="@+id/infoFragment" android:id="@+id/infoFragment"