fix: 考勤页面交互全面优化

1. 半屏下拉面板(不是全屏页面)
2. 去掉状态文字,按钮即状态
3. 点击空白收回面板
4. 增加时间+日期显示(每秒更新)
5. NFC开启时播放语音+振动(planId=8开启/planId=4成功/planId=7失败)
6. NFC扫描时按钮变"NFC扫描中..."(不弹窗)
7. 点击扫描中的按钮可取消扫描
8. 10秒超时自动关闭+振动提示

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-28 20:04:42 +09:30
parent d9ee5f2059
commit 5a5c8bfaa0
2 changed files with 156 additions and 253 deletions

View File

@@ -12,6 +12,8 @@ import com.xiaoqu.watch.data.punch.PunchStatus
import com.xiaoqu.watch.databinding.FragmentPunchBinding import com.xiaoqu.watch.databinding.FragmentPunchBinding
import com.xiaoqu.watch.device.nfc.NfcController import com.xiaoqu.watch.device.nfc.NfcController
import com.xiaoqu.watch.device.screen.ScreenController 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.event.AppEvent import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.network.ApiResult import com.xiaoqu.watch.network.ApiResult
@@ -20,22 +22,19 @@ 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.widget.QuConfirmDialog import com.xiaoqu.watch.ui.widget.QuConfirmDialog
import com.xiaoqu.watch.ui.widget.QuTipDialog import com.xiaoqu.watch.ui.widget.QuTipDialog
import com.xiaoqu.watch.util.DateUtil
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* 考勤打卡页面NFC 方式 * 考勤打卡(半屏下拉面板
* * - 点击空白收回
* 流程: * - NFC 扫描时按钮变状态,不弹窗
* 1. 进入页面 → GET myCurrentAttendance → 显示考勤状态 * - 打卡有语音+振动反馈
* 2. 点击「上班打卡」→ 开启 NFC → "请将手表贴近打卡信标"
* 3. NFC 读到卡号 → 上班弹确认 / 下班直接提交
* 4. POST nfcOnAndOffPunch {nfcId, punchType}
* 5. 成功 → 提示 + 更新工作状态 + 屏幕亮度
*
* 来源v1.2.5 punchApis.js nfcOnAndOffPunch + 用户确认
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class PunchFragment : BaseFragment<FragmentPunchBinding>() { class PunchFragment : BaseFragment<FragmentPunchBinding>() {
@@ -43,18 +42,12 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
@Inject lateinit var punchApi: PunchApi @Inject lateinit var punchApi: PunchApi
@Inject lateinit var nfcController: NfcController @Inject lateinit var nfcController: NfcController
@Inject lateinit var screenController: ScreenController @Inject lateinit var screenController: ScreenController
@Inject lateinit var vibrationController: VibrationController
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
/** 当前考勤状态 */
private var punchStatus: PunchStatus? = null
/** 当前打卡类型0=上班, 1=下班) */
private var currentPunchType = 0 private var currentPunchType = 0
private var isScanning = false
/** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog private lateinit var tipDialog: QuTipDialog
/** 确认弹窗 */
private lateinit var confirmDialog: QuConfirmDialog private lateinit var confirmDialog: QuConfirmDialog
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPunchBinding { override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPunchBinding {
@@ -64,86 +57,70 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// 初始化弹窗
val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container) val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container)
tipDialog = QuTipDialog(dialogContainer) tipDialog = QuTipDialog(dialogContainer)
confirmDialog = QuConfirmDialog(dialogContainer) confirmDialog = QuConfirmDialog(dialogContainer)
// 点击空白收回
binding.dismissArea.setOnClickListener {
if (!isScanning) findNavController().popBackStack()
}
// 显示时间并每秒更新
updateTime()
viewLifecycleOwner.lifecycleScope.launch {
while (isActive) { delay(1000); updateTime() }
}
// 获取考勤状态 // 获取考勤状态
fetchAttendance() fetchAttendance()
// 上滑返回手势
setupSwipeUpToBack()
// 监听系统状态事件
observeEvents()
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
// 离开页面时关闭 NFC stopNfc()
nfcController.stopScan()
if (nfcController.isOpen()) {
nfcController.close()
}
} }
// ===== 数据获取 ===== // ===== 时间 =====
private fun updateTime() {
binding.tvTime.text = DateUtil.formatTimeShort()
val info = DateUtil.getDateInfo()
binding.tvDate.text = "${info.month}\u6708${info.day}\u65E5 ${info.week}"
}
// ===== 考勤状态 =====
/** 获取当前考勤状态 */
private fun fetchAttendance() { private fun fetchAttendance() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val result = safeApiCall { punchApi.getAttendance() } val result = safeApiCall { punchApi.getAttendance() }
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> displayStatus(result.data ?: PunchStatus())
punchStatus = result.data else -> displayStatus(PunchStatus())
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) { private fun displayStatus(status: PunchStatus) {
binding.btnRevoke.visibility = View.GONE binding.btnRevoke.visibility = View.GONE
binding.lowPowerHint.visibility = View.GONE binding.tvLowPower.visibility = View.GONE
when { when {
// 未上班
!status.isOnDuty -> { !status.isOnDuty -> {
binding.tvPunchStatus.text = "未上班"
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "上班打卡" binding.btnPunch.text = "上班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startNfcPunch(0) } binding.btnPunch.setOnClickListener { startNfcPunch(0) }
} }
// 已上班 + 已下班
status.isOnDuty && status.isOffDuty -> { status.isOnDuty && status.isOffDuty -> {
val timeText = if (!status.offPunchTime.isNullOrEmpty()) "已下班 ${status.offPunchTime}" else "已下班"
binding.tvPunchStatus.text = timeText
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary))
binding.btnPunch.text = "下班打卡" binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startNfcPunch(1) } binding.btnPunch.setOnClickListener { startNfcPunch(1) }
binding.btnRevoke.visibility = View.VISIBLE binding.btnRevoke.visibility = View.VISIBLE
binding.btnRevoke.setOnClickListener { doRevoke() } binding.btnRevoke.setOnClickListener { doRevoke() }
binding.lowPowerHint.visibility = View.VISIBLE binding.tvLowPower.visibility = View.VISIBLE
} }
// 已上班 + 未下班
status.isOnDuty && !status.isOffDuty -> { status.isOnDuty && !status.isOffDuty -> {
val timeText = if (!status.onPunchTime.isNullOrEmpty()) "已上班 ${status.onPunchTime}" else "已上班"
binding.tvPunchStatus.text = timeText
binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.success))
binding.btnPunch.text = "下班打卡" binding.btnPunch.text = "下班打卡"
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary))
binding.btnPunch.setOnClickListener { startNfcPunch(1) } binding.btnPunch.setOnClickListener { startNfcPunch(1) }
@@ -151,135 +128,123 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
} }
} }
// ===== NFC 打卡操作 ===== // ===== NFC 打卡 =====
/**
* 开始 NFC 打卡
* 1. 开启 NFC
* 2. 显示"请将手表贴近打卡信标"
* 3. NFC 读到卡号后回调
*/
private fun startNfcPunch(punchType: Int) { private fun startNfcPunch(punchType: Int) {
if (isScanning) return
currentPunchType = punchType currentPunchType = punchType
isScanning = true
// 开启 NFC // 按钮变为扫描状态(不弹窗)
binding.btnPunch.text = "NFC 扫描中..."
binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.warning))
binding.btnPunch.setOnClickListener { stopNfc(); resetButton() }
// NFC 开启语音+振动planId=8
VibrationDefaults.getPattern(8)?.let { vibrationController.executePattern(it) }
// 开启 NFC 扫描
nfcController.open() nfcController.open()
// 显示扫描提示
tipDialog.show(
status = QuTipDialog.Status.LOCATION,
title = "请将手表贴近打卡信标",
back = true,
step = 0,
countdown = 10, // 10秒超时自动关闭
onBack = {
// 用户取消或超时 → 关闭 NFC
nfcController.stopScan()
nfcController.close()
}
)
// 开始 NFC 扫描,读到卡号后回调
nfcController.startScan { nfcId -> nfcController.startScan { nfcId ->
Timber.d("考勤: NFC 读到卡号 $nfcId") Timber.d("考勤: NFC 读到 $nfcId")
// 关闭 NFC 和提示 stopNfc()
nfcController.stopScan()
nfcController.close()
tipDialog.dismiss()
// 上班弹确认 / 下班直接提交
if (currentPunchType == 0) { if (currentPunchType == 0) {
confirmDialog.showText( confirmDialog.showText(
text = "确定上班打卡?", text = "确定上班打卡?",
onConfirm = { doPunch(nfcId) }, onConfirm = { doPunch(nfcId) },
onCancel = { /* 取消,不打卡 */ } onCancel = { resetButton() }
) )
} else { } else {
doPunch(nfcId) doPunch(nfcId)
} }
} }
// 10秒超时
viewLifecycleOwner.lifecycleScope.launch {
delay(10000)
if (isScanning) {
stopNfc()
resetButton()
VibrationDefaults.getPattern(7)?.let { vibrationController.executePattern(it) }
}
}
}
private fun stopNfc() {
isScanning = false
nfcController.stopScan()
if (nfcController.isOpen()) nfcController.close()
}
private fun resetButton() {
isScanning = false
fetchAttendance()
} }
/** 执行 NFC 打卡 API */
private fun doPunch(nfcId: String) { private fun doPunch(nfcId: String) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val params = hashMapOf<String, Any>( val params = hashMapOf<String, Any>("nfcId" to nfcId, "punchType" to currentPunchType)
"nfcId" to nfcId,
"punchType" to currentPunchType
)
val result = safeApiCall { punchApi.nfcOnAndOffPunch(params) } val result = safeApiCall { punchApi.nfcOnAndOffPunch(params) }
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
Timber.d("考勤: NFC 打卡成功 punchType=$currentPunchType") // 成功语音+振动planId=4
VibrationDefaults.getPattern(4)?.let { vibrationController.executePattern(it) }
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.SUCCESS, status = QuTipDialog.Status.SUCCESS, title = "打卡成功",
title = "打卡成功",
back = true, step = 0, countdown = 2 back = true, step = 0, countdown = 2
) )
// 副作用:更新工作状态和屏幕亮度
if (currentPunchType == 0) { if (currentPunchType == 0) {
screenController.turnOn() screenController.turnOn(); emitWorkState(true)
emitWorkState(true)
} else { } else {
screenController.turnOff() screenController.turnOff(); emitWorkState(false)
emitWorkState(false)
} }
fetchAttendance() fetchAttendance()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
VibrationDefaults.getPattern(7)?.let { vibrationController.executePattern(it) }
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.ERROR, status = QuTipDialog.Status.ERROR, title = "打卡失败",
title = "打卡失败", desc = result.message, back = true, step = 0, countdown = 3
desc = result.message,
back = true, step = 0, countdown = 3
) )
resetButton()
} }
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.ERROR, status = QuTipDialog.Status.ERROR, title = "网络异常",
title = "网络异常",
back = true, step = 0, countdown = 3 back = true, step = 0, countdown = 3
) )
resetButton()
} }
} }
} }
} }
/** 撤销打卡 */
private fun doRevoke() { private fun doRevoke() {
confirmDialog.showText( confirmDialog.showText(
text = "确定撤销打卡?", text = "确定撤销打卡?",
onConfirm = { onConfirm = {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val params = hashMapOf<String, Any>() val result = safeApiCall { punchApi.revokePunch(hashMapOf()) }
val result = safeApiCall { punchApi.revokePunch(params) }
when (result) { when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
VibrationDefaults.getPattern(4)?.let { vibrationController.executePattern(it) }
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.SUCCESS, status = QuTipDialog.Status.SUCCESS, title = "撤销成功",
title = "撤销成功",
back = true, step = 0, countdown = 2 back = true, step = 0, countdown = 2
) )
screenController.turnOn() screenController.turnOn(); emitWorkState(true); fetchAttendance()
emitWorkState(true)
fetchAttendance()
} }
is ApiResult.Error -> { is ApiResult.Error -> {
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.ERROR, status = QuTipDialog.Status.ERROR, title = "撤销失败",
title = "撤销失败", desc = result.message, back = true, step = 0, countdown = 3
desc = result.message,
back = true, step = 0, countdown = 3
) )
} }
is ApiResult.NetworkError -> { is ApiResult.NetworkError -> {
tipDialog.show( tipDialog.show(
status = QuTipDialog.Status.ERROR, status = QuTipDialog.Status.ERROR, title = "网络异常",
title = "网络异常",
back = true, step = 0, countdown = 3 back = true, step = 0, countdown = 3
) )
} }
@@ -289,53 +254,9 @@ class PunchFragment : BaseFragment<FragmentPunchBinding>() {
) )
} }
// ===== 辅助 =====
/** 发送工作状态变更事件 */
private fun emitWorkState(isWorking: Boolean) { private fun emitWorkState(isWorking: Boolean) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
eventBus.emit(AppEvent.WorkStateChanged(isWorking)) 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 -> {}
}
}
}
}
/** 上滑返回首页 */
@android.annotation.SuppressLint("ClickableViewAccessibility")
private fun setupSwipeUpToBack() {
var startY = 0f
binding.contentArea.setOnTouchListener { _, event ->
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
startY = event.y
true
}
android.view.MotionEvent.ACTION_UP -> {
val dy = event.y - startY
// 上滑dy < -50→ 返回
if (dy < -50) {
findNavController().popBackStack()
}
true
}
else -> true
}
}
}
} }

