refactor: 优化 QuTipDialog + 删除 ResultFlashView

QuTipDialog 优化:
- 图标区域放大 85→110dp,图标字号 38→50sp
- 加入淡入+缩放动画(OvershootInterpolator 回弹效果)
- 默认倒计时 3→2 秒
- 倒计时文字默认隐藏(showCountdown 参数控制)
- 简化调用:show(status, title) 即可,2秒后自动消失

NFC 打卡加回 QuTipDialog:
- 成功:绿色图标 + "打卡成功" + 2秒消失
- 失败:红色图标 + "打卡失败" + 错误信息

删除 ResultFlashView(方案不合适,统一用优化后的 QuTipDialog)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-05-06 15:40:07 +09:30
parent 1ef6824c5e
commit 3ab6a94676
9 changed files with 94 additions and 224 deletions

View File

@@ -17,7 +17,6 @@ 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
@@ -51,8 +50,6 @@ 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
@@ -88,9 +85,6 @@ class MainActivity : AppCompatActivity() {
// 初始化通知横幅
notificationBanner = binding.notificationBanner
// 初始化结果指示器
resultFlash = binding.resultFlash
// 初始化 OTA 更新弹窗
updateDialog = binding.updateDialog
setupUpdateDialog()
@@ -211,10 +205,8 @@ class MainActivity : AppCompatActivity() {
Timber.d("返回键: 触发主动打卡")
nfcTaskManager.startActivePunch { success, message ->
if (success) {
resultFlash.showSuccess()
} else if (message.isNotEmpty()) {
resultFlash.showFailure()
if (message.isNotEmpty()) {
android.widget.Toast.makeText(this@MainActivity, message, android.widget.Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -222,12 +222,20 @@ 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) {
resultFlash?.showSuccess { findNavController().popBackStack() }
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "打卡成功",
step = 1,
onBack = { findNavController().popBackStack() }
)
} else {
if (message != "超时") {
resultFlash?.showFailure()
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "打卡失败",
desc = message
)
}
// 恢复按钮(可重试)
btn.text = "开启打卡"

View File

@@ -451,12 +451,20 @@ 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) {
resultFlash?.showSuccess { fetchCurrentDetail() }
tipDialog.show(
status = QuTipDialog.Status.SUCCESS,
title = "打卡成功"
)
// 2 秒后(弹窗消失后)刷新任务详情
btn.postDelayed({ fetchCurrentDetail() }, 2100)
} else {
if (message != "超时") {
resultFlash?.showFailure()
tipDialog.show(
status = QuTipDialog.Status.ERROR,
title = "打卡失败",
desc = message
)
}
// 恢复按钮(可重试)
btn.text = "开启打卡"

View File

@@ -1,30 +1,35 @@
package com.xiaoqu.watch.ui.widget
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.graphics.Typeface
import android.os.CountDownTimer
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.TextView
import com.xiaoqu.watch.R
/**
* 提示弹窗(对应旧版 qu-tip.vue
* 显示状态图标(成功/警告/错误)+ 标题 + 可选描述 + 倒计时自动关闭/返回
* 提示弹窗(优化版
*
* 显示状态图标(成功/警告/错误)+ 标题 + 可选描述
* 带淡入+缩放动画,自动倒计时关闭
*
* 使用方式:
* ```
* val tip = QuTipDialog(binding.dialogContainer)
* tip.show(
* status = QuTipDialog.Status.SUCCESS,
* title = "打卡成功",
* desc = "已记录考勤",
* back = true,
* step = 1, // 0=只关闭1=返回上一页
* countdown = 3,
* onBack = { findMainNavController().popBackStack() }
* )
* // 简洁模式1.5 秒后自动消失
* tip.show(status = SUCCESS, title = "打卡成功")
*
* // 带返回1.5 秒后自动消失并执行回调
* tip.show(status = SUCCESS, title = "打卡成功",
* step = 1, onBack = { popBackStack() })
*
* // 带描述 + 倒计时文字
* tip.show(status = ERROR, title = "打卡失败", desc = "网络异常",
* showCountdown = true, countdown = 3)
* ```
*/
class QuTipDialog(
@@ -38,12 +43,13 @@ class QuTipDialog(
/**
* 显示提示弹窗
* @param status 状态类型(成功/警告/错误/定位)
* @param status 状态类型
* @param title 标题文字
* @param desc 描述文字(可选)
* @param back 是否显示倒计时返回按钮
* @param step 倒计时结束后行为0=只关闭弹窗>0=触发 onBack 回调
* @param countdown 倒计时秒数(默认 3 秒)
* @param desc 描述文字(可选,不传则不显示
* @param back 是否自动倒计时关闭(默认 true
* @param step 倒计时结束后行为0=只关闭,>0=触发 onBack 回调
* @param countdown 倒计时秒数(默认 2 秒)
* @param showCountdown 是否显示倒计时文字(默认 false更简洁
* @param onBack 返回回调step > 0 时触发)
*/
fun show(
@@ -51,8 +57,9 @@ class QuTipDialog(
title: String,
desc: String? = null,
back: Boolean = true,
step: Int = 1,
countdown: Int = 3,
step: Int = 0,
countdown: Int = 2,
showCountdown: Boolean = false,
onBack: (() -> Unit)? = null
) {
// 先移除旧弹窗
@@ -103,33 +110,38 @@ class QuTipDialog(
descView.visibility = View.VISIBLE
}
// 倒计时返回按钮
// 倒计时
val backBtn = view.findViewById<TextView>(R.id.tipBackBtn)
if (back) {
backBtn.visibility = View.VISIBLE
backBtn.typeface = typeface
if (showCountdown) {
backBtn.visibility = View.VISIBLE
backBtn.typeface = typeface
}
// 启动倒计时
timer = object : CountDownTimer(countdown * 1000L, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val seconds = (millisUntilFinished / 1000) + 1
backBtn.text = "${IconFont.BACK} ${seconds}s"
if (showCountdown) {
val seconds = (millisUntilFinished / 1000) + 1
backBtn.text = "${IconFont.BACK} ${seconds}s"
}
}
override fun onFinish() {
dismiss()
// step > 0 时触发返回回调
if (step > 0) {
onBack?.invoke()
}
}
}.start()
// 点击立即返回(不等倒计时)
backBtn.setOnClickListener {
dismiss()
if (step > 0) {
onBack?.invoke()
// 点击立即关闭(不等倒计时)
if (showCountdown) {
backBtn.setOnClickListener {
dismiss()
if (step > 0) {
onBack?.invoke()
}
}
}
}
@@ -137,6 +149,23 @@ class QuTipDialog(
// 显示弹窗
container.addView(view)
container.visibility = View.VISIBLE
// 淡入 + 缩放动画
val content = view.findViewById<View>(R.id.tipContent)
content.alpha = 0f
content.scaleX = 0.8f
content.scaleY = 0.8f
val fadeIn = ObjectAnimator.ofFloat(content, "alpha", 0f, 1f)
val scaleX = ObjectAnimator.ofFloat(content, "scaleX", 0.8f, 1f)
val scaleY = ObjectAnimator.ofFloat(content, "scaleY", 0.8f, 1f)
AnimatorSet().apply {
playTogether(fadeIn, scaleX, scaleY)
duration = 250
interpolator = OvershootInterpolator(1.2f)
start()
}
}
/** 关闭弹窗 */

View File

@@ -1,120 +0,0 @@
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

@@ -1,6 +0,0 @@
<?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,14 +33,7 @@
android:layout_height="match_parent"
android:visibility="gone" />
<!-- 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 更新弹窗(最顶层,全屏覆盖,默认隐藏) -->
<!-- Layer 4: OTA 更新弹窗(最顶层,全屏覆盖,默认隐藏) -->
<com.xiaoqu.watch.ui.widget.UpdateDialogView
android:id="@+id/updateDialog"
android:layout_width="match_parent"

View File

@@ -1,12 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- QuTipDialog反馈提示弹窗按原型图V3样式
圆形图标背景 + 标题 + 描述 + 倒计时 -->
<!-- QuTipDialog反馈提示弹窗优化版
圆形图标 + 标题,简洁清晰 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<LinearLayout
android:id="@+id/tipContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
@@ -14,11 +15,11 @@
android:paddingStart="21dp"
android:paddingEnd="21dp">
<!-- 圆形图标背景(85dp -->
<!-- 圆形图标背景(放大 85→110dp -->
<FrameLayout
android:layout_width="85dp"
android:layout_height="85dp"
android:layout_marginBottom="19dp">
android:layout_width="110dp"
android:layout_height="110dp"
android:layout_marginBottom="16dp">
<View
android:id="@+id/tipIconBg"
@@ -30,7 +31,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="38sp" />
android:textSize="50sp" />
</FrameLayout>
@@ -43,7 +44,7 @@
android:textSize="28sp"
android:textStyle="bold" />
<!-- 描述20sp -->
<!-- 描述20sp,可选 -->
<TextView
android:id="@+id/tipDesc"
android:layout_width="wrap_content"
@@ -55,7 +56,7 @@
android:layout_marginTop="8dp"
android:visibility="gone" />
<!-- 倒计时 -->
<!-- 倒计时(隐藏,只在需要时显示) -->
<TextView
android:id="@+id/tipBackBtn"
android:layout_width="wrap_content"

View File

@@ -1,35 +0,0 @@
<?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">
<!-- 中心图标容器(圆形背景 + 图标文字) -->
<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>