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:
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt
Normal file
25
app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt
Normal 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>
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首页 Fragment(ViewPager2 容器)
|
* 首页 Fragment(ViewPager2 容器 + 考勤打卡面板)
|
||||||
* 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
178
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt
Normal file
178
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
277
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt
Normal file
277
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt
Normal 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
|
||||||
|
}
|
||||||
16
app/src/main/res/drawable/bg_btn_primary.xml
Normal file
16
app/src/main/res/drawable/bg_btn_primary.xml
Normal 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>
|
||||||
16
app/src/main/res/drawable/bg_btn_secondary.xml
Normal file
16
app/src/main/res/drawable/bg_btn_secondary.xml
Normal 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>
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<?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_height="match_parent"
|
||||||
|
android:background="@color/background">
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<LinearLayout
|
||||||
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:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingStart="21dp"
|
android:paddingStart="21dp"
|
||||||
android:paddingTop="27dp"
|
android:paddingTop="27dp"
|
||||||
@@ -24,3 +29,18 @@
|
|||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 考勤打卡面板(覆盖层,默认隐藏) -->
|
||||||
|
<com.xiaoqu.watch.ui.punch.PunchPanelView
|
||||||
|
android:id="@+id/punchPanel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<!-- 弹窗容器(最顶层,用于 QuTipDialog) -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/dialogContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
114
app/src/main/res/layout/view_punch_panel.xml
Normal file
114
app/src/main/res/layout/view_punch_panel.xml
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user