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=未打卡) */
@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

View File

@@ -56,14 +56,17 @@ data class TaskDetail(
@SerializedName("taskRequire") val taskRequire: String? = 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
) {
/** 是否有打卡地点(决定打卡方式:有=NFC无=直接确认) */
val hasPosition: Boolean get() = !taskPositions.isNullOrEmpty()
/** 是否有语音附件(用户上报任务) */
val hasVoice: Boolean get() = !voice.isNullOrEmpty()
/** 地点显示文字(多个用逗号分隔) */
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
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<FragmentTaskListBinding>() {
/** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog
/** 语音播放器(用户上报任务的语音附件) */
private var mediaPlayer: MediaPlayer? = null
/** 手势检测(上下滑切换任务) */
private lateinit var gestureDetector: GestureDetector
@@ -117,6 +122,11 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
fetchTaskIds()
}
override fun onDestroyView() {
stopVoice()
super.onDestroyView()
}
// ===== 数据获取 =====
/** 第一步:获取任务 ID 列表 */
@@ -173,6 +183,8 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
/** 第二步:获取当前任务详情 */
private fun fetchCurrentDetail() {
if (taskList.isEmpty()) return
// 切换任务时停止语音播放
stopVoice()
val taskId = taskList[taskIndex].id
viewLifecycleOwner.lifecycleScope.launch {
@@ -226,15 +238,15 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
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<FragmentTaskListBinding>() {
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<FragmentTaskListBinding>() {
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<FragmentTaskListBinding>() {
// ===== 进行中/待完成:地点+打卡时间+完成指引 =====
4 -> {
// 巡检时段
// 时间信息:先巡检时段(直接写入),再截止时间(追加模式)
showInspectTime(detail)
showDeadline(detail)
if (detail.taskType == 5) {
// ===== 巡检任务:显示场景打卡清单 =====
showInspectScenes(detail)
@@ -315,9 +327,10 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
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<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 时,拆为开始/结束两行) */
private fun showInspectTime(detail: TaskDetail) {
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
* 每个场景一行:✅ 已打卡 / ❌ 未打卡
*
* 三种视觉层级:
* - 已打卡:绿色 ● + 场景名 + 打卡时间(确定感)
* - 下一个待打卡:橙色高亮 ▶ + 大字体(行动引导)
* - 后续未打卡:灰色 ○ + 小字体(减少干扰)
*/
private fun showInspectScenes(detail: TaskDetail) {
val scenes = detail.taskInspectScenes
@@ -377,22 +467,71 @@ class TaskListFragment : BaseFragment<FragmentTaskListBinding>() {
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<FragmentTaskListBinding>() {
// ===== 状态显示 =====
/** 获取当前状态对应的标题 */
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<FragmentTaskListBinding>() {
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()
}
}

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: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去哪里橙色 -->
<LinearLayout
android:id="@+id/blockGoWhere"

View File

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