feat: ResultFlashView 操作结果指示器(Apple Watch 风格)

全屏深色遮罩 + 大圆圈图标,属性动画弹出+淡出,1.5秒自动消失。
- 成功:绿色圆圈 + ✓,OvershootInterpolator 回弹效果
- 失败:红色圆圈 + ✗
- 替代 QuTipDialog 用于 NFC 打卡和按钮操作的结果反馈
- 所有操作统一视觉风格

应用到:
- TaskDetailFragment NFC 打卡
- TaskListFragment NFC 打卡
- MainActivity 返回键主动打卡

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-05-06 14:25:57 +09:30
parent 306af795a6
commit 7f6e9cf039
7 changed files with 192 additions and 13 deletions

View File

@@ -17,6 +17,7 @@ import com.xiaoqu.watch.service.manager.NfcTaskManager
import com.xiaoqu.watch.service.manager.NotificationManager
import com.xiaoqu.watch.service.manager.SystemStateMonitor
import com.xiaoqu.watch.service.manager.UpdateManager
import com.xiaoqu.watch.ui.widget.ResultFlashView
import com.xiaoqu.watch.ui.widget.UpdateDialogView
import com.xiaoqu.watch.ui.widget.NotificationBannerView
import dagger.hilt.android.AndroidEntryPoint
@@ -50,6 +51,8 @@ class MainActivity : AppCompatActivity() {
/** NFC 任务打卡管理器 */
@Inject lateinit var nfcTaskManager: NfcTaskManager
@Inject lateinit var userPrefs: UserPrefs
/** 操作结果指示器Apple Watch 风格) */
lateinit var resultFlash: ResultFlashView
/** OTA 更新弹窗 */
lateinit var updateDialog: UpdateDialogView
lateinit var notificationBanner: NotificationBannerView
@@ -85,6 +88,9 @@ class MainActivity : AppCompatActivity() {
// 初始化通知横幅
notificationBanner = binding.notificationBanner
// 初始化结果指示器
resultFlash = binding.resultFlash
// 初始化 OTA 更新弹窗
updateDialog = binding.updateDialog
setupUpdateDialog()
@@ -205,8 +211,10 @@ class MainActivity : AppCompatActivity() {
Timber.d("返回键: 触发主动打卡")
nfcTaskManager.startActivePunch { success, message ->
if (message.isNotEmpty()) {
android.widget.Toast.makeText(this@MainActivity, message, android.widget.Toast.LENGTH_SHORT).show()
if (success) {
resultFlash.showSuccess()
} else if (message.isNotEmpty()) {
resultFlash.showFailure()
}
}
}

View File

@@ -222,13 +222,14 @@ class TaskDetailFragment : BaseFragment<FragmentTaskDetailBinding>() {
btn.setBackgroundResource(R.drawable.bg_foot_btn_grey)
nfcTaskManager.startTaskPunch(taskId) { success, message ->
val resultFlash = (activity as? com.xiaoqu.watch.app.MainActivity)?.resultFlash
if (success) {
// 音效已由 NfcTaskManager 播放,按钮短暂显示"打卡成功"后返回
btn.text = "打卡成功"
btn.setBackgroundResource(R.drawable.bg_foot_btn_green)
btn.postDelayed({ findNavController().popBackStack() }, 1000)
resultFlash?.showSuccess { findNavController().popBackStack() }
} else {
// 失败/超时 → 恢复按钮(可重试)
if (message != "超时") {
resultFlash?.showFailure()
}
// 恢复按钮(可重试)
btn.text = "开启打卡"
btn.isEnabled = true
btn.setBackgroundResource(R.drawable.bg_foot_btn_orange)

View File

@@ -451,13 +451,14 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
btn.setBackgroundResource(R.drawable.bg_foot_btn_grey)
nfcTaskManager.startTaskPunch(taskId) { success, message ->
val resultFlash = (activity as? com.xiaoqu.watch.app.MainActivity)?.resultFlash
if (success) {
// 音效已由 NfcTaskManager 播放,按钮短暂显示"打卡成功"后刷新
btn.text = "打卡成功"
btn.setBackgroundResource(R.drawable.bg_foot_btn_green)
btn.postDelayed({ fetchCurrentDetail() }, 1000)
resultFlash?.showSuccess { fetchCurrentDetail() }
} else {
// 失败/超时 → 恢复按钮(可重试)
if (message != "超时") {
resultFlash?.showFailure()
}
// 恢复按钮(可重试)
btn.text = "开启打卡"
btn.isEnabled = true
btn.setBackgroundResource(R.drawable.bg_foot_btn_orange)

View File

@@ -0,0 +1,120 @@
package com.xiaoqu.watch.ui.widget
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.TextView
import com.xiaoqu.watch.R
/**
* 操作结果闪烁指示器Apple Watch 风格)
*
* 全屏深色遮罩 + 中心大圆圈图标,属性动画弹出后自动淡出。
* 用于所有操作的成功/失败反馈,替代 QuTipDialog。
*
* 使用方式:
* - 添加到 activity_main.xml 最顶层
* - 调用 showSuccess() 或 showFailure()
* - 1.5 秒后自动消失,执行 onDismiss 回调
*/
class ResultFlashView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object {
/** 弹出动画时长(毫秒) */
private const val ANIM_IN_DURATION = 300L
/** 停留时长(毫秒) */
private const val STAY_DURATION = 1000L
/** 淡出动画时长(毫秒) */
private const val ANIM_OUT_DURATION = 300L
/** 成功绿色 */
private const val COLOR_SUCCESS = 0xFF4CAF50.toInt()
/** 失败红色 */
private const val COLOR_FAILURE = 0xFFF44336.toInt()
}
private val iconContainer: View
private val circleRing: View
private val tvIcon: TextView
/** 消失后回调 */
var onDismiss: (() -> Unit)? = null
init {
LayoutInflater.from(context).inflate(R.layout.view_result_flash, this, true)
iconContainer = findViewById(R.id.iconContainer)
circleRing = findViewById(R.id.circleRing)
tvIcon = findViewById(R.id.tvIcon)
}
/** 显示成功指示器(绿色 ✓) */
fun showSuccess(onDone: (() -> Unit)? = null) {
onDismiss = onDone
setColor(COLOR_SUCCESS)
tvIcon.text = ""
playAnimation()
}
/** 显示失败指示器(红色 ✗) */
fun showFailure(onDone: (() -> Unit)? = null) {
onDismiss = onDone
setColor(COLOR_FAILURE)
tvIcon.text = ""
playAnimation()
}
/** 设置圆圈颜色 */
private fun setColor(color: Int) {
val drawable = circleRing.background as? GradientDrawable
drawable?.setStroke(6, color)
tvIcon.setTextColor(color)
}
/** 执行弹出 → 停留 → 淡出动画 */
private fun playAnimation() {
// 重置状态
visibility = View.VISIBLE
alpha = 1f
iconContainer.scaleX = 0.3f
iconContainer.scaleY = 0.3f
iconContainer.alpha = 0f
// 弹出动画scale + alphaOvershootInterpolator 有回弹效果)
val scaleX = ObjectAnimator.ofFloat(iconContainer, "scaleX", 0.3f, 1f)
val scaleY = ObjectAnimator.ofFloat(iconContainer, "scaleY", 0.3f, 1f)
val alphaIn = ObjectAnimator.ofFloat(iconContainer, "alpha", 0f, 1f)
val animIn = AnimatorSet().apply {
playTogether(scaleX, scaleY, alphaIn)
duration = ANIM_IN_DURATION
interpolator = OvershootInterpolator(1.5f)
}
// 淡出动画
val fadeOut = ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
duration = ANIM_OUT_DURATION
startDelay = ANIM_IN_DURATION + STAY_DURATION
}
// 播放
animIn.start()
fadeOut.start()
// 动画结束后隐藏 + 回调
postDelayed({
visibility = View.GONE
alpha = 1f
onDismiss?.invoke()
onDismiss = null
}, ANIM_IN_DURATION + STAY_DURATION + ANIM_OUT_DURATION)
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 结果指示器圆形边框(默认绿色,代码中动态设置颜色) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke android:width="4dp" android:color="#4CAF50" />
</shape>

View File

@@ -33,7 +33,14 @@
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Layer 4: OTA 更新弹窗(最顶层,全屏覆盖,默认隐藏) -->
<!-- Layer 4: 操作结果指示器Apple Watch 风格,弹出+淡出,默认隐藏) -->
<com.xiaoqu.watch.ui.widget.ResultFlashView
android:id="@+id/resultFlash"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Layer 5: OTA 更新弹窗(最顶层,全屏覆盖,默认隐藏) -->
<com.xiaoqu.watch.ui.widget.UpdateDialogView
android:id="@+id/updateDialog"
android:layout_width="match_parent"

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 操作结果闪烁指示器(类 Apple Watch 风格)
全屏深色遮罩 + 中心大图标,属性动画弹出/淡出 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#CC000000"
android:visibility="gone">
<!-- 中心图标容器(圆形背景 + 图标文字) -->
<FrameLayout
android:id="@+id/iconContainer"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center">
<!-- 圆形边框 -->
<View
android:id="@+id/circleRing"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_result_circle" />
<!-- ✓ 或 ✗ 图标 -->
<TextView
android:id="@+id/tvIcon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textColor="@android:color/white"
android:textSize="48sp"
android:textStyle="bold" />
</FrameLayout>
</FrameLayout>