View File

@@ -1,53 +1,55 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- 考勤打卡页面按原型图V3 <!-- 考勤打卡(半屏下拉面板
顶部:状态栏 上半:时间+按钮(黑色背景)
中间:时间 + 考勤状态 + 按钮 下半:透明,点击收回 -->
底部:低耗电提示 --> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout 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="#00000000">
android:orientation="vertical">
<!-- 固定状态栏 --> <!-- 点击空白收回 -->
<com.xiaoqu.watch.ui.widget.StatusBarView <View
android:id="@+id/statusBar" android:id="@+id/dismissArea"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="24dp" android:layout_height="match_parent" />
android:layout_marginStart="21dp"
android:layout_marginTop="27dp"
android:layout_marginEnd="21dp" />
<!-- 居中内容区(可上滑返回) --> <!-- 上半屏面板 -->
<LinearLayout <LinearLayout
android:id="@+id/contentArea"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_gravity="top"
android:gravity="center" android:background="@color/background"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="21dp" android:paddingStart="21dp"
android:paddingEnd="21dp"> android:paddingTop="27dp"
android:paddingEnd="21dp"
android:paddingBottom="21dp">
<!-- 考勤状态("未上班" / "已上班 07:02" / "已下班 17:05" --> <!-- 时间 -->
<TextView <TextView
android:id="@+id/tvPunchStatus" android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="26sp"
android:layout_marginBottom="27dp" />
<!-- 主按钮区域 -->
<LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:orientation="vertical"> android:textColor="@color/text_primary"
android:textSize="48sp"
android:fontFamily="sans-serif-medium"
android:layout_marginBottom="5dp" />
<!-- 主按钮(上班打卡 / 下班打卡) --> <!-- 日期 -->
<TextView
android:id="@+id/tvDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="20sp"
android:layout_marginBottom="16dp" />
<!-- 主按钮 -->
<TextView <TextView
android:id="@+id/btnPunch" android:id="@+id/btnPunch"
android:layout_width="250dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:padding="16dp" android:padding="16dp"
@@ -55,10 +57,10 @@
android:textSize="26sp" android:textSize="26sp"
android:textStyle="bold" /> android:textStyle="bold" />
<!-- 撤销按钮(已上班+已下班时显示) --> <!-- 撤销按钮 -->
<TextView <TextView
android:id="@+id/btnRevoke" android:id="@+id/btnRevoke"
android:layout_width="250dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:gravity="center"
android:padding="14dp" android:padding="14dp"
@@ -66,41 +68,21 @@
android:textColor="@color/error" android:textColor="@color/error"
android:textSize="22sp" android:textSize="22sp"
android:text="撤销打卡" android:text="撤销打卡"
android:layout_marginTop="8dp"
android:visibility="gone" />
<!-- 低耗电提示 -->
<TextView
android:id="@+id/tvLowPower"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="低耗电模式已开启"
android:textColor="@color/warning"
android:textSize="18sp"
android:layout_marginTop="11dp" android:layout_marginTop="11dp"
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>
<!-- 低耗电模式提示(下班后显示) --> </FrameLayout>
<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="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="蓝牙扫描已停止\nNFC 已关闭"
android:textColor="@color/text_secondary"
android:textSize="18sp"
android:gravity="center"
android:lineSpacingMultiplier="1.5"
android:layout_marginTop="8dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>