feat: 任务页面布局优化(接单池防挑选+截止时间+巡检引导+语音播放)

1. 接单池改名"新任务"、隐藏积分和页码(防止用户挑肥拣瘦)
2. 待打卡/待完成页面增加截止时间显示
3. 巡检场景三层视觉:已打卡绿色+时间、下一个橙色高亮、后续灰色弱化
4. 巡检场景超屏自动滚动到当前进度
5. 用户上报任务支持语音播放(橙色药丸按钮)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-05-06 20:46:16 +09:30
parent 2d83986150
commit 75cbb831c5
7 changed files with 256 additions and 44 deletions

View File

@@ -12,7 +12,9 @@ data class InspectScene(
/** 是否已打卡1=已打卡0=未打卡) */ /** 是否已打卡1=已打卡0=未打卡) */
@SerializedName("isCheck") val isCheck: Int = 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 val checked: Boolean get() = isCheck == 1

View File

@@ -56,14 +56,17 @@ data class TaskDetail(
@SerializedName("taskRequire") val taskRequire: String? = null, @SerializedName("taskRequire") val taskRequire: String? = null,
/** 图片附件 */ /** 图片附件 */
@SerializedName("uploadPic") val uploadPic: List<Any>? = null, @SerializedName("uploadPic") val uploadPic: List<Any>? = null,
/** 语音附件 */ /** 语音附件(用户上报任务可能包含语音描述) */
@SerializedName("voice") val voice: List<Any>? = null, @SerializedName("voice") val voice: List<VoiceItem>? = null,
/** 打卡标志 */ /** 打卡标志 */
@SerializedName("clockFlag") val clockFlag: Int = 0 @SerializedName("clockFlag") val clockFlag: Int = 0
) { ) {
/** 是否有打卡地点(决定打卡方式:有=NFC无=直接确认) */ /** 是否有打卡地点(决定打卡方式:有=NFC无=直接确认) */
val hasPosition: Boolean get() = !taskPositions.isNullOrEmpty() val hasPosition: Boolean get() = !taskPositions.isNullOrEmpty()
/** 是否有语音附件(用户上报任务) */
val hasVoice: Boolean get() = !voice.isNullOrEmpty()
/** 地点显示文字(多个用逗号分隔) */ /** 地点显示文字(多个用逗号分隔) */
val positionText: String get() = taskPositions?.joinToString(",") ?: "" val positionText: String get() = taskPositions?.joinToString(",") ?: ""

View File

@@ -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 = ""
)

View File

@@ -1,6 +1,8 @@
package com.xiaoqu.watch.ui.task package com.xiaoqu.watch.ui.task
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Typeface
import android.media.MediaPlayer
import android.os.Bundle import android.os.Bundle
import android.view.GestureDetector import android.view.GestureDetector
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -62,6 +64,9 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
/** 提示弹窗 */ /** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog private lateinit var tipDialog: QuTipDialog
/** 语音播放器(用户上报任务的语音附件) */
private var mediaPlayer: MediaPlayer? = null
/** 手势检测(上下滑切换任务) */ /** 手势检测(上下滑切换任务) */
private lateinit var gestureDetector: GestureDetector private lateinit var gestureDetector: GestureDetector
@@ -117,6 +122,11 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
fetchTaskIds() fetchTaskIds()
} }
override fun onDestroyView() {
stopVoice()
super.onDestroyView()
}
// ===== 数据获取 ===== // ===== 数据获取 =====
/** 第一步:获取任务 ID 列表 */ /** 第一步:获取任务 ID 列表 */
@@ -173,6 +183,8 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
/** 第二步:获取当前任务详情 */ /** 第二步:获取当前任务详情 */
private fun fetchCurrentDetail() { private fun fetchCurrentDetail() {
if (taskList.isEmpty()) return if (taskList.isEmpty()) return
// 切换任务时停止语音播放
stopVoice()
val taskId = taskList[taskIndex].id val taskId = taskList[taskIndex].id
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -226,15 +238,15 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvEmpty.visibility = View.GONE binding.tvEmpty.visibility = View.GONE
binding.loadingWrap.visibility = View.GONE binding.loadingWrap.visibility = View.GONE
// 标题栏:状态名 // 标题栏
binding.tvTitle.text = when (currentStatus) { binding.tvTitle.text = statusTitle()
2 -> "接单池" // 页码:接单池不显示(防止用户比较挑选),其他正常显示
3 -> "待打卡" if (currentStatus == 2) {
4 -> "待完成" binding.tvPageNum.visibility = View.GONE
else -> "任务" } 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 binding.tvTaskName.text = detail.displayName
@@ -244,6 +256,7 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvTimeInfo.visibility = View.GONE binding.tvTimeInfo.visibility = View.GONE
binding.tvPoints.visibility = View.GONE binding.tvPoints.visibility = View.GONE
binding.tvNote.visibility = View.GONE binding.tvNote.visibility = View.GONE
binding.btnVoice.visibility = View.GONE
binding.blockGoWhere.visibility = View.GONE binding.blockGoWhere.visibility = View.GONE
binding.blockHowTo.visibility = View.GONE binding.blockHowTo.visibility = View.GONE
binding.blockNoScene.visibility = View.GONE binding.blockNoScene.visibility = View.GONE
@@ -274,19 +287,17 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvTimeInfo.text = timeLines.joinToString("\n") binding.tvTimeInfo.text = timeLines.joinToString("\n")
binding.tvTimeInfo.visibility = View.VISIBLE binding.tvTimeInfo.visibility = View.VISIBLE
} }
// 积分 // 接单池不显示积分(防止用户挑肥拣瘦,接单后才能看到积分)
if (detail.point > 0) {
binding.tvPoints.text = "积分: ${detail.pointText}"
binding.tvPoints.visibility = View.VISIBLE
}
// 备注/描述 // 备注/描述
showNote(detail) showNote(detail)
showVoice(detail)
} }
// ===== 待打卡:指引去哪+怎么做 ===== // ===== 待打卡:指引去哪+怎么做 =====
3 -> { 3 -> {
// 巡检时段 // 时间信息:先巡检时段(直接写入),再截止时间(追加模式)
showInspectTime(detail) showInspectTime(detail)
showDeadline(detail)
// 备注 // 备注
showNote(detail) showNote(detail)
@@ -303,8 +314,9 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
// ===== 进行中/待完成:地点+打卡时间+完成指引 ===== // ===== 进行中/待完成:地点+打卡时间+完成指引 =====
4 -> { 4 -> {
// 巡检时段 // 时间信息:先巡检时段(直接写入),再截止时间(追加模式)
showInspectTime(detail) showInspectTime(detail)
showDeadline(detail)
if (detail.taskType == 5) { if (detail.taskType == 5) {
// ===== 巡检任务:显示场景打卡清单 ===== // ===== 巡检任务:显示场景打卡清单 =====
showInspectScenes(detail) showInspectScenes(detail)
@@ -315,8 +327,9 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvPosition.visibility = View.VISIBLE binding.tvPosition.visibility = View.VISIBLE
} }
showNote(detail) showNote(detail)
showVoice(detail)
if (!detail.confirmTime.isNullOrEmpty()) { if (!detail.confirmTime.isNullOrEmpty()) {
binding.tvCheckinTime.text = "${detail.confirmTime} \u5DF2\u6253\u5361" binding.tvCheckinTime.text = "${detail.confirmTime} 已打卡"
binding.tvCheckinTime.visibility = View.VISIBLE binding.tvCheckinTime.visibility = View.VISIBLE
} }
binding.blockInProgress.visibility = View.VISIBLE binding.blockInProgress.visibility = View.VISIBLE
@@ -333,6 +346,28 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
} }
} }
/**
* 显示截止/结束时间(所有状态通用)
* 优先级expireTime > preFinishTime > executeTimeEnd
*/
private fun showDeadline(detail: TaskDetail) {
val deadlineLines = mutableListOf<String>()
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 时,拆为开始/结束两行) */ /** 显示巡检时段(仅 taskType=5 时,拆为开始/结束两行) */
private fun showInspectTime(detail: TaskDetail) { private fun showInspectTime(detail: TaskDetail) {
if (detail.taskType == 5) { if (detail.taskType == 5) {
@@ -359,9 +394,64 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
} }
} }
/**
* 显示语音播放按钮(用户上报任务 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=5status=4 * 显示巡检场景打卡清单taskType=5status=4
* 每个场景一行:✅ 已打卡 / ❌ 未打卡 *
* 三种视觉层级:
* - 已打卡:绿色 ● + 场景名 + 打卡时间(确定感)
* - 下一个待打卡:橙色高亮 ▶ + 大字体(行动引导)
* - 后续未打卡:灰色 ○ + 小字体(减少干扰)
*/ */
private fun showInspectScenes(detail: TaskDetail) { private fun showInspectScenes(detail: TaskDetail) {
val scenes = detail.taskInspectScenes val scenes = detail.taskInspectScenes
@@ -377,22 +467,71 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvInspectTitle.text = "打卡进度 $checkedCount/$totalCount" binding.tvInspectTitle.text = "打卡进度 $checkedCount/$totalCount"
binding.blockInspect.visibility = View.VISIBLE binding.blockInspect.visibility = View.VISIBLE
// 动态添加场景行(绿色圆点=已打卡,灰色圆圈=未打卡 // 找到第一个未打卡的场景索引(即"下一个"
val nextIndex = scenes.indexOfFirst { !it.checked }
// 动态添加场景行
binding.sceneList.removeAllViews() binding.sceneList.removeAllViews()
for (scene in scenes) { var nextView: View? = null
for ((index, scene) in scenes.withIndex()) {
val tv = android.widget.TextView(requireContext()).apply { val tv = android.widget.TextView(requireContext()).apply {
if (scene.checked) { when {
text = " \u25CF ${scene.name}" // ● 实心圆 // ===== 已打卡:绿色 + 打卡时间 =====
scene.checked -> {
val timeStr = if (!scene.checkTime.isNullOrEmpty()) " ${scene.checkTime}" else ""
text = " \u25CF ${scene.name}$timeStr"
setTextColor(requireContext().getColor(R.color.success)) setTextColor(requireContext().getColor(R.color.success))
} else { 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}" // ○ 空心圆 text = " \u25CB ${scene.name}" // ○ 空心圆
setTextColor(requireContext().getColor(R.color.text_secondary)) 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) 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<FragmentTaskListBinding>() {
// ===== 状态显示 ===== // ===== 状态显示 =====
/** 获取当前状态对应的标题 */
private fun statusTitle(): String = when (currentStatus) {
2 -> "新任务"
3 -> "待打卡"
4 -> "待完成"
else -> "任务列表"
}
/** 显示 Loading */ /** 显示 Loading */
private fun showLoading() { private fun showLoading() {
binding.taskContent.visibility = View.GONE binding.taskContent.visibility = View.GONE
binding.tvEmpty.visibility = View.GONE binding.tvEmpty.visibility = View.GONE
binding.loadingWrap.visibility = View.VISIBLE binding.loadingWrap.visibility = View.VISIBLE
binding.btnAction.visibility = View.GONE binding.btnAction.visibility = View.GONE
binding.tvTitle.text = when (currentStatus) { binding.tvTitle.text = statusTitle()
2 -> "接单池"
3 -> "待打卡"
4 -> "待完成"
else -> "任务列表"
}
} }
/** 显示空状态 */ /** 显示空状态 */
@@ -600,11 +742,6 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
binding.tvEmpty.visibility = View.VISIBLE binding.tvEmpty.visibility = View.VISIBLE
binding.loadingWrap.visibility = View.GONE binding.loadingWrap.visibility = View.GONE
binding.btnAction.visibility = View.GONE binding.btnAction.visibility = View.GONE
binding.tvTitle.text = when (currentStatus) { binding.tvTitle.text = statusTitle()
2 -> "接单池"
3 -> "待打卡"
4 -> "待完成"
else -> "任务列表"
}
} }
} }

View File

@@ -0,0 +1,24 @@
<?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="#FFCC8820" />
<corners
android:topStartRadius="8dp"
android:bottomStartRadius="8dp"
android:topEndRadius="24dp"
android:bottomEndRadius="24dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#FFEB9A26" />
<corners
android:topStartRadius="8dp"
android:bottomStartRadius="8dp"
android:topEndRadius="24dp"
android:bottomEndRadius="24dp" />
</shape>
</item>
</selector>

View File

@@ -134,6 +134,38 @@
android:layout_marginBottom="11dp" android:layout_marginBottom="11dp"
android:visibility="gone" /> android:visibility="gone" />
<!-- 语音播放按钮(用户上报任务附带语音) -->
<LinearLayout
android:id="@+id/btnVoice"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:background="@drawable/bg_btn_voice"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="24dp"
android:layout_marginBottom="11dp"
android:visibility="gone">
<!-- 播放图标 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="▶"
android:textColor="@color/text_primary"
android:textSize="24sp" />
<!-- 语音时长 -->
<TextView
android:id="@+id/tvVoiceDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginStart="8dp" />
</LinearLayout>
<!-- 指引块1去哪里橙色 --> <!-- 指引块1去哪里橙色 -->
<LinearLayout <LinearLayout
android:id="@+id/blockGoWhere" android:id="@+id/blockGoWhere"

View File

@@ -80,7 +80,7 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="接单池" android:text="新任务"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="20sp" android:textSize="20sp"
android:textStyle="bold" android:textStyle="bold"