feat: 考勤打卡模块(NFC)重新开发
基于新方法论(11层源码分析+独立评审)重新开发考勤打卡功能: - 首页下拉展开打卡面板,点击按钮开启NFC贴卡打卡 - 支持上班打卡、下班打卡、撤销打卡 - NFC超时自动关闭,音效反馈(4种planId) - MQTT type=5 上下班状态推送处理 - 按钮状态矩阵:onPunchState×offPunchState决定显示 新增: AttendanceStatus / PunchApi / PunchViewModel / PunchPanelView 修改: NetworkModule / HomeFragment / fragment_home.xml 删除: AttendanceInfo(被AttendanceStatus替代) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package com.xiaoqu.watch.data.punch
|
||||
|
||||
/**
|
||||
* 考勤状态(对应 GET /watchTask/myCurrentAttendance 响应)
|
||||
*
|
||||
* 按钮显示规则:
|
||||
* - onPunchState=0 → 显示"上班打卡"
|
||||
* - onPunchState=1, offPunchState=0 → 显示"下班打卡"
|
||||
* - onPunchState=1, offPunchState=1 → 显示"撤销打卡" + "下班打卡"
|
||||
*/
|
||||
data class AttendanceStatus(
|
||||
/** 上班打卡状态: 0=未打卡, 1=已打卡 */
|
||||
val onPunchState: Int = 0,
|
||||
/** 下班打卡状态: 0=未打卡, 1=已打卡 */
|
||||
val offPunchState: Int = 0,
|
||||
/** 上班打卡时间 */
|
||||
val actualOnTime: String? = null,
|
||||
/** 下班打卡时间 */
|
||||
val actualOffTime: String? = null
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.xiaoqu.watch.data.task
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* 考勤状态信息
|
||||
* 对应 watchTask/myCurrentAttendance API 返回
|
||||
*/
|
||||
data class AttendanceInfo(
|
||||
/** 工作状态 (0=工作中) */
|
||||
@SerializedName("workStatus") val workStatus: Int = 0,
|
||||
/** 考勤状态 (3=未上班) */
|
||||
@SerializedName("workAtStatus") val workAtStatus: Int = 0
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import com.xiaoqu.watch.network.EnvConfig
|
||||
import com.xiaoqu.watch.network.SignatureInterceptor
|
||||
import com.xiaoqu.watch.network.UnbindInterceptor
|
||||
import com.xiaoqu.watch.network.api.CommonApi
|
||||
import com.xiaoqu.watch.network.api.PunchApi
|
||||
import com.xiaoqu.watch.network.api.TaskApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -69,4 +70,10 @@ object NetworkModule {
|
||||
fun provideTaskApi(retrofit: Retrofit): TaskApi {
|
||||
return retrofit.create(TaskApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePunchApi(retrofit: Retrofit): PunchApi {
|
||||
return retrofit.create(PunchApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
25
app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt
Normal file
25
app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.xiaoqu.watch.network.api
|
||||
|
||||
import com.xiaoqu.watch.data.punch.AttendanceStatus
|
||||
import com.xiaoqu.watch.network.ApiResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 考勤打卡 API 接口
|
||||
*/
|
||||
interface PunchApi {
|
||||
|
||||
/** 查询当前考勤状态 */
|
||||
@GET("watchTask/myCurrentAttendance")
|
||||
suspend fun getAttendance(): ApiResponse<AttendanceStatus>
|
||||
|
||||
/** NFC 考勤打卡(上班/下班) */
|
||||
@POST("watchTask/nfcOnAndOffPunch")
|
||||
suspend fun nfcPunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
|
||||
|
||||
/** 撤销打卡 */
|
||||
@POST("watchTask/revokePunch")
|
||||
suspend fun revokePunch(@Body params: HashMap<String, Any>): ApiResponse<Any>
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.xiaoqu.watch.network.api
|
||||
|
||||
import com.xiaoqu.watch.data.task.AttendanceInfo
|
||||
import com.xiaoqu.watch.data.task.TaskDetail
|
||||
import com.xiaoqu.watch.data.task.TaskItem
|
||||
import com.xiaoqu.watch.data.task.TaskStatistics
|
||||
@@ -19,10 +18,6 @@ interface TaskApi {
|
||||
@GET("watchTask/statisticsNew")
|
||||
suspend fun getStatistics(): ApiResponse<TaskStatistics>
|
||||
|
||||
/** 查询当前考勤状态 */
|
||||
@GET("watchTask/myCurrentAttendance")
|
||||
suspend fun getAttendance(): ApiResponse<AttendanceInfo>
|
||||
|
||||
/** 获取任务列表(按状态筛选) */
|
||||
@POST("watchTask/queryTaskIds")
|
||||
suspend fun getTaskIds(@Body params: HashMap<String, Any>): ApiResponse<List<TaskItem>>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package com.xiaoqu.watch.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.GestureDetector
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.xiaoqu.watch.BuildConfig
|
||||
import com.xiaoqu.watch.R
|
||||
@@ -19,6 +25,10 @@ import com.xiaoqu.watch.network.ApiResult
|
||||
import com.xiaoqu.watch.network.api.TaskApi
|
||||
import com.xiaoqu.watch.network.safeApiCall
|
||||
import com.xiaoqu.watch.ui.common.BaseFragment
|
||||
import com.xiaoqu.watch.ui.punch.PunchPanelView
|
||||
import com.xiaoqu.watch.ui.punch.PunchResult
|
||||
import com.xiaoqu.watch.ui.punch.PunchViewModel
|
||||
import com.xiaoqu.watch.ui.widget.QuConfirmDialog
|
||||
import com.xiaoqu.watch.ui.widget.StatusBarView
|
||||
import com.xiaoqu.watch.util.DateUtil
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -29,8 +39,9 @@ import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 首页 Fragment(ViewPager2 容器)
|
||||
* 首页 Fragment(ViewPager2 容器 + 考勤打卡面板)
|
||||
* Page 0 = 设置页,Page 1 = 主页(默认显示)
|
||||
* 下拉手势展开打卡面板
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
@@ -40,9 +51,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
@Inject lateinit var eventBus: EventBus
|
||||
@Inject lateinit var taskApi: TaskApi
|
||||
|
||||
/** 考勤打卡 ViewModel */
|
||||
private val punchViewModel: PunchViewModel by viewModels()
|
||||
|
||||
// ===== 固定状态栏(不随 ViewPager 滑动) =====
|
||||
private lateinit var statusBar: StatusBarView
|
||||
|
||||
// ===== 打卡面板 =====
|
||||
private lateinit var punchPanel: PunchPanelView
|
||||
private var confirmDialog: QuConfirmDialog? = null
|
||||
|
||||
// ===== 主页 View 引用 =====
|
||||
private lateinit var tvClock: TextView
|
||||
private lateinit var tvDate: TextView
|
||||
@@ -59,6 +77,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
private var debugTapCount = 0
|
||||
private var lastTapTime = 0L
|
||||
|
||||
// ===== 下拉手势检测 =====
|
||||
private lateinit var gestureDetector: GestureDetector
|
||||
|
||||
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
|
||||
return FragmentHomeBinding.inflate(inflater, container, false)
|
||||
}
|
||||
@@ -66,9 +87,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// 绑定固定状态栏(不随 ViewPager 滑动)
|
||||
// 绑定固定状态栏
|
||||
statusBar = binding.statusBar
|
||||
|
||||
// 初始化打卡面板
|
||||
initPunchPanel()
|
||||
|
||||
// 创建两个页面 View
|
||||
val inflater = LayoutInflater.from(requireContext())
|
||||
val configPage = inflater.inflate(R.layout.page_config, null)
|
||||
@@ -81,12 +105,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
// 设置 ViewPager2
|
||||
val adapter = HomePagerAdapter(listOf(configPage, mainPage))
|
||||
binding.viewPager.adapter = adapter
|
||||
binding.viewPager.setCurrentItem(1, false) // 默认显示主页
|
||||
binding.viewPager.setCurrentItem(1, false)
|
||||
|
||||
// 初始化页面数据
|
||||
initMainPage()
|
||||
initConfigPage()
|
||||
|
||||
// 初始化下拉手势
|
||||
initGestureDetector()
|
||||
|
||||
// 启动时钟定时器
|
||||
startClockUpdater()
|
||||
|
||||
@@ -95,6 +122,130 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
|
||||
// 监听 MQTT 事件
|
||||
observeEvents()
|
||||
|
||||
// 监听打卡状态
|
||||
observePunchState()
|
||||
}
|
||||
|
||||
// ===== 打卡面板 =====
|
||||
|
||||
/** 初始化打卡面板 */
|
||||
private fun initPunchPanel() {
|
||||
punchPanel = binding.punchPanel
|
||||
confirmDialog = QuConfirmDialog(binding.dialogContainer)
|
||||
|
||||
// 上班打卡
|
||||
punchPanel.onPunchInClick = {
|
||||
punchViewModel.startPunch(0)
|
||||
}
|
||||
|
||||
// 下班打卡
|
||||
punchPanel.onPunchOutClick = {
|
||||
punchViewModel.startPunch(1)
|
||||
}
|
||||
|
||||
// 撤销打卡
|
||||
punchPanel.onRevokeClick = {
|
||||
// 弹出确认弹窗
|
||||
showRevokeConfirm()
|
||||
}
|
||||
|
||||
// 面板关闭时恢复 ViewPager2 滑动
|
||||
punchPanel.onDismiss = {
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示撤销打卡确认弹窗 */
|
||||
private fun showRevokeConfirm() {
|
||||
confirmDialog?.showText(
|
||||
text = "确定撤销打卡?",
|
||||
onConfirm = {
|
||||
punchViewModel.revokePunch()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 监听打卡状态变化 */
|
||||
private fun observePunchState() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
punchViewModel.uiState.collect { state ->
|
||||
// 更新面板按钮
|
||||
punchPanel.updateButtons(state)
|
||||
|
||||
// 处理打卡结果(一次性事件)
|
||||
state.punchResult?.let { result ->
|
||||
punchViewModel.consumePunchResult()
|
||||
when (result) {
|
||||
PunchResult.SUCCESS -> {
|
||||
Toast.makeText(requireContext(), "打卡成功", Toast.LENGTH_SHORT).show()
|
||||
// 延迟收回面板
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(1000)
|
||||
punchPanel.dismiss()
|
||||
}
|
||||
// 刷新首页统计
|
||||
fetchStatistics()
|
||||
}
|
||||
PunchResult.REVOKE_SUCCESS -> {
|
||||
Toast.makeText(requireContext(), "撤销成功", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
PunchResult.FAIL -> {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
state.errorMessage ?: "操作失败",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开打卡面板 */
|
||||
private fun showPunchPanel() {
|
||||
if (punchPanel.isShowing) return
|
||||
// 禁用 ViewPager2 滑动
|
||||
binding.viewPager.isUserInputEnabled = false
|
||||
// 查询考勤状态
|
||||
punchViewModel.fetchAttendance()
|
||||
// 展开面板
|
||||
punchPanel.show()
|
||||
}
|
||||
|
||||
// ===== 下拉手势 =====
|
||||
|
||||
/** 初始化下拉手势检测器 */
|
||||
private fun initGestureDetector() {
|
||||
gestureDetector = GestureDetector(requireContext(),
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onFling(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
// 只在主页(page=1)且面板未展开时响应下拉
|
||||
if (binding.viewPager.currentItem != 1) return false
|
||||
if (punchPanel.isShowing) return false
|
||||
|
||||
// 下拉:velocityY > 0 且垂直速度大于水平速度
|
||||
if (velocityY > 500 && Math.abs(velocityY) > Math.abs(velocityX)) {
|
||||
showPunchPanel()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// 在 ViewPager2 上设置触摸监听
|
||||
binding.viewPager.getChildAt(0)?.setOnTouchListener { _, event ->
|
||||
gestureDetector.onTouchEvent(event)
|
||||
false // 不消费事件,让 ViewPager2 正常处理
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 主页 =====
|
||||
@@ -129,6 +280,10 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
val info = DateUtil.getDateInfo()
|
||||
tvClock.text = DateUtil.formatTimeShort()
|
||||
tvDate.text = "${info.month}月${info.day}日 ${info.week}"
|
||||
// 同步更新打卡面板时钟
|
||||
if (punchPanel.isShowing) {
|
||||
punchPanel.updateClock()
|
||||
}
|
||||
}
|
||||
|
||||
/** 每秒更新时钟 */
|
||||
@@ -172,7 +327,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化设置页数据(直接用 inflate 好的 configPageView) */
|
||||
/** 初始化设置页数据 */
|
||||
private fun initConfigPage() {
|
||||
// 用户信息
|
||||
val userName = userPrefs.userName
|
||||
@@ -186,7 +341,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
mobile
|
||||
}
|
||||
|
||||
// 设备信息(直接操作已 inflate 的 View,不依赖 ViewPager2 的 ViewHolder)
|
||||
// 设备信息
|
||||
configPageView.findViewById<TextView>(R.id.tvModel)?.text =
|
||||
"${devicePrefs.brand} ${devicePrefs.model}"
|
||||
configPageView.findViewById<TextView>(R.id.tvOsVersion)?.text =
|
||||
@@ -213,8 +368,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
if (debugTapCount >= 6) {
|
||||
debugTapCount = 0
|
||||
Timber.d("调试模式已开启")
|
||||
android.widget.Toast.makeText(requireContext(), "调试模式已开启", android.widget.Toast.LENGTH_SHORT).show()
|
||||
// TODO: 打开调试页面
|
||||
Toast.makeText(requireContext(), "调试模式已开启", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +379,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
eventBus.events.collect { event ->
|
||||
when (event) {
|
||||
// 电量变化:更新固定状态栏
|
||||
// 电量变化
|
||||
is AppEvent.BatteryChanged -> {
|
||||
statusBar.updateBattery(event.level, event.isCharging)
|
||||
}
|
||||
@@ -248,9 +402,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
findNavController().navigate(R.id.action_home_to_bind)
|
||||
}
|
||||
4 -> {
|
||||
// 工作状态变更
|
||||
// 工作状态变更(上下班)
|
||||
Timber.d("首页: 收到工作状态变更")
|
||||
// TODO: 考勤模块重新开发时实现
|
||||
// TODO: 解析 rawJson 中的 action 字段
|
||||
// 暂时使用事件触发
|
||||
}
|
||||
5 -> {
|
||||
// 上下班状态推送
|
||||
Timber.d("首页: 收到上下班状态推送")
|
||||
handleMqttWorkState(event.rawJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,14 +420,32 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 MQTT type=5 上下班状态推送
|
||||
* rawJson 格式: {"messageType":5, "action":0/1, ...}
|
||||
* action=0 → 下班(屏幕变暗)
|
||||
* action=1 → 上班(屏幕变亮)
|
||||
*/
|
||||
private fun handleMqttWorkState(rawJson: String) {
|
||||
try {
|
||||
val json = org.json.JSONObject(rawJson)
|
||||
val action = json.optInt("action", -1)
|
||||
if (action == 0 || action == 1) {
|
||||
val isWorking = action == 1
|
||||
punchViewModel.handleWorkStateChange(isWorking)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "解析 MQTT 工作状态消息失败")
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到任务列表(传 tableStatus 参数) */
|
||||
private fun navigateToTaskList(tableStatus: Int) {
|
||||
// 防止重复导航:只有当前在 homeFragment 时才跳转
|
||||
// 防止重复导航
|
||||
val currentDest = findNavController().currentDestination?.id
|
||||
if (currentDest != R.id.homeFragment) return
|
||||
|
||||
val bundle = bundleOf("tableStatus" to tableStatus)
|
||||
findNavController().navigate(R.id.action_home_to_taskList, bundle)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
178
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt
Normal file
178
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchPanelView.kt
Normal file
@@ -0,0 +1,178 @@
|
||||
package com.xiaoqu.watch.ui.punch
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.xiaoqu.watch.R
|
||||
import com.xiaoqu.watch.util.DateUtil
|
||||
|
||||
/**
|
||||
* 考勤打卡面板(嵌入首页,覆盖在 ViewPager2 上方)
|
||||
*
|
||||
* 交互方式:
|
||||
* - 调用 show() 展开面板
|
||||
* - 点击遮罩区域收回
|
||||
* - 打卡完成后自动收回
|
||||
*
|
||||
* 按钮显示规则(来自源码分析,已评审修正):
|
||||
* - onPunchState=0 → "上班打卡"
|
||||
* - onPunchState=1, offPunchState=0 → "下班打卡"
|
||||
* - onPunchState=1, offPunchState=1 → "撤销打卡" + "下班打卡"
|
||||
*/
|
||||
class PunchPanelView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
// 面板内部 View 引用
|
||||
private val overlay: View
|
||||
private val panelContent: LinearLayout
|
||||
private val tvPunchTime: TextView
|
||||
private val tvPunchDate: TextView
|
||||
private val tvNfcHint: TextView
|
||||
private val btnPunchIn: TextView
|
||||
private val btnPunchOut: TextView
|
||||
private val btnRevoke: TextView
|
||||
|
||||
/** 面板是否正在显示 */
|
||||
var isShowing = false
|
||||
private set
|
||||
|
||||
/** 按钮点击回调 */
|
||||
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)
|
||||
tvPunchDate = findViewById(R.id.tvPunchDate)
|
||||
tvNfcHint = findViewById(R.id.tvNfcHint)
|
||||
btnPunchIn = findViewById(R.id.btnPunchIn)
|
||||
btnPunchOut = findViewById(R.id.btnPunchOut)
|
||||
btnRevoke = findViewById(R.id.btnRevoke)
|
||||
|
||||
// 点击遮罩收回面板
|
||||
overlay.setOnClickListener { dismiss() }
|
||||
|
||||
// 按钮点击
|
||||
btnPunchIn.setOnClickListener { onPunchInClick?.invoke() }
|
||||
btnPunchOut.setOnClickListener { onPunchOutClick?.invoke() }
|
||||
btnRevoke.setOnClickListener { onRevokeClick?.invoke() }
|
||||
}
|
||||
|
||||
/** 展开面板 */
|
||||
fun show() {
|
||||
if (isShowing) return
|
||||
isShowing = true
|
||||
|
||||
// 更新时间
|
||||
updateClock()
|
||||
|
||||
// 显示并播放动画
|
||||
visibility = VISIBLE
|
||||
panelContent.translationY = -panelContent.height.toFloat().coerceAtLeast(300f)
|
||||
panelContent.animate()
|
||||
.translationY(0f)
|
||||
.setDuration(200)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
overlay.alpha = 0f
|
||||
overlay.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(200)
|
||||
.start()
|
||||
}
|
||||
|
||||
/** 收回面板 */
|
||||
fun dismiss() {
|
||||
if (!isShowing) return
|
||||
isShowing = false
|
||||
|
||||
// 收回动画
|
||||
panelContent.animate()
|
||||
.translationY(-panelContent.height.toFloat().coerceAtLeast(300f))
|
||||
.setDuration(200)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.withEndAction {
|
||||
visibility = GONE
|
||||
tvNfcHint.visibility = GONE
|
||||
onDismiss?.invoke()
|
||||
}
|
||||
.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
|
||||
|
||||
when {
|
||||
// 未上班打卡 → 显示"上班打卡"
|
||||
onState == 0 -> {
|
||||
btnPunchIn.visibility = VISIBLE
|
||||
}
|
||||
// 已上班,已下班 → 显示"撤销打卡" + "下班打卡"
|
||||
onState == 1 && offState == 1 -> {
|
||||
btnRevoke.visibility = VISIBLE
|
||||
btnPunchOut.visibility = VISIBLE
|
||||
}
|
||||
// 已上班,未下班 → 仅"下班打卡"
|
||||
onState == 1 && offState == 0 -> {
|
||||
btnPunchOut.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
// NFC 扫描中 → 显示提示
|
||||
if (state.isNfcScanning) {
|
||||
tvNfcHint.visibility = VISIBLE
|
||||
// 扫描中禁用按钮
|
||||
btnPunchIn.isEnabled = false
|
||||
btnPunchOut.isEnabled = false
|
||||
btnRevoke.isEnabled = false
|
||||
} else {
|
||||
tvNfcHint.visibility = GONE
|
||||
btnPunchIn.isEnabled = true
|
||||
btnPunchOut.isEnabled = true
|
||||
btnRevoke.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新时钟显示 */
|
||||
fun updateClock() {
|
||||
val info = DateUtil.getDateInfo()
|
||||
tvPunchTime.text = DateUtil.formatTimeShort()
|
||||
tvPunchDate.text = "${info.month}月${info.day}日 ${info.week}"
|
||||
}
|
||||
}
|
||||
277
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt
Normal file
277
app/src/main/java/com/xiaoqu/watch/ui/punch/PunchViewModel.kt
Normal file
@@ -0,0 +1,277 @@
|
||||
package com.xiaoqu.watch.ui.punch
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.xiaoqu.watch.device.nfc.NfcController
|
||||
import com.xiaoqu.watch.device.screen.ScreenController
|
||||
import com.xiaoqu.watch.device.sensor.VibrationController
|
||||
import com.xiaoqu.watch.device.sensor.VibrationDefaults
|
||||
import com.xiaoqu.watch.network.ApiResult
|
||||
import com.xiaoqu.watch.network.api.PunchApi
|
||||
import com.xiaoqu.watch.network.safeApiCall
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 考勤打卡 ViewModel
|
||||
* 管理 NFC 打卡流程、考勤状态查询、撤销打卡
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PunchViewModel @Inject constructor(
|
||||
private val punchApi: PunchApi,
|
||||
private val nfcController: NfcController,
|
||||
private val vibrationController: VibrationController,
|
||||
private val screenController: ScreenController
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
/** NFC 自动关闭超时(毫秒),可被服务端下发覆盖 */
|
||||
private const val DEFAULT_NFC_TIMEOUT_MS = 10_000L
|
||||
/** planId 常量 */
|
||||
private const val PLAN_PUNCH_SUCCESS = 4
|
||||
private const val PLAN_PUNCH_FAIL = 7
|
||||
private const val PLAN_NFC_OPEN = 8
|
||||
private const val PLAN_NFC_CLOSE = 9
|
||||
}
|
||||
|
||||
/** NFC 超时时间(毫秒),可通过 MQTT type=4 更新 */
|
||||
var nfcTimeoutMs: Long = DEFAULT_NFC_TIMEOUT_MS
|
||||
|
||||
private val _uiState = MutableStateFlow(PunchUiState())
|
||||
val uiState: StateFlow<PunchUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** NFC 超时协程 */
|
||||
private var nfcTimeoutJob: Job? = null
|
||||
|
||||
/** 查询当前考勤状态 */
|
||||
fun fetchAttendance() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
val result = safeApiCall { punchApi.getAttendance() }
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
val data = result.data
|
||||
if (data != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
onPunchState = data.onPunchState,
|
||||
offPunchState = data.offPunchState,
|
||||
actualOnTime = data.actualOnTime,
|
||||
actualOffTime = data.actualOffTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Timber.w("查询考勤状态失败: ${result.message}")
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
Timber.w(result.exception, "查询考勤状态网络异常")
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启 NFC 开始打卡
|
||||
* @param punchType 0=上班打卡, 1=下班打卡
|
||||
*/
|
||||
fun startPunch(punchType: Int) {
|
||||
// 防重复触发
|
||||
if (_uiState.value.isNfcScanning) return
|
||||
|
||||
Timber.d("考勤: 开始NFC打卡, punchType=$punchType")
|
||||
_uiState.update { it.copy(isNfcScanning = true, scanningPunchType = punchType) }
|
||||
|
||||
// 1. 开启 NFC + 音效
|
||||
nfcController.open()
|
||||
playFeedback(PLAN_NFC_OPEN)
|
||||
|
||||
// 2. 开始扫描,读到卡号后回调
|
||||
nfcController.startScan { nfcId ->
|
||||
Timber.d("考勤: NFC读到卡号 $nfcId")
|
||||
nfcTimeoutJob?.cancel()
|
||||
handleNfcResult(nfcId, punchType)
|
||||
}
|
||||
|
||||
// 3. 超时自动关闭
|
||||
nfcTimeoutJob = viewModelScope.launch {
|
||||
delay(nfcTimeoutMs)
|
||||
Timber.d("考勤: NFC超时自动关闭")
|
||||
closeNfc()
|
||||
_uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消 NFC 扫描(用户手动取消) */
|
||||
fun cancelNfcScan() {
|
||||
if (!_uiState.value.isNfcScanning) return
|
||||
Timber.d("考勤: 手动取消NFC扫描")
|
||||
nfcTimeoutJob?.cancel()
|
||||
closeNfc()
|
||||
_uiState.update { it.copy(isNfcScanning = false, scanningPunchType = -1) }
|
||||
}
|
||||
|
||||
/** NFC 读到卡号后处理 */
|
||||
private fun handleNfcResult(nfcId: String, punchType: Int) {
|
||||
// 关闭 NFC
|
||||
closeNfc()
|
||||
|
||||
// 调用打卡 API
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isNfcScanning = false) }
|
||||
|
||||
val params = hashMapOf<String, Any>(
|
||||
"nfcId" to nfcId,
|
||||
"punchType" to punchType
|
||||
)
|
||||
val result = safeApiCall { punchApi.nfcPunch(params) }
|
||||
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
Timber.d("考勤: 打卡成功")
|
||||
playFeedback(PLAN_PUNCH_SUCCESS)
|
||||
// 刷新考勤状态
|
||||
fetchAttendance()
|
||||
// 通知打卡成功(UI 用于收回面板等)
|
||||
_uiState.update {
|
||||
it.copy(punchResult = PunchResult.SUCCESS, scanningPunchType = -1)
|
||||
}
|
||||
// 屏幕亮度:上班亮、下班暗
|
||||
if (punchType == 1) {
|
||||
screenController.turnOff()
|
||||
} else {
|
||||
screenController.turnOn()
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Timber.w("考勤: 打卡失败 - ${result.message}")
|
||||
playFeedback(PLAN_PUNCH_FAIL)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
punchResult = PunchResult.FAIL,
|
||||
errorMessage = result.message,
|
||||
scanningPunchType = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
Timber.w(result.exception, "考勤: 打卡网络异常")
|
||||
playFeedback(PLAN_PUNCH_FAIL)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
punchResult = PunchResult.FAIL,
|
||||
errorMessage = "网络异常",
|
||||
scanningPunchType = -1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销打卡 */
|
||||
fun revokePunch() {
|
||||
viewModelScope.launch {
|
||||
val result = safeApiCall { punchApi.revokePunch(hashMapOf()) }
|
||||
when (result) {
|
||||
is ApiResult.Success -> {
|
||||
Timber.d("考勤: 撤销打卡成功")
|
||||
playFeedback(PLAN_PUNCH_SUCCESS)
|
||||
// 刷新状态
|
||||
fetchAttendance()
|
||||
// 恢复屏幕亮度
|
||||
screenController.turnOn()
|
||||
_uiState.update { it.copy(punchResult = PunchResult.REVOKE_SUCCESS) }
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
Timber.w("考勤: 撤销失败 - ${result.message}")
|
||||
_uiState.update {
|
||||
it.copy(punchResult = PunchResult.FAIL, errorMessage = result.message)
|
||||
}
|
||||
}
|
||||
is ApiResult.NetworkError -> {
|
||||
Timber.w(result.exception, "考勤: 撤销网络异常")
|
||||
_uiState.update {
|
||||
it.copy(punchResult = PunchResult.FAIL, errorMessage = "网络异常")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 MQTT 上下班状态变更(type=5) */
|
||||
fun handleWorkStateChange(isWorking: Boolean) {
|
||||
Timber.d("考勤: MQTT工作状态变更, isWorking=$isWorking")
|
||||
if (isWorking) {
|
||||
screenController.turnOn()
|
||||
} else {
|
||||
screenController.turnOff()
|
||||
}
|
||||
// 刷新考勤状态
|
||||
fetchAttendance()
|
||||
}
|
||||
|
||||
/** 消费打卡结果(UI 读取后重置) */
|
||||
fun consumePunchResult() {
|
||||
_uiState.update { it.copy(punchResult = null, errorMessage = null) }
|
||||
}
|
||||
|
||||
/** 关闭 NFC 硬件 + 播放关闭音效 */
|
||||
private fun closeNfc() {
|
||||
nfcController.stopScan()
|
||||
nfcController.close()
|
||||
playFeedback(PLAN_NFC_CLOSE)
|
||||
}
|
||||
|
||||
/** 播放震动+音效反馈 */
|
||||
private fun playFeedback(planId: Int) {
|
||||
val pattern = VibrationDefaults.getPattern(planId)
|
||||
if (pattern != null) {
|
||||
vibrationController.executePattern(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// 清理 NFC 资源
|
||||
nfcTimeoutJob?.cancel()
|
||||
if (nfcController.isOpen()) {
|
||||
nfcController.stopScan()
|
||||
nfcController.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 打卡面板 UI 状态 */
|
||||
data class PunchUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val onPunchState: Int = 0,
|
||||
val offPunchState: Int = 0,
|
||||
val actualOnTime: String? = null,
|
||||
val actualOffTime: String? = null,
|
||||
val isNfcScanning: Boolean = false,
|
||||
val scanningPunchType: Int = -1,
|
||||
val punchResult: PunchResult? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
/** 打卡操作结果(一次性事件) */
|
||||
enum class PunchResult {
|
||||
SUCCESS,
|
||||
FAIL,
|
||||
REVOKE_SUCCESS
|
||||
}
|
||||
16
app/src/main/res/drawable/bg_btn_primary.xml
Normal file
16
app/src/main/res/drawable/bg_btn_primary.xml
Normal 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="#FF1565C0" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF1E88E5" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
16
app/src/main/res/drawable/bg_btn_secondary.xml
Normal file
16
app/src/main/res/drawable/bg_btn_secondary.xml
Normal 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="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF333333" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -1,9 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 首页容器:固定状态栏 + ViewPager2 左右滑动 -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<!-- 首页容器:固定状态栏 + ViewPager2 + 打卡面板覆盖层 -->
|
||||
<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:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="21dp"
|
||||
android:paddingTop="27dp"
|
||||
@@ -24,3 +29,18 @@
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 考勤打卡面板(覆盖层,默认隐藏) -->
|
||||
<com.xiaoqu.watch.ui.punch.PunchPanelView
|
||||
android:id="@+id/punchPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<!-- 弹窗容器(最顶层,用于 QuTipDialog) -->
|
||||
<FrameLayout
|
||||
android:id="@+id/dialogContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
114
app/src/main/res/layout/view_punch_panel.xml
Normal file
114
app/src/main/res/layout/view_punch_panel.xml
Normal file
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 考勤打卡面板(嵌入首页,下拉展开) -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<!-- 半透明遮罩(点击收回面板) -->
|
||||
<View
|
||||
android:id="@+id/overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#80000000" />
|
||||
|
||||
<!-- 面板内容区 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/panelContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:background="@color/background"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="28dp"
|
||||
android:paddingTop="27dp"
|
||||
android:paddingEnd="28dp"
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<!-- 时间显示(大字体,老年用户) -->
|
||||
<TextView
|
||||
android:id="@+id/tvPunchTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="00:00"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<!-- 日期显示 -->
|
||||
<TextView
|
||||
android:id="@+id/tvPunchDate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="1月1日 星期一"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<!-- NFC 扫描提示(默认隐藏) -->
|
||||
<TextView
|
||||
android:id="@+id/tvNfcHint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="请将手表贴近打卡信标"
|
||||
android:textColor="@color/warning"
|
||||
android:textSize="18sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 按钮区域 -->
|
||||
<LinearLayout
|
||||
android:id="@+id/buttonContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- 上班打卡按钮 -->
|
||||
<TextView
|
||||
android:id="@+id/btnPunchIn"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_btn_primary"
|
||||
android:gravity="center"
|
||||
android:text="上班打卡"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="20sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- 下班打卡按钮 -->
|
||||
<TextView
|
||||
android:id="@+id/btnPunchOut"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_btn_primary"
|
||||
android:gravity="center"
|
||||
android:text="下班打卡"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="20sp"
|
||||
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>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -59,12 +59,6 @@
|
||||
<argument android:name="taskId" android:defaultValue="0L" app:argType="long" />
|
||||
</fragment>
|
||||
|
||||
<!-- 打卡页 -->
|
||||
<fragment
|
||||
android:id="@+id/punchFragment"
|
||||
android:name="com.xiaoqu.watch.ui.punch.PunchFragment"
|
||||
android:label="打卡" />
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<fragment
|
||||
android:id="@+id/infoFragment"
|
||||
|
||||
Reference in New Issue
Block a user