feat: 考勤打卡模块(新方法论首个验证模块)

基于分析产出物开发(discovery-map考勤章节+baseline/05流程6):
- PunchFragment 3种状态(未上班/已上班/已下班)
- PunchApi 3个接口(getAttendance/onAndOffPunch/revokePunch)
- PunchStatus 数据类(字段名基于分析,非猜测)
- 上班打卡:蓝牙识别1.5s→确认弹窗→API
- 下班打卡:蓝牙识别1.5s→直接提交→低耗电模式
- 撤销打卡:确认弹窗→API→恢复
- 首页下拉手势→考勤页
- 蓝牙用模拟MAC,后续对接

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-28 18:34:36 +09:30
parent b7a1b2683f
commit c812b9ee1a
7 changed files with 509 additions and 4 deletions

View File

@@ -0,0 +1,20 @@
package com.xiaoqu.watch.data.punch
import com.google.gson.annotations.SerializedName
/**
* 考勤状态数据类
* 对应 watchTask/myCurrentAttendance API 返回
* 来源discovery-map.md 考勤章节(已验证字段名)
*/
data class PunchStatus(
/** 上班打卡状态0=未上班, 1=已上班 */
@SerializedName("onPunchState") val onPunchState: Int = 0,
/** 下班打卡状态1=已下班, 其他=未下班 */
@SerializedName("offPunchState") val offPunchState: Int = 0
) {
/** 是否已上班 */
val isOnDuty: Boolean get() = onPunchState == 1
/** 是否已下班 */
val isOffDuty: Boolean get() = offPunchState == 1
}

View File

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

View File

@@ -0,0 +1,27 @@
package com.xiaoqu.watch.network.api
import com.xiaoqu.watch.data.punch.PunchStatus
import com.xiaoqu.watch.network.ApiResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
/**
* 考勤打卡 API 接口
* 来源discovery-map.md 考勤章节
* 注意myCurrentAttendance 标注为 GET如返回 405 需改为 POST
*/
interface PunchApi {
/** 查询当前考勤状态 */
@GET("watchTask/myCurrentAttendance")
suspend fun getAttendance(): ApiResponse<PunchStatus>
/** 上班/下班打卡 */
@POST("watchTask/onAndOffPunch")
suspend fun onAndOffPunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
/** 撤销打卡 */
@POST("watchTask/revokePunch")
suspend fun revokePunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
}

View File

@@ -95,6 +95,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
// 监听 MQTT 事件
observeEvents()
// 下拉手势 → 进入考勤页
setupPullDownGesture()
}
// ===== 主页 =====
@@ -269,4 +272,43 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
val bundle = bundleOf("tableStatus" to tableStatus)
findNavController().navigate(R.id.action_home_to_taskList, bundle)
}
/** 设置下拉手势 → 进入考勤打卡页 */
@android.annotation.SuppressLint("ClickableViewAccessibility")
private fun setupPullDownGesture() {
val gestureDetector = android.view.GestureDetector(
requireContext(),
object : android.view.GestureDetector.SimpleOnGestureListener() {
override fun onFling(
e1: android.view.MotionEvent?,
e2: android.view.MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
if (e1 == null) return false
val dy = e2.y - e1.y
val dx = e2.x - e1.x
// 下拉dy > 0且垂直幅度 > 水平
if (dy > 80 && kotlin.math.abs(dy) > kotlin.math.abs(dx)) {
navigateToPunch()
return true
}
return false
}
}
)
// 给 ViewPager2 内部的 RecyclerView 添加触摸监听
binding.viewPager.getChildAt(0)?.setOnTouchListener { _, event ->
gestureDetector.onTouchEvent(event)
false
}
}
/** 跳转到考勤打卡页 */
private fun navigateToPunch() {
val currentDest = findNavController().currentDestination?.id
if (currentDest != R.id.homeFragment) return
findNavController().navigate(R.id.action_home_to_punch)
}
}

View File

