diff --git a/app/src/main/java/com/xiaoqu/watch/data/punch/PunchStatus.kt b/app/src/main/java/com/xiaoqu/watch/data/punch/PunchStatus.kt new file mode 100644 index 0000000..a3fbb9a --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/data/punch/PunchStatus.kt @@ -0,0 +1,20 @@ +package com.xiaoqu.watch.data.punch + +import com.google.gson.annotations.SerializedName + +/** + * 考勤状态数据类 + * 对应 watchTask/myCurrentAttendance API 返回 + * 来源:discovery-map.md 考勤章节(已验证字段名) + */ +data class PunchStatus( + /** 上班打卡状态:0=未上班, 1=已上班 */ + @SerializedName("onPunchState") val onPunchState: Int = 0, + /** 下班打卡状态:1=已下班, 其他=未下班 */ + @SerializedName("offPunchState") val offPunchState: Int = 0 +) { + /** 是否已上班 */ + val isOnDuty: Boolean get() = onPunchState == 1 + /** 是否已下班 */ + val isOffDuty: Boolean get() = offPunchState == 1 +} diff --git a/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt b/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt index 49e5471..bb37484 100644 --- a/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt +++ b/app/src/main/java/com/xiaoqu/watch/di/NetworkModule.kt @@ -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) + } } diff --git a/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt b/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt new file mode 100644 index 0000000..ce1ad33 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/network/api/PunchApi.kt @@ -0,0 +1,27 @@ +package com.xiaoqu.watch.network.api + +import com.xiaoqu.watch.data.punch.PunchStatus +import com.xiaoqu.watch.network.ApiResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +/** + * 考勤打卡 API 接口 + * 来源:discovery-map.md 考勤章节 + * 注意:myCurrentAttendance 标注为 GET,如返回 405 需改为 POST + */ +interface PunchApi { + + /** 查询当前考勤状态 */ + @GET("watchTask/myCurrentAttendance") + suspend fun getAttendance(): ApiResponse + + /** 上班/下班打卡 */ + @POST("watchTask/onAndOffPunch") + suspend fun onAndOffPunch(@Body params: HashMap): ApiResponse + + /** 撤销打卡 */ + @POST("watchTask/revokePunch") + suspend fun revokePunch(@Body params: HashMap): ApiResponse +} diff --git a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt index 53bea71..ca8698c 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/home/HomeFragment.kt @@ -95,6 +95,9 @@ class HomeFragment : BaseFragment() { // 监听 MQTT 事件 observeEvents() + + // 下拉手势 → 进入考勤页 + setupPullDownGesture() } // ===== 主页 ===== @@ -269,4 +272,43 @@ class HomeFragment : BaseFragment() { val bundle = bundleOf("tableStatus" to tableStatus) findNavController().navigate(R.id.action_home_to_taskList, bundle) } + + /** 设置下拉手势 → 进入考勤打卡页 */ + @android.annotation.SuppressLint("ClickableViewAccessibility") + private fun setupPullDownGesture() { + val gestureDetector = android.view.GestureDetector( + requireContext(), + object : android.view.GestureDetector.SimpleOnGestureListener() { + override fun onFling( + e1: android.view.MotionEvent?, + e2: android.view.MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (e1 == null) return false + val dy = e2.y - e1.y + val dx = e2.x - e1.x + // 下拉(dy > 0)且垂直幅度 > 水平 + if (dy > 80 && kotlin.math.abs(dy) > kotlin.math.abs(dx)) { + navigateToPunch() + return true + } + return false + } + } + ) + + // 给 ViewPager2 内部的 RecyclerView 添加触摸监听 + binding.viewPager.getChildAt(0)?.setOnTouchListener { _, event -> + gestureDetector.onTouchEvent(event) + false + } + } + + /** 跳转到考勤打卡页 */ + private fun navigateToPunch() { + val currentDest = findNavController().currentDestination?.id + if (currentDest != R.id.homeFragment) return + findNavController().navigate(R.id.action_home_to_punch) + } } diff --git a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchFragment.kt index 2cc15bc..846d618 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/punch/PunchFragment.kt @@ -1,15 +1,313 @@ package com.xiaoqu.watch.ui.punch +import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.xiaoqu.watch.R +import com.xiaoqu.watch.data.punch.PunchStatus import com.xiaoqu.watch.databinding.FragmentPunchBinding +import com.xiaoqu.watch.device.screen.ScreenController +import com.xiaoqu.watch.event.AppEvent +import com.xiaoqu.watch.event.EventBus +import com.xiaoqu.watch.network.ApiResult +import com.xiaoqu.watch.network.api.PunchApi +import com.xiaoqu.watch.network.safeApiCall import com.xiaoqu.watch.ui.common.BaseFragment +import com.xiaoqu.watch.ui.widget.QuConfirmDialog +import com.xiaoqu.watch.ui.widget.QuTipDialog import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +/** + * 考勤打卡页面 + * 入口:首页下拉手势 + * 3 种状态:未上班→上班打卡→已上班→下班打卡/撤销 + * + * 流程(基于 discovery-map.md 考勤章节 + baseline/05 流程6): + * 上班:蓝牙识别1.5s → 确认弹窗 → POST onAndOffPunch → 成功 + * 下班:蓝牙识别1.5s → 直接提交 → POST onAndOffPunch → 成功+低耗电 + * 撤销:确认弹窗 → POST revokePunch → 恢复上班状态 + */ @AndroidEntryPoint class PunchFragment : BaseFragment() { + @Inject lateinit var punchApi: PunchApi + @Inject lateinit var screenController: ScreenController + @Inject lateinit var eventBus: EventBus + + /** 当前考勤状态 */ + private var punchStatus: PunchStatus? = null + + /** 提示弹窗 */ + private lateinit var tipDialog: QuTipDialog + + /** 确认弹窗 */ + private lateinit var confirmDialog: QuConfirmDialog + override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPunchBinding { return FragmentPunchBinding.inflate(inflater, container, false) } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // 初始化弹窗 + val dialogContainer = requireActivity().findViewById(R.id.dialog_container) + tipDialog = QuTipDialog(dialogContainer) + confirmDialog = QuConfirmDialog(dialogContainer) + + // 获取考勤状态 + fetchAttendance() + + // 监听系统状态事件(电量更新状态栏) + observeEvents() + } + + // ===== 数据获取 ===== + + /** 获取当前考勤状态 */ + private fun fetchAttendance() { + viewLifecycleOwner.lifecycleScope.launch { + val result = safeApiCall { punchApi.getAttendance() } + when (result) { + is ApiResult.Success -> { + punchStatus = result.data + displayStatus(result.data ?: PunchStatus()) + } + is ApiResult.Error -> { + Timber.w("考勤: API 错误 ${result.code}") + // 默认显示未上班 + displayStatus(PunchStatus()) + } + is ApiResult.NetworkError -> { + Timber.w("考勤: 网络异常") + displayStatus(PunchStatus()) + } + } + } + } + + // ===== UI 显示 ===== + + /** 根据考勤状态更新页面显示(基于业务逻辑矩阵) */ + private fun displayStatus(status: PunchStatus) { + // 重置所有可选元素 + binding.btnRevoke.visibility = View.GONE + binding.lowPowerHint.visibility = View.GONE + + when { + // 未上班 → 显示「上班打卡」 + !status.isOnDuty -> { + binding.tvPunchStatus.text = "未上班" + binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary)) + binding.btnPunch.text = "上班打卡" + binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) + binding.btnPunch.setOnClickListener { startPunch(0) } + } + + // 已上班 + 已下班 → 显示「撤销」+「下班打卡」+ 低耗电提示 + status.isOnDuty && status.isOffDuty -> { + binding.tvPunchStatus.text = "已下班" + binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.text_secondary)) + binding.btnPunch.text = "下班打卡" + binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) + binding.btnPunch.setOnClickListener { startPunch(1) } + binding.btnRevoke.visibility = View.VISIBLE + binding.btnRevoke.setOnClickListener { doRevoke() } + binding.lowPowerHint.visibility = View.VISIBLE + } + + // 已上班 + 未下班 → 显示「下班打卡」 + status.isOnDuty && !status.isOffDuty -> { + binding.tvPunchStatus.text = "已上班" + binding.tvPunchStatus.setTextColor(requireContext().getColor(R.color.success)) + binding.btnPunch.text = "下班打卡" + binding.btnPunch.setBackgroundColor(requireContext().getColor(R.color.primary)) + binding.btnPunch.setOnClickListener { startPunch(1) } + } + } + } + + // ===== 打卡操作 ===== + + /** + * 开始打卡流程 + * 1. 显示蓝牙识别 Loading(1.5秒) + * 2. 读取 beacons(当前用模拟数据) + * 3. 上班:弹确认框;下班:直接提交 + */ + private fun startPunch(punchType: Int) { + // 显示蓝牙识别提示 + tipDialog.show( + status = QuTipDialog.Status.LOCATION, + title = "蓝牙正在识别…", + back = false + ) + + viewLifecycleOwner.lifecycleScope.launch { + // 等待 1.5 秒(模拟蓝牙扫描采集) + delay(1500) + tipDialog.dismiss() + + // 读取蓝牙信标(TODO: 后续从 store.beacons 读取,当前用模拟数据) + val beaconMacs = getMockBeaconMacs() + + if (beaconMacs.isEmpty()) { + // 无信标 → 提示失败 + tipDialog.show( + status = QuTipDialog.Status.WARNING, + title = "打卡失败", + desc = "未搜索到蓝牙信标,请重试", + back = true, step = 0, countdown = 3 + ) + return@launch + } + + if (punchType == 0) { + // 上班 → 弹确认弹窗 + confirmDialog.showText( + text = "确定上班打卡?", + onConfirm = { doPunch(punchType, beaconMacs) } + ) + } else { + // 下班 → 不弹确认,直接提交 + doPunch(punchType, beaconMacs) + } + } + } + + /** 执行打卡 API 调用 */ + private fun doPunch(punchType: Int, beaconMacs: List) { + viewLifecycleOwner.lifecycleScope.launch { + val params = hashMapOf( + "beaconMacs" to beaconMacs, + "punchType" to punchType + ) + val result = safeApiCall { punchApi.onAndOffPunch(params) } + + when (result) { + is ApiResult.Success -> { + Timber.d("考勤: 打卡成功 punchType=$punchType") + tipDialog.show( + status = QuTipDialog.Status.SUCCESS, + title = "打卡成功", + back = true, step = 0, countdown = 2 + ) + + // 副作用:更新工作状态和屏幕亮度 + if (punchType == 0) { + // 上班 → 屏幕正常亮度 + screenController.turnOn() + emitWorkState(true) + } else { + // 下班 → 低耗电(熄屏) + screenController.turnOff() + emitWorkState(false) + } + + // 刷新考勤状态 + fetchAttendance() + } + is ApiResult.Error -> { + tipDialog.show( + status = QuTipDialog.Status.ERROR, + title = "打卡失败", + desc = result.message, + back = true, step = 0, countdown = 3 + ) + } + is ApiResult.NetworkError -> { + tipDialog.show( + status = QuTipDialog.Status.ERROR, + title = "网络异常", + back = true, step = 0, countdown = 3 + ) + } + } + } + } + + /** 撤销打卡 */ + private fun doRevoke() { + confirmDialog.showText( + text = "确定撤销打卡?", + onConfirm = { + viewLifecycleOwner.lifecycleScope.launch { + val params = hashMapOf() + val result = safeApiCall { punchApi.revokePunch(params) } + + when (result) { + is ApiResult.Success -> { + Timber.d("考勤: 撤销成功") + tipDialog.show( + status = QuTipDialog.Status.SUCCESS, + title = "撤销成功", + back = true, step = 0, countdown = 2 + ) + // 恢复上班状态 + screenController.turnOn() + emitWorkState(true) + fetchAttendance() + } + is ApiResult.Error -> { + tipDialog.show( + status = QuTipDialog.Status.ERROR, + title = "撤销失败", + desc = result.message, + back = true, step = 0, countdown = 3 + ) + } + is ApiResult.NetworkError -> { + tipDialog.show( + status = QuTipDialog.Status.ERROR, + title = "网络异常", + back = true, step = 0, countdown = 3 + ) + } + } + } + } + ) + } + + // ===== 辅助方法 ===== + + /** + * 模拟蓝牙信标 MAC 列表 + * TODO: 蓝牙扫描模块完成后,改为从 store.beacons 读取 + */ + private fun getMockBeaconMacs(): List { + return listOf("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66") + } + + /** 发送工作状态变更事件 */ + private fun emitWorkState(isWorking: Boolean) { + viewLifecycleOwner.lifecycleScope.launch { + eventBus.emit(AppEvent.WorkStateChanged(isWorking)) + } + } + + /** 监听系统状态事件 */ + private fun observeEvents() { + viewLifecycleOwner.lifecycleScope.launch { + eventBus.events.collect { event -> + when (event) { + is AppEvent.BatteryChanged -> { + binding.statusBar.updateBattery(event.level, event.isCharging) + } + is AppEvent.BluetoothStateChanged -> { + binding.statusBar.updateBluetooth(event.isOn) + } + else -> {} + } + } + } + } } diff --git a/app/src/main/res/layout/fragment_punch.xml b/app/src/main/res/layout/fragment_punch.xml index 14c4a2e..bfc23f6 100644 --- a/app/src/main/res/layout/fragment_punch.xml +++ b/app/src/main/res/layout/fragment_punch.xml @@ -1,9 +1,117 @@ - + + android:background="@color/background" + android:orientation="vertical"> - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index 8f8d0e4..8c9a02a 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -28,6 +28,9 @@ + +