diff --git a/app/src/main/java/com/xiaoqu/watch/data/task/InspectScene.kt b/app/src/main/java/com/xiaoqu/watch/data/task/InspectScene.kt index 83ad688..d1d184c 100644 --- a/app/src/main/java/com/xiaoqu/watch/data/task/InspectScene.kt +++ b/app/src/main/java/com/xiaoqu/watch/data/task/InspectScene.kt @@ -12,7 +12,9 @@ data class InspectScene( /** 是否已打卡(1=已打卡,0=未打卡) */ @SerializedName("isCheck") val isCheck: Int = 0, /** 场景状态 */ - @SerializedName("status") val status: Int = 0 + @SerializedName("status") val status: Int = 0, + /** 打卡时间(服务端返回,如 "14:35") */ + @SerializedName("checkTime") val checkTime: String? = null ) { /** 是否已完成打卡 */ val checked: Boolean get() = isCheck == 1 diff --git a/app/src/main/java/com/xiaoqu/watch/data/task/TaskDetail.kt b/app/src/main/java/com/xiaoqu/watch/data/task/TaskDetail.kt index d924ea8..75b9d32 100644 --- a/app/src/main/java/com/xiaoqu/watch/data/task/TaskDetail.kt +++ b/app/src/main/java/com/xiaoqu/watch/data/task/TaskDetail.kt @@ -56,14 +56,17 @@ data class TaskDetail( @SerializedName("taskRequire") val taskRequire: String? = null, /** 图片附件 */ @SerializedName("uploadPic") val uploadPic: List? = null, - /** 语音附件 */ - @SerializedName("voice") val voice: List? = null, + /** 语音附件(用户上报任务可能包含语音描述) */ + @SerializedName("voice") val voice: List? = null, /** 打卡标志 */ @SerializedName("clockFlag") val clockFlag: Int = 0 ) { /** 是否有打卡地点(决定打卡方式:有=NFC,无=直接确认) */ val hasPosition: Boolean get() = !taskPositions.isNullOrEmpty() + /** 是否有语音附件(用户上报任务) */ + val hasVoice: Boolean get() = !voice.isNullOrEmpty() + /** 地点显示文字(多个用逗号分隔) */ val positionText: String get() = taskPositions?.joinToString(",") ?: "" diff --git a/app/src/main/java/com/xiaoqu/watch/data/task/VoiceItem.kt b/app/src/main/java/com/xiaoqu/watch/data/task/VoiceItem.kt new file mode 100644 index 0000000..960b093 --- /dev/null +++ b/app/src/main/java/com/xiaoqu/watch/data/task/VoiceItem.kt @@ -0,0 +1,14 @@ +package com.xiaoqu.watch.data.task + +import com.google.gson.annotations.SerializedName + +/** + * 语音附件数据类 + * 用户上报任务可能包含语音描述 + */ +data class VoiceItem( + /** 语音文件 URL */ + @SerializedName("url") val url: String = "", + /** 语音时长(秒) */ + @SerializedName("voiceLength") val voiceLength: String = "" +) diff --git a/app/src/main/java/com/xiaoqu/watch/ui/task/TaskListFragment.kt b/app/src/main/java/com/xiaoqu/watch/ui/task/TaskListFragment.kt index 1c43d90..e9833f3 100644 --- a/app/src/main/java/com/xiaoqu/watch/ui/task/TaskListFragment.kt +++ b/app/src/main/java/com/xiaoqu/watch/ui/task/TaskListFragment.kt @@ -1,6 +1,8 @@ package com.xiaoqu.watch.ui.task import android.annotation.SuppressLint +import android.graphics.Typeface +import android.media.MediaPlayer import android.os.Bundle import android.view.GestureDetector import android.view.LayoutInflater @@ -62,6 +64,9 @@ class TaskListFragment : BaseFragment() { /** 提示弹窗 */ private lateinit var tipDialog: QuTipDialog + /** 语音播放器(用户上报任务的语音附件) */ + private var mediaPlayer: MediaPlayer? = null + /** 手势检测(上下滑切换任务) */ private lateinit var gestureDetector: GestureDetector @@ -117,6 +122,11 @@ class TaskListFragment : BaseFragment() { fetchTaskIds() } + override fun onDestroyView() { + stopVoice() + super.onDestroyView() + } + // ===== 数据获取 ===== /** 第一步:获取任务 ID 列表 */ @@ -173,6 +183,8 @@ class TaskListFragment : BaseFragment() { /** 第二步:获取当前任务详情 */ private fun fetchCurrentDetail() { if (taskList.isEmpty()) return + // 切换任务时停止语音播放 + stopVoice() val taskId = taskList[taskIndex].id viewLifecycleOwner.lifecycleScope.launch { @@ -226,15 +238,15 @@ class TaskListFragment : BaseFragment() { binding.tvEmpty.visibility = View.GONE binding.loadingWrap.visibility = View.GONE - // 标题栏:状态名 - binding.tvTitle.text = when (currentStatus) { - 2 -> "接单池" - 3 -> "待打卡" - 4 -> "待完成" - else -> "任务" + // 标题栏 + binding.tvTitle.text = statusTitle() + // 页码:接单池不显示(防止用户比较挑选),其他正常显示 + if (currentStatus == 2) { + binding.tvPageNum.visibility = View.GONE + } else { + binding.tvPageNum.visibility = View.VISIBLE + binding.tvPageNum.text = "${taskIndex + 1}/${taskList.size}" } - // 页码 - binding.tvPageNum.text = "${taskIndex + 1}/${taskList.size}" // 任务名(始终显示,最大最突出) binding.tvTaskName.text = detail.displayName @@ -244,6 +256,7 @@ class TaskListFragment : BaseFragment() { binding.tvTimeInfo.visibility = View.GONE binding.tvPoints.visibility = View.GONE binding.tvNote.visibility = View.GONE + binding.btnVoice.visibility = View.GONE binding.blockGoWhere.visibility = View.GONE binding.blockHowTo.visibility = View.GONE binding.blockNoScene.visibility = View.GONE @@ -274,19 +287,17 @@ class TaskListFragment : BaseFragment() { binding.tvTimeInfo.text = timeLines.joinToString("\n") binding.tvTimeInfo.visibility = View.VISIBLE } - // 积分 - if (detail.point > 0) { - binding.tvPoints.text = "积分: ${detail.pointText}" - binding.tvPoints.visibility = View.VISIBLE - } + // 接单池不显示积分(防止用户挑肥拣瘦,接单后才能看到积分) // 备注/描述 showNote(detail) + showVoice(detail) } // ===== 待打卡:指引去哪+怎么做 ===== 3 -> { - // 巡检时段 + // 时间信息:先巡检时段(直接写入),再截止时间(追加模式) showInspectTime(detail) + showDeadline(detail) // 备注 showNote(detail) @@ -303,8 +314,9 @@ class TaskListFragment : BaseFragment() { // ===== 进行中/待完成:地点+打卡时间+完成指引 ===== 4 -> { - // 巡检时段 + // 时间信息:先巡检时段(直接写入),再截止时间(追加模式) showInspectTime(detail) + showDeadline(detail) if (detail.taskType == 5) { // ===== 巡检任务:显示场景打卡清单 ===== showInspectScenes(detail) @@ -315,9 +327,10 @@ class TaskListFragment : BaseFragment() { binding.tvPosition.visibility = View.VISIBLE } showNote(detail) + showVoice(detail) if (!detail.confirmTime.isNullOrEmpty()) { - binding.tvCheckinTime.text = "${detail.confirmTime} \u5DF2\u6253\u5361" - binding.tvCheckinTime.visibility = View.VISIBLE + binding.tvCheckinTime.text = "${detail.confirmTime} 已打卡" + binding.tvCheckinTime.visibility = View.VISIBLE } binding.blockInProgress.visibility = View.VISIBLE } @@ -333,6 +346,28 @@ class TaskListFragment : BaseFragment() { } } + /** + * 显示截止/结束时间(所有状态通用) + * 优先级:expireTime > preFinishTime > executeTimeEnd + */ + private fun showDeadline(detail: TaskDetail) { + val deadlineLines = mutableListOf() + if (!detail.expireTime.isNullOrEmpty()) { + deadlineLines.add("截止: ${detail.expireTime}") + } + if (!detail.preFinishTime.isNullOrEmpty()) { + deadlineLines.add("要求: ${detail.preFinishTime}完成") + } + if (deadlineLines.isNotEmpty()) { + // 复用 tvTimeInfo(如果巡检时段已占用,则追加) + val existing = if (binding.tvTimeInfo.visibility == View.VISIBLE) { + binding.tvTimeInfo.text.toString() + "\n" + } else "" + binding.tvTimeInfo.text = existing + deadlineLines.joinToString("\n") + binding.tvTimeInfo.visibility = View.VISIBLE + } + } + /** 显示巡检时段(仅 taskType=5 时,拆为开始/结束两行) */ private fun showInspectTime(detail: TaskDetail) { if (detail.taskType == 5) { @@ -359,9 +394,64 @@ class TaskListFragment : BaseFragment() { } } + /** + * 显示语音播放按钮(用户上报任务 taskType=3/4 可能有语音附件) + * 点击播放/暂停语音 + */ + private fun showVoice(detail: TaskDetail) { + if (!detail.hasVoice) return + + val voiceItem = detail.voice!!.first() + if (voiceItem.url.isEmpty()) return + + // 显示按钮和时长 + binding.tvVoiceDuration.text = "${voiceItem.voiceLength}\"" + binding.btnVoice.visibility = View.VISIBLE + + // 点击播放/停止 + binding.btnVoice.setOnClickListener { + toggleVoice(voiceItem.url) + } + } + + /** 播放/停止语音 */ + private fun toggleVoice(url: String) { + if (mediaPlayer?.isPlaying == true) { + // 正在播放 → 停止 + stopVoice() + } else { + // 没有播放 → 开始 + stopVoice() // 清理上一个 + mediaPlayer = MediaPlayer().apply { + setDataSource(url) + setOnPreparedListener { start() } + setOnCompletionListener { stopVoice() } + setOnErrorListener { _, _, _ -> + Timber.w("语音播放失败: $url") + stopVoice() + true + } + prepareAsync() + } + } + } + + /** 停止并释放 MediaPlayer */ + private fun stopVoice() { + mediaPlayer?.let { + if (it.isPlaying) it.stop() + it.release() + } + mediaPlayer = null + } + /** * 显示巡检场景打卡清单(taskType=5,status=4) - * 每个场景一行:✅ 已打卡 / ❌ 未打卡 + * + * 三种视觉层级: + * - 已打卡:绿色 ● + 场景名 + 打卡时间(确定感) + * - 下一个待打卡:橙色高亮 ▶ + 大字体(行动引导) + * - 后续未打卡:灰色 ○ + 小字体(减少干扰) */ private fun showInspectScenes(detail: TaskDetail) { val scenes = detail.taskInspectScenes @@ -377,22 +467,71 @@ class TaskListFragment : BaseFragment() { binding.tvInspectTitle.text = "打卡进度 $checkedCount/$totalCount" binding.blockInspect.visibility = View.VISIBLE - // 动态添加场景行(绿色圆点=已打卡,灰色圆圈=未打卡) + // 找到第一个未打卡的场景索引(即"下一个") + val nextIndex = scenes.indexOfFirst { !it.checked } + + // 动态添加场景行 binding.sceneList.removeAllViews() - for (scene in scenes) { + var nextView: View? = null + + for ((index, scene) in scenes.withIndex()) { val tv = android.widget.TextView(requireContext()).apply { - if (scene.checked) { - text = " \u25CF ${scene.name}" // ● 实心圆 - setTextColor(requireContext().getColor(R.color.success)) - } else { - text = " \u25CB ${scene.name}" // ○ 空心圆 - setTextColor(requireContext().getColor(R.color.text_secondary)) + when { + // ===== 已打卡:绿色 + 打卡时间 ===== + scene.checked -> { + val timeStr = if (!scene.checkTime.isNullOrEmpty()) " ${scene.checkTime}" else "" + text = " \u25CF ${scene.name}$timeStr" + setTextColor(requireContext().getColor(R.color.success)) + textSize = 26f + setPadding(0, 11, 0, 11) + } + // ===== 下一个待打卡:橙色高亮 + 大字体 ===== + index == nextIndex -> { + text = " \u25B6 ${scene.name}" // ▶ 三角箭头 + setTextColor(requireContext().getColor(R.color.warning)) + textSize = 32f + setTypeface(null, Typeface.BOLD) + setPadding(0, 16, 0, 16) + } + // ===== 后续未打卡:灰色 + 小字体 ===== + else -> { + text = " \u25CB ${scene.name}" // ○ 空心圆 + setTextColor(requireContext().getColor(R.color.text_secondary)) + textSize = 24f + setPadding(0, 9, 0, 9) + } } - textSize = 28f - setPadding(0, 13, 0, 13) } binding.sceneList.addView(tv) + // 记录下一个待打卡的 View,用于自动滚动 + if (index == nextIndex) nextView = tv } + + // 自动滚动到当前进度位置(场景多超出屏幕时) + nextView?.let { target -> + binding.scrollView.post { + // 计算目标 View 在 ScrollView 中的位置 + val scrollTo = calculateScrollTarget(target) + binding.scrollView.smoothScrollTo(0, scrollTo) + } + } + } + + /** + * 计算目标 View 在 ScrollView 中的滚动位置 + * 将目标 View 滚动到屏幕中间偏上的位置 + */ + private fun calculateScrollTarget(target: View): Int { + // 累加 target 相对于 ScrollView 内容的 Y 偏移 + var offsetY = 0 + var current: View? = target + while (current != null && current != binding.scrollView) { + offsetY += current.top + current = current.parent as? View + } + // 滚动到目标位置偏上 1/3 屏高,让用户能看到上下文 + val screenOffset = binding.scrollView.height / 3 + return (offsetY - screenOffset).coerceAtLeast(0) } /** @@ -580,18 +719,21 @@ class TaskListFragment : BaseFragment() { // ===== 状态显示 ===== + /** 获取当前状态对应的标题 */ + private fun statusTitle(): String = when (currentStatus) { + 2 -> "新任务" + 3 -> "待打卡" + 4 -> "待完成" + else -> "任务列表" + } + /** 显示 Loading */ private fun showLoading() { binding.taskContent.visibility = View.GONE binding.tvEmpty.visibility = View.GONE binding.loadingWrap.visibility = View.VISIBLE binding.btnAction.visibility = View.GONE - binding.tvTitle.text = when (currentStatus) { - 2 -> "接单池" - 3 -> "待打卡" - 4 -> "待完成" - else -> "任务列表" - } + binding.tvTitle.text = statusTitle() } /** 显示空状态 */ @@ -600,11 +742,6 @@ class TaskListFragment : BaseFragment() { binding.tvEmpty.visibility = View.VISIBLE binding.loadingWrap.visibility = View.GONE binding.btnAction.visibility = View.GONE - binding.tvTitle.text = when (currentStatus) { - 2 -> "接单池" - 3 -> "待打卡" - 4 -> "待完成" - else -> "任务列表" - } + binding.tvTitle.text = statusTitle() } } diff --git a/app/src/main/res/drawable/bg_btn_voice.xml b/app/src/main/res/drawable/bg_btn_voice.xml new file mode 100644 index 0000000..199bc7a --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_voice.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_task_list.xml b/app/src/main/res/layout/fragment_task_list.xml index 9399f60..79916c2 100644 --- a/app/src/main/res/layout/fragment_task_list.xml +++ b/app/src/main/res/layout/fragment_task_list.xml @@ -134,6 +134,38 @@ android:layout_marginBottom="11dp" android:visibility="gone" /> + + + + + + + + + + +