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.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
punchViewModel.uiState.collect { state -> punchViewModel.uiState.collect { state ->
// 更新面板按钮 // 更新面板按钮和状态文字
punchPanel.updateButtons(state) punchPanel.updateButtons(state)
// 更新 NFC 倒计时
punchPanel.updateNfcCountdown(state.nfcCountdown)
// 处理打卡结果(一次性事件) // 处理打卡结果(一次性事件)
state.punchResult?.let { result -> state.punchResult?.let { result ->
punchViewModel.consumePunchResult() punchViewModel.consumePunchResult()

View File

@@ -1,6 +1,5 @@
package com.xiaoqu.watch.ui.punch package com.xiaoqu.watch.ui.punch
import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -17,13 +16,13 @@ import com.xiaoqu.watch.util.DateUtil
* *
* 交互方式: * 交互方式:
* - 调用 show() 展开面板 * - 调用 show() 展开面板
* - 点击遮罩区域收回 * - 点击遮罩 / 底部指示条收回
* - 打卡完成后自动收回 * - 打卡完成后自动收回
* *
* 按钮显示规则(来自源码分析,已评审修正): * 按钮显示规则(来自源码分析,已评审修正):
* - onPunchState=0 → "上班打卡" * - onPunchState=0 → "上班打卡"
* - onPunchState=1, offPunchState=0 → "下班打卡" * - onPunchState=1, offPunchState=0 → "下班打卡" + 状态文字"已上班 HH:MM"
* - onPunchState=1, offPunchState=1 → "撤销打卡" + "下班打卡" * - onPunchState=1, offPunchState=1 → "撤销" + "下班打卡" + 状态文字"已下班 HH:MM"
*/ */
class PunchPanelView @JvmOverloads constructor( class PunchPanelView @JvmOverloads constructor(
context: Context, context: Context,
@@ -35,10 +34,12 @@ class PunchPanelView @JvmOverloads constructor(
private val overlay: View private val overlay: View
private val panelContent: LinearLayout private val panelContent: LinearLayout
private val tvPunchTime: TextView private val tvPunchTime: TextView
private val tvPunchStatus: TextView
private val tvNfcHint: TextView private val tvNfcHint: TextView
private val btnPunchIn: TextView private val btnPunchIn: TextView
private val btnPunchOut: TextView private val btnPunchOut: TextView
private val btnRevoke: TextView private val btnRevoke: TextView
private val dismissBar: View
/** 面板是否正在显示 */ /** 面板是否正在显示 */
var isShowing = false var isShowing = false
@@ -48,27 +49,27 @@ class PunchPanelView @JvmOverloads constructor(
var onPunchInClick: (() -> Unit)? = null var onPunchInClick: (() -> Unit)? = null
var onPunchOutClick: (() -> Unit)? = null var onPunchOutClick: (() -> Unit)? = null
var onRevokeClick: (() -> Unit)? = null var onRevokeClick: (() -> Unit)? = null
/** 面板关闭回调(用于通知 HomeFragment 恢复 ViewPager2 滑动) */ /** 面板关闭回调 */
var onDismiss: (() -> Unit)? = null var onDismiss: (() -> Unit)? = null
init { init {
// 加载布局
LayoutInflater.from(context).inflate(R.layout.view_punch_panel, this, true) LayoutInflater.from(context).inflate(R.layout.view_punch_panel, this, true)
// 默认隐藏
visibility = GONE visibility = GONE
// 绑定 View // 绑定 View
overlay = findViewById(R.id.overlay) overlay = findViewById(R.id.overlay)
panelContent = findViewById(R.id.panelContent) panelContent = findViewById(R.id.panelContent)
tvPunchTime = findViewById(R.id.tvPunchTime) tvPunchTime = findViewById(R.id.tvPunchTime)
tvPunchStatus = findViewById(R.id.tvPunchStatus)
tvNfcHint = findViewById(R.id.tvNfcHint) tvNfcHint = findViewById(R.id.tvNfcHint)
btnPunchIn = findViewById(R.id.btnPunchIn) btnPunchIn = findViewById(R.id.btnPunchIn)
btnPunchOut = findViewById(R.id.btnPunchOut) btnPunchOut = findViewById(R.id.btnPunchOut)
btnRevoke = findViewById(R.id.btnRevoke) btnRevoke = findViewById(R.id.btnRevoke)
dismissBar = findViewById(R.id.dismissBar)
// 点击遮罩收回面板 // 点击遮罩 / 指示条 收回面板
overlay.setOnClickListener { dismiss() } overlay.setOnClickListener { dismiss() }
dismissBar.setOnClickListener { dismiss() }
// 按钮点击 // 按钮点击
btnPunchIn.setOnClickListener { onPunchInClick?.invoke() } btnPunchIn.setOnClickListener { onPunchInClick?.invoke() }
@@ -80,11 +81,8 @@ class PunchPanelView @JvmOverloads constructor(
fun show() { fun show() {
if (isShowing) return if (isShowing) return
isShowing = true isShowing = true
// 更新时间
updateClock() updateClock()
// 显示并播放动画
visibility = VISIBLE visibility = VISIBLE
panelContent.translationY = -panelContent.height.toFloat().coerceAtLeast(300f) panelContent.translationY = -panelContent.height.toFloat().coerceAtLeast(300f)
panelContent.animate() panelContent.animate()
@@ -94,10 +92,7 @@ class PunchPanelView @JvmOverloads constructor(
.start() .start()
overlay.alpha = 0f overlay.alpha = 0f
overlay.animate() overlay.animate().alpha(1f).setDuration(200).start()
.alpha(1f)
.setDuration(200)
.start()
} }
/** 收回面板 */ /** 收回面板 */
@@ -105,7 +100,6 @@ class PunchPanelView @JvmOverloads constructor(
if (!isShowing) return if (!isShowing) return
isShowing = false isShowing = false
// 收回动画
panelContent.animate() panelContent.animate()
.translationY(-panelContent.height.toFloat().coerceAtLeast(300f)) .translationY(-panelContent.height.toFloat().coerceAtLeast(300f))
.setDuration(200) .setDuration(200)
@@ -113,49 +107,61 @@ class PunchPanelView @JvmOverloads constructor(
.withEndAction { .withEndAction {
visibility = GONE visibility = GONE
tvNfcHint.visibility = GONE tvNfcHint.visibility = GONE
tvPunchStatus.visibility = GONE
onDismiss?.invoke() onDismiss?.invoke()
} }
.start() .start()
overlay.animate() overlay.animate().alpha(0f).setDuration(200).start()
.alpha(0f)
.setDuration(200)
.start()
} }
/** /**
* 更新按钮显示状态 * 更新面板显示状态
* @param state 考勤状态 * @param state 考勤状态
*/ */
fun updateButtons(state: PunchUiState) { fun updateButtons(state: PunchUiState) {
val onState = state.onPunchState val onState = state.onPunchState
val offState = state.offPunchState val offState = state.offPunchState
// 重置所有按钮 // 重置按钮
btnPunchIn.visibility = GONE btnPunchIn.visibility = GONE
btnPunchOut.visibility = GONE btnPunchOut.visibility = GONE
btnRevoke.visibility = GONE btnRevoke.visibility = GONE
// 重置按钮权重(单按钮时 weight=1双按钮时撤销0.4+下班0.6
when { when {
// 未上班打卡 → 显示"上班打卡" // 未上班打卡 → "上班打卡"
onState == 0 -> { onState == 0 -> {
btnPunchIn.visibility = VISIBLE btnPunchIn.visibility = VISIBLE
setWeight(btnPunchIn, 1f)
// 无状态文字
tvPunchStatus.visibility = GONE
} }
// 已上班,已下班 → 显示"撤销打卡" + "下班打卡" // 已上班,已下班 → "撤销" + "下班打卡"
onState == 1 && offState == 1 -> { onState == 1 && offState == 1 -> {
btnRevoke.visibility = VISIBLE btnRevoke.visibility = VISIBLE
btnPunchOut.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 -> { onState == 1 && offState == 0 -> {
btnPunchOut.visibility = VISIBLE 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) { if (state.isNfcScanning) {
tvNfcHint.visibility = VISIBLE tvNfcHint.visibility = VISIBLE
// 扫描中禁用按钮
btnPunchIn.isEnabled = false btnPunchIn.isEnabled = false
btnPunchOut.isEnabled = false btnPunchOut.isEnabled = false
btnRevoke.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() { fun updateClock() {
tvPunchTime.text = DateUtil.formatTimeShort() 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) handleNfcResult(nfcId, punchType)
} }
// 3. 时自动关闭 // 3. 倒计时 + <20><>时自动关闭
nfcTimeoutJob = viewModelScope.launch { 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超时自动关闭") Timber.d("考勤: NFC超时自动关闭")
closeNfc() 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 结果决定) // 关闭 NFC 硬件(不播关闭音效,成功/失败音效由 API 结果决定)
nfcController.stopScan() nfcController.stopScan()
nfcController.close() nfcController.close()
// 清除倒计时
_uiState.update { it.copy(nfcCountdown = 0) }
// 调用打卡 API // 调用打卡 API
viewModelScope.launch { viewModelScope.launch {
@@ -266,6 +272,7 @@ data class PunchUiState(
val actualOffTime: String? = null, val actualOffTime: String? = null,
val isNfcScanning: Boolean = false, val isNfcScanning: Boolean = false,
val scanningPunchType: Int = -1, val scanningPunchType: Int = -1,
val nfcCountdown: Int = 0,
val punchResult: PunchResult? = null, val punchResult: PunchResult? = null,
val errorMessage: String? = null val errorMessage: String? = null
) )

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 药丸形蓝色渐变按钮(对应源码 #4CBAF1→#339AFB -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<gradient android:startColor="#FF2B8AD4" android:endColor="#FF2780C8" android:angle="0" />
<corners android:radius="28dp" />
</shape>
</item>
<item android:state_enabled="false">
<shape android:shape="rectangle">
<solid android:color="#FF333333" />
<corners android:radius="28dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient android:startColor="#FF4CBAF1" android:endColor="#FF339AFB" android:angle="0" />
<corners android:radius="28dp" />
</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="28dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FF2C2C2E" />
<corners android:radius="28dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 底部收回指示条(灰色圆角短横线) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#4DFFFFFF" />
<corners android:radius="2dp" />
</shape>

View File

@@ -25,7 +25,7 @@
android:paddingStart="28dp" android:paddingStart="28dp"
android:paddingTop="27dp" android:paddingTop="27dp"
android:paddingEnd="28dp" android:paddingEnd="28dp"
android:paddingBottom="20dp"> android:paddingBottom="8dp">
<!-- 时间显示(大字体,老年用户) --> <!-- 时间显示(大字体,老年用户) -->
<TextView <TextView
@@ -37,13 +37,23 @@
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="48sp" /> android:textSize="48sp" />
<!-- NFC 扫描提示(默认隐藏) --> <!-- 考勤状态文字:"已上班 07:02" / "已下班 17:05"(默认隐藏) -->
<TextView
android:id="@+id/tvPunchStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/success"
android:textSize="16sp"
android:visibility="gone" />
<!-- NFC 扫描提示 + 倒计时(默认隐藏) -->
<TextView <TextView
android:id="@+id/tvNfcHint" android:id="@+id/tvNfcHint"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="12dp"
android:text="请将手表贴近打卡信标" android:text="请贴近信标"
android:textColor="@color/warning" android:textColor="@color/warning"
android:textSize="18sp" android:textSize="18sp"
android:visibility="gone" /> android:visibility="gone" />
@@ -57,48 +67,56 @@
android:gravity="center" android:gravity="center"
android:orientation="horizontal"> android:orientation="horizontal">
<!-- 上班打卡按钮 --> <!-- 上班打卡按钮(药丸形,蓝色渐变) -->
<TextView <TextView
android:id="@+id/btnPunchIn" android:id="@+id/btnPunchIn"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="56dp" android:layout_height="56dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_btn_primary" android:background="@drawable/bg_btn_pill_blue"
android:gravity="center" android:gravity="center"
android:text="上班打卡" android:text="上班打卡"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="20sp" android:textSize="20sp"
android:visibility="gone" /> android:visibility="gone" />
<!-- 下班打卡按钮 --> <!-- 撤销打卡按钮(窄,灰底红字) -->
<TextView
android:id="@+id/btnRevoke"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="0.4"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_btn_pill_gray"
android:gravity="center"
android:text="撤销"
android:textColor="@color/error"
android:textSize="18sp"
android:visibility="gone" />
<!-- 下班打卡按钮(宽,蓝色渐变) -->
<TextView <TextView
android:id="@+id/btnPunchOut" android:id="@+id/btnPunchOut"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="56dp" android:layout_height="56dp"
android:layout_weight="1" android:layout_weight="0.6"
android:background="@drawable/bg_btn_primary" android:background="@drawable/bg_btn_pill_blue"
android:gravity="center" android:gravity="center"
android:text="下班打卡" android:text="下班打卡"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="20sp" android:textSize="20sp"
android:visibility="gone" /> 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>
<!-- 底部收回指示条 -->
<View
android:id="@+id/dismissBar"
android:layout_width="40dp"
android:layout_height="4dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_dismiss_bar" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>