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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
120
app/src/main/java/com/xiaoqu/watch/ui/widget/ResultFlashView.kt
Normal file
120
app/src/main/java/com/xiaoqu/watch/ui/widget/ResultFlashView.kt
Normal 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 + alpha,OvershootInterpolator 有回弹效果)
|
||||
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)
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/drawable/bg_result_circle.xml
Normal file
6
app/src/main/res/drawable/bg_result_circle.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
36
app/src/main/res/layout/view_result_flash.xml
Normal file
36
app/src/main/res/layout/view_result_flash.xml
Normal 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>
|
||||
Reference in New Issue
Block a user