feat: 考勤面板UI优化(5项改进)

1. 打卡状态文字:显示"已上班 07:02"/"已下班 17:05"
2. NFC扫描倒计时:显示"请贴近信标(8s)"
3. 底部收回指示条:灰色短横线,点击可收回面板
4. 药丸形按钮:大圆角28dp + 蓝色渐变(源码#4CBAF1→#339AFB)
5. 撤销按钮主次分明:窄灰底红字(0.4) + 宽蓝色下班(0.6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-29 10:27:40 +09:30
parent 1b9015e9ce
commit 3d2261c809
7 changed files with 155 additions and 56 deletions

View File

@@ -172,9 +172,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
punchViewModel.uiState.collect { state ->
// 更新面板按钮
// 更新面板按钮和状态文字
punchPanel.updateButtons(state)
// 更新 NFC 倒计时
punchPanel.updateNfcCountdown(state.nfcCountdown)
// 处理打卡结果(一次性事件)
state.punchResult?.let { result ->
punchViewModel.consumePunchResult()

View File

@@ -1,6 +1,5 @@
package com.xiaoqu.watch.ui.punch
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
@@ -17,13 +16,13 @@ import com.xiaoqu.watch.util.DateUtil
*
* 交互方式:
* - 调用 show() 展开面板
* - 点击遮罩区域收回
* - 点击遮罩 / 底部指示条收回
* - 打卡完成后自动收回
*
* 按钮显示规则(来自源码分析,已评审修正):
* - onPunchState=0 → "上班打卡"
* - onPunchState=1, offPunchState=0 → "下班打卡"
* - onPunchState=1, offPunchState=1 → "撤销打卡" + "下班打卡"
* - onPunchState=1, offPunchState=0 → "下班打卡" + 状态文字"已上班 HH:MM"
* - onPunchState=1, offPunchState=1 → "撤销" + "下班打卡" + 状态文字"已下班 HH:MM"
*/
class PunchPanelView @JvmOverloads constructor(
context: Context,
@@ -35,10 +34,12 @@ class PunchPanelView @JvmOverloads constructor(
private val overlay: View
private val panelContent: LinearLayout
private val tvPunchTime: TextView
private val tvPunchStatus: TextView
private val tvNfcHint: TextView
private val btnPunchIn: TextView
private val btnPunchOut: TextView
private val btnRevoke: TextView
private val dismissBar: View
/** 面板是否正在显示 */
var isShowing = false
@@ -48,27 +49,27 @@ class PunchPanelView @JvmOverloads constructor(
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)
tvPunchStatus = findViewById(R.id.tvPunchStatus)
tvNfcHint = findViewById(R.id.tvNfcHint)
btnPunchIn = findViewById(R.id.btnPunchIn)
btnPunchOut = findViewById(R.id.btnPunchOut)
btnRevoke = findViewById(R.id.btnRevoke)
dismissBar = findViewById(R.id.dismissBar)
// 点击遮罩收回面板
// 点击遮罩 / 指示条 收回面板
overlay.setOnClickListener { dismiss() }
dismissBar.setOnClickListener { dismiss() }
// 按钮点击
btnPunchIn.setOnClickListener { onPunchInClick?.invoke() }
@@ -80,11 +81,8 @@ class PunchPanelView @JvmOverloads constructor(
fun show() {
if (isShowing) return
isShowing = true
// 更新时间
updateClock()
// 显示并播放动画
visibility = VISIBLE
panelContent.translationY = -panelContent.height.toFloat().coerceAtLeast(300f)
panelContent.animate()
@@ -94,10 +92,7 @@ class PunchPanelView @JvmOverloads constructor(
.start()
overlay.alpha = 0f
overlay.animate()
.alpha(1f)
.setDuration(200)
.start()
overlay.animate().alpha(1f).setDuration(200).start()
}
/** 收回面板 */
@@ -105,7 +100,6 @@ class PunchPanelView @JvmOverloads constructor(
if (!isShowing) return
isShowing = false
// 收回动画
panelContent.animate()
.translationY(-panelContent.height.toFloat().coerceAtLeast(300f))
.setDuration(200)
@@ -113,49 +107,61 @@ class PunchPanelView @JvmOverloads constructor(
.withEndAction {
visibility = GONE
tvNfcHint.visibility = GONE
tvPunchStatus.visibility = GONE
onDismiss?.invoke()
}
.start()
overlay.animate()
.alpha(0f)
.setDuration(200)
.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
// 重置按钮权重(单按钮时 weight=1双按钮时撤销0.4+下班0.6
when {
// 未上班打卡 → 显示"上班打卡"
// 未上班打卡 → "上班打卡"
onState == 0 -> {
btnPunchIn.visibility = VISIBLE
setWeight(btnPunchIn, 1f)
// 无状态文字
tvPunchStatus.visibility = GONE
}
// 已上班,已下班 → 显示"撤销打卡" + "下班打卡"
// 已上班,已下班 → "撤销" + "下班打卡"
onState == 1 && offState == 1 -> {
btnRevoke.visibility = VISIBLE
btnPunchOut.visibility = VISIBLE
setWeight(btnRevoke, 0.4f)
setWeight(btnPunchOut, 0.6f)
// 状态:已下班 HH:MM
tvPunchStatus.visibility = VISIBLE
tvPunchStatus.text = "已下班 ${state.actualOffTime ?: ""}"
tvPunchStatus.setTextColor(context.getColor(R.color.text_secondary))
}
// 已上班,未下班 → "下班打卡"
// 已上班,未下班 → "下班打卡"
onState == 1 && offState == 0 -> {
btnPunchOut.visibility = VISIBLE
setWeight(btnPunchOut, 1f)
// 状态:已上班 HH:MM
tvPunchStatus.visibility = VISIBLE
tvPunchStatus.text = "已上班 ${state.actualOnTime ?: ""}"
tvPunchStatus.setTextColor(context.getColor(R.color.success))
}
}
// NFC 扫描中 → 显示提示
// NFC 扫描中
if (state.isNfcScanning) {
tvNfcHint.visibility = VISIBLE
// 扫描中禁用按钮
btnPunchIn.isEnabled = false
btnPunchOut.isEnabled = false
btnRevoke.isEnabled = false
@@ -167,8 +173,28 @@ class PunchPanelView @JvmOverloads constructor(
}
}
/** 更新时钟显示 */
/**
* 更新 NFC 扫描倒计时
* @param secondsLeft 剩余秒数,-1 表示不显示
*/
fun updateNfcCountdown(secondsLeft: Int) {
if (secondsLeft > 0) {
tvNfcHint.text = "请贴近信标(${secondsLeft}s"
tvNfcHint.visibility = VISIBLE
} else {
tvNfcHint.visibility = GONE
}
}
/** 更新时钟 */
fun updateClock() {
tvPunchTime.text = DateUtil.formatTimeShort()
}
/** 设置 View 的 layout_weight */
private fun setWeight(view: View, weight: Float) {
val lp = view.layoutParams as LinearLayout.LayoutParams
lp.weight = weight
view.layoutParams = lp
}
}

View File

@@ -107,12 +107,16 @@ class PunchViewModel @Inject constructor(
handleNfcResult(nfcId, punchType)
}
// 3. 时自动关闭
// 3. 倒计时 + <20><>时自动关闭
nfcTimeoutJob = viewModelScope.launch {
delay(nfcTimeoutMs)
val totalSeconds = (nfcTimeoutMs / 1000).toInt()
for (i in totalSeconds downTo 1) {
_uiState.update { it.copy(nfcCountdown = i) }
delay(1000)
}
Timber.d("考勤: NFC超时自动关闭")
closeNfc()
_uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1) }
_uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1, nfcCountdown = 0) }
}
}
@@ -130,6 +134,8 @@ class PunchViewModel @Inject constructor(
// 关闭 NFC 硬件(不播关闭音效,成功/失败音效由 API 结果决定)
nfcController.stopScan()
nfcController.close()
// 清除倒计时
_uiState.update { it.copy(nfcCountdown = 0) }
// 调用打卡 API
viewModelScope.launch {
@@ -266,6 +272,7 @@ data class PunchUiState(
val actualOffTime: String? = null,
val isNfcScanning: Boolean = false,
val scanningPunchType: Int = -1,
val nfcCountdown: Int = 0,
val punchResult: PunchResult? = null,
val errorMessage: String? = null
)