@@ -1,15 +1,313 @@
package com.xiaoqu.watch.ui.punch
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.punch.PunchStatus
import com.xiaoqu.watch.databinding.FragmentPunchBinding
import com.xiaoqu.watch.device.screen.ScreenController
import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.PunchApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.QuConfirmDialog
import com.xiaoqu.watch.ui.widget.QuTipDialog
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* 考勤打卡页面
* 入口:首页下拉手势
* 3 种状态:未上班→上班打卡→已上班→下班打卡/撤销
*
* 流程(基于 discovery-map.md 考勤章节 + baseline/05 流程6
* 上班蓝牙识别1.5s → 确认弹窗 → POST onAndOffPunch → 成功
* 下班蓝牙识别1.5s → 直接提交 → POST onAndOffPunch → 成功+低耗电
* 撤销:确认弹窗 → POST revokePunch → 恢复上班状态
*/
@AndroidEntryPoint
class PunchFragment : BaseFragment<FragmentPunchBinding>() {
@Inject lateinit var punchApi: PunchApi
@Inject lateinit var screenController: ScreenController
@Inject lateinit var eventBus: EventBus
/** 当前考勤状态 */
private var punchStatus: PunchStatus? = null
/** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog
/** 确认弹窗 */
private lateinit var confirmDialog: QuConfirmDialog
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPunchBinding {
return FragmentPunchBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 初始化弹窗
val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container)
tipDialog = QuTipDialog(dialogContainer)
confirmDialog = QuConfirmDialog(dialogContainer)
// 获取考勤状态
fetchAttendance()
// 监听系统状态事件(电量更新状态栏)
observeEvents()
}
// ===== 数据获取 =====
/** 获取当前考勤状态 */
private fun fetchAttendance() {
viewLifecycleOwner.lifecycleScope.launch {
val result = safeApiCall { punchApi.getAttendance() }
when (result) {
is ApiResult.Success -> {
punchStatus = result.data
displayStatus(result.data ?: PunchStatus())
}
is ApiResult.Error -> {
Timber.w("考勤: API 错误 ${result.code}")
// 默认显示未上班
displayStatus(PunchStatus())
}
is ApiResult.NetworkError -> {
Timber.w("考勤: 网络异常")
displayStatus(PunchStatus())
}
}
}
}
// ===== UI 显示 =====
/** 根据考勤状态更新页面显示(基于业务逻辑矩阵) */
private fun displayStatus(status: PunchStatus) {
// 重置所有可选元素
binding.btnRevoke.visibility = View.GONE
binding.lowPowerHint.visibility = View.GONE
when {
// 未上班 → 显示「上班打卡」
!status.isOnDuty -> {
binding.tvPunchStatus.text = "未上班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "上班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(0) }
}
// 已上班 + 已下班 → 显示「撤销」+「下班打卡」+ 低耗电提示
status.isOnDuty && status.isOffDuty -> {
binding.tvPunchStatus.text = "已下班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(1) }
binding.btnRevoke.visibility = View.VISIBLE
binding.btnRevoke.setOnClickListener { doRevoke() }
binding.lowPowerHint.visibility = View.VISIBLE
}
// 已上班 + 未下班 → 显示「下班打卡」
status.isOnDuty && !status.isOffDuty -> {
binding.tvPunchStatus.text = "已上班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.success))
binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startPunch(1) }
}
}
}
// ===== 打卡操作 =====
/**
* 开始打卡流程
* 1. 显示蓝牙识别 Loading1.5秒)
* 2. 读取 beacons当前用模拟数据
* 3. 上班:弹确认框;下班:直接提交
*/
private fun startPunch(punchType: Int) {
// 显示蓝牙识别提示
tipDialog.show(
status = QuTipDialog.Status.LOCATION,
title = "蓝牙正在识别…",
back = false
)
viewLifecycleOwner.lifecycleScope.launch {
// 等待 1.5 秒(模拟蓝牙扫描采集)
delay(1500)
tipDialog.dismiss()
// 读取蓝牙信标TODO: 后续从 store.beacons 读取,当前用模拟数据)
val beaconMacs = getMockBeaconMacs()
if (beaconMacs.isEmpty()) {
// 无信标 → 提示失败
tipDialog.show(
status = QuTipDialog.Status.WARNING,
title = "打卡失败",
desc = "未搜索到蓝牙信标,请重试",
back = true, step = 0, countdown = 3
)
return@launch
}
if (punchType == 0) {
// 上班 → 弹确认弹窗
confirmDialog.showText(
text = "确定上班打卡?",
onConfirm = { doPunch(punchType, beaconMacs) }
)
} else {
// 下班 → 不弹确认,直接提交
doPunch(punchType, beaconMacs)
}
}
}
/** 执行打卡 API 调用 */
private fun doPunch(punchType: Int, beaconMacs: List<String>) {
viewLifecycleOwner.lifecycleScope.launch {
val params = hashMapOf<String, Any>(
"beaconMacs" to beaconMacs,
"punchType" to punchType
)
val result = safeApiCall { punchApi.onAndOffPunch(params) }
when (result) {
is ApiResult.Success -> {
Timber.d("考勤: 打卡成功 punchType=$punchType")
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "打卡成功",
back = true, step = 0, countdown = 2
)
// 副作用:更新工作状态和屏幕亮度
if (punchType == 0) {
// 上班 → 屏幕正常亮度
screenController.turnOn()
emitWorkState(true)
} else {
// 下班 → 低耗电(熄屏)
screenController.turnOff()
emitWorkState(false)
}
// 刷新考勤状态
fetchAttendance()
}
is ApiResult.Error -> {
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "打卡失败",
desc = result.message,
back = true, step = 0, countdown = 3
)
}
is ApiResult.NetworkError -> {
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "网络异常",
back = true, step = 0, countdown = 3
)
}
}
}
}
/** 撤销打卡 */
private fun doRevoke() {
confirmDialog.showText(
text = "确定撤销打卡?",
onConfirm = {
viewLifecycleOwner.lifecycleScope.launch {
val params = hashMapOf<String, Any>()
val result = safeApiCall { punchApi.revokePunch(params) }
when (result) {
is ApiResult.Success -> {
Timber.d("考勤: 撤销成功")
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "撤销成功",
back = true, step = 0, countdown = 2
)
// 恢复上班状态
screenController.turnOn()
emitWorkState(true)
fetchAttendance()
}
is ApiResult.Error -> {
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "撤销失败",
desc = result.message,
back = true, step = 0, countdown = 3
)
}
is ApiResult.NetworkError -> {
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "网络异常",
back = true, step = 0, countdown = 3
)
}
}
}
}
)
}
// ===== 辅助方法 =====
/**
* 模拟蓝牙信标 MAC 列表
* TODO: 蓝牙扫描模块完成后,改为从 store.beacons 读取
*/
private fun getMockBeaconMacs(): List<String> {
return listOf("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66")
}
/** 发送工作状态变更事件 */
private fun emitWorkState(isWorking: Boolean) {
viewLifecycleOwner.lifecycleScope.launch {
eventBus.emit(AppEvent.WorkStateChanged(isWorking))
}
}
/** 监听系统状态事件 */
private fun observeEvents() {
viewLifecycleOwner.lifecycleScope.launch {
eventBus.events.collect { event ->
when (event) {
is AppEvent.BatteryChanged -> {
binding.statusBar.updateBattery(event.level, event.isCharging)
}
is AppEvent.BluetoothStateChanged -> {
binding.statusBar.updateBluetooth(event.isOn)
}
else -> {}
}
}
}
}
}

View File

@@ -1,9 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<!-- 考勤打卡页面按原型图V3适老化设计
3种状态未上班 / 已上班 / 已下班
120dpi 换算,老年人大字体 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
android:background="@color/background"
android:orientation="vertical">
<!-- TODO: 打卡操作界面 -->
<!-- 固定状态栏 -->
<com.xiaoqu.watch.ui.widget.StatusBarView
android:id="@+id/statusBar"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_marginStart="21dp"
android:layout_marginTop="27dp"
android:layout_marginEnd="21dp" />
</FrameLayout>
<!-- 居中内容区 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:paddingStart="21dp"
android:paddingEnd="21dp">
<!-- 考勤状态文字("未上班" / "已上班 07:02" / "已下班 17:05" -->
<TextView
android:id="@+id/tvPunchStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="28sp"
android:textStyle="bold" />
<!-- 按钮区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:layout_marginTop="27dp">
<!-- 主按钮(上班打卡 / 下班打卡) -->
<TextView
android:id="@+id/btnPunch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp"
android:background="@color/primary"
android:textColor="@color/text_primary"
android:textSize="26sp"
android:textStyle="bold" />
<!-- 撤销按钮(已上班+已下班时显示) -->
<TextView
android:id="@+id/btnRevoke"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:padding="16dp"
android:background="@color/grey_button"
android:textColor="@color/error"
android:textSize="22sp"
android:textStyle="bold"
android:text="撤销打卡"
android:layout_marginTop="11dp"
android:visibility="gone" />
</LinearLayout>
<!-- 低耗电模式提示(下班后显示) -->
<LinearLayout
android:id="@+id/lowPowerHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:layout_marginTop="21dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="低耗电模式"
android:textColor="@color/warning"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="蓝牙扫描已停止\nNFC 已关闭"
android:textColor="@color/text_secondary"
android:textSize="16sp"
android:gravity="center"
android:lineSpacingMultiplier="1.5"
android:layout_marginTop="8dp" />
</LinearLayout>
</LinearLayout>
<!-- 底部提示(上滑返回) -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="上滑返回"
android:textColor="@color/text_secondary"
android:textSize="16sp"
android:paddingBottom="27dp" />
</LinearLayout>

View File

@@ -28,6 +28,9 @@
<!-- 首页 → 任务列表 -->
<action android:id="@+id/action_home_to_taskList"
app:destination="@id/taskListFragment" />
<!-- 首页 → 考勤打卡(下拉入口) -->
<action android:id="@+id/action_home_to_punch"
app:destination="@id/punchFragment" />
</fragment>
<!-- 设备绑定页 -->