task(62a18e54-9143-4263-9927-83f1631aa54c): Add M3 task management module (2.6/2.7 task lists and detail)

S-M3 deliverables: TaskApi (2.6 GetUserPlanGroup / 2.7 GetPlanGroup),
TaskModels, feature/task package (Repository/ViewModel/Workspace/Detail/ListAdapter),
layouts and resources; FeatureMount Tab.TASK mounted; placeholder TaskFragment removed.
CommitOnArchive was stuck after staging; committed by master chat as compensation.
This commit is contained in:
阿猫
2026-09-04 00:42:54 +08:00
parent ddd5009bb1
commit 85a144459e
17 changed files with 1693 additions and 8 deletions

View File

@@ -2,6 +2,7 @@ package com.stec.cmd
import com.stec.cmd.feature.home.ProjectWorkspaceFragment
import com.stec.cmd.feature.mine.MineFragment
import com.stec.cmd.feature.task.TaskWorkspaceFragment
/**
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
@@ -32,7 +33,7 @@ object FeatureMount {
/** 挂载注册表S1+ 替换 fragmentClass 即完成业务页挂载。 */
val mounts: List<MountItem> = listOf(
MountItem(Tab.HOME, ProjectWorkspaceFragment::class.java, "M2 项目工作台"),
MountItem(Tab.TASK, TaskFragment::class.java, "M3 任务管理"),
MountItem(Tab.TASK, TaskWorkspaceFragment::class.java, "M3 任务管理"),
MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"),
MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"),
MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),

View File

@@ -1,7 +0,0 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 任务占位M3 任务管理S2 挂载任务列表)。 */
@AndroidEntryPoint
class TaskFragment : PlaceholderFragment()

View File

@@ -0,0 +1,93 @@
package com.stec.cmd.feature.task
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import com.stec.cmd.R
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.FragmentTaskDetailBinding
import kotlinx.serialization.json.Json
/**
* 任务详情M3-03 测组/测点完成度/计划周期展示)。
*
* 数据来自列表条目本身(明细表 M3-03 对应 API「2.6/2.7 data」2.9 测点列表归属 M5
* 经 arguments JSON 传参ProjectSwitcherSheet 同款先例),纯展示无网络交互——
* 故不设 ViewModelMVVM 最小状态机原则,见 design 留档)。
*/
class TaskDetailFragment : Fragment() {
private var _binding: FragmentTaskDetailBinding? = null
private val binding get() = checkNotNull(_binding)
/** 传参解析失败时退化为全「-」展示(数据源为本端自编码 JSON正常不触发。 */
private val task: TaskItem? by lazy {
arguments?.getString(ARG_TASK_JSON)?.let { raw ->
runCatching { JSON.decodeFromString(TaskItem.serializer(), raw) }.getOrNull()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentTaskDetailBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
render()
}
private fun render() {
val data = task
binding.tvDetailCode.text = data?.TaskCode.orDash()
binding.tvDetailMold.text = data?.Mold.orDash()
binding.tvDetailMonitoring.text = data?.MonitoringType.orDash()
binding.tvDetailDuty.text = data?.DutyName.orDash()
binding.tvDetailProject.text = data?.SimpleName.orDash()
binding.tvDetailWorkPoint.text = data?.WorkPointName.orDash()
binding.tvDetailPlanId.text = data?.PlanID.orDash()
binding.tvDetailPeriod.text = data?.period
?.takeIf { it.isNotBlank() }
?.let { getString(R.string.task_detail_label_period_fmt, it) }
?: getString(R.string.task_detail_unknown)
binding.tvDetailBegin.text = data?.BeginTime.orDash()
binding.tvDetailEnd.text = data?.EndTime.orDash()
binding.tvDetailGroup.text = data?.GroupID.orDash()
binding.tvDetailDegree.text = data?.CompleteDegree.orDash()
binding.tvDetailWorking.text = data?.Working.orDash()
degreePercent(data?.CompleteDegree)?.let { percent ->
binding.progressDegree.isVisible = true
binding.progressDegree.setProgressCompat(percent, true)
}
}
private fun String?.orDash(): String =
this?.takeIf { it.isNotBlank() } ?: getString(R.string.task_detail_unknown)
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_TASK_JSON = "task_json"
private val JSON = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
fun newInstance(taskJson: String): TaskDetailFragment =
TaskDetailFragment().apply {
arguments = Bundle().apply { putString(ARG_TASK_JSON, taskJson) }
}
}
}

View File

@@ -0,0 +1,95 @@
package com.stec.cmd.feature.task
import android.content.res.ColorStateList
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.ViewGroup
import com.google.android.material.chip.Chip
import com.google.android.material.color.MaterialColors
import com.stec.cmd.R
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.ItemTaskBinding
/**
* 任务列表适配器M3-01/02 条目 + M3-04 状态 Chip 三态配色)。
*
* 状态 Chip 配色:未开始=surfaceVariant 灰 / 进行中=primaryContainer / 已完成=brand_success 绿。
*/
class TaskListAdapter(
private val onClick: (TaskItem) -> Unit,
) : ListAdapter<TaskItem, TaskListAdapter.TaskViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
val binding = ItemTaskBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return TaskViewHolder(binding)
}
override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class TaskViewHolder(
private val binding: ItemTaskBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(task: TaskItem) {
val context = binding.root.context
binding.tvTaskCode.text = task.TaskCode?.takeIf { it.isNotBlank() }
?: context.getString(R.string.task_detail_unknown)
binding.chipStatus.applyStatus(statusOf(task))
binding.tvTaskType.text = task.MonitoringType?.takeIf { it.isNotBlank() }
?: context.getString(R.string.task_detail_unknown)
binding.tvTaskMold.text = task.Mold.orEmpty()
binding.tvTaskMold.isVisible = !task.Mold.isNullOrBlank()
binding.tvTaskMeta.text =
listOf(task.WorkPointName, task.DutyName)
.filter { !it.isNullOrBlank() }
.joinToString(" · ")
binding.tvTaskMeta.isVisible = binding.tvTaskMeta.text.isNotBlank()
binding.tvTaskPeriod.text = context.getString(
R.string.task_item_period_fmt,
task.BeginTime?.takeIf { it.isNotBlank() } ?: context.getString(R.string.task_detail_unknown),
task.EndTime?.takeIf { it.isNotBlank() } ?: context.getString(R.string.task_detail_unknown),
)
binding.tvTaskDegree.text = context.getString(
R.string.task_item_degree_fmt,
task.CompleteDegree?.takeIf { it.isNotBlank() }
?: context.getString(R.string.task_detail_unknown),
)
binding.root.setOnClickListener { onClick(task) }
}
}
private fun Chip.applyStatus(status: TaskStatus) {
val context = context
text = context.getString(status.labelRes)
val (background, foreground) = when (status) {
TaskStatus.NOT_STARTED ->
MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant) to
MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurfaceVariant)
TaskStatus.IN_PROGRESS ->
MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer) to
MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimaryContainer)
TaskStatus.COMPLETED ->
ContextCompat.getColor(context, R.color.brand_success) to ContextCompat.getColor(context, R.color.brand_on_primary)
}
chipBackgroundColor = ColorStateList.valueOf(background)
setTextColor(foreground)
}
private companion object {
val DIFF = object : DiffUtil.ItemCallback<TaskItem>() {
override fun areItemsTheSame(oldItem: TaskItem, newItem: TaskItem): Boolean =
oldItem.TaskCode == newItem.TaskCode &&
oldItem.GroupID == newItem.GroupID &&
oldItem.PlanID == newItem.PlanID
override fun areContentsTheSame(oldItem: TaskItem, newItem: TaskItem): Boolean =
oldItem == newItem
}
}
}

View File

@@ -0,0 +1,61 @@
package com.stec.cmd.feature.task
import androidx.annotation.StringRes
import com.stec.cmd.R
import com.stec.cmd.core.network.api.TaskItem
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
/**
* 任务状态流转M3-04。文档 2.6/2.7 的 data 无显式状态字段,
* 按 design 兜底序从 CompleteDegree 与 BeginTime 推导:
* 完成度可解析且已完成 → 已完成BeginTime 可解析且晚于当前 → 未开始;其余 → 进行中。
*/
enum class TaskStatus(@StringRes val labelRes: Int) {
NOT_STARTED(R.string.task_status_not_started),
IN_PROGRESS(R.string.task_status_in_progress),
COMPLETED(R.string.task_status_completed),
}
/** 文档口径的计划周期格式yyyy-MM-dd HH:mm2.6/2.7 BeginTime/EndTime。 */
private val TASK_TIME_FORMATS = listOf(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
)
/** 完成度「已上传点数/总点数」,容忍空白与百分比后缀等夹带字符。 */
private val DEGREE_REGEX = Regex("(\\d+)\\s*/\\s*(\\d+)")
/** 解析计划时间;主格式失败时退回 ISOLocalDateTime 默认格式)兜底。 */
fun parseTaskTime(raw: String?): LocalDateTime? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
TASK_TIME_FORMATS.forEach { format ->
runCatching { return LocalDateTime.parse(value, format) }
}
return runCatching { LocalDateTime.parse(value) }.getOrNull()
}
/** 解析完成度为 (已上传, 总数);不可解析返回 nullUI 透传原文)。 */
fun parseDegree(raw: String?): Pair<Int, Int>? {
val match = raw?.let { DEGREE_REGEX.find(it) } ?: return null
val uploaded = match.groupValues[1].toIntOrNull() ?: return null
val total = match.groupValues[2].toIntOrNull() ?: return null
return uploaded to total
}
/** 完成度百分比0100总数为 0 或不可解析时返回 null。 */
fun degreePercent(raw: String?): Int? {
val (uploaded, total) = parseDegree(raw) ?: return null
if (total <= 0) return null
return (uploaded * 100 / total).coerceIn(0, 100)
}
/** 按兜底序推导任务状态([now] 由调用方传入以便测试与刷新一致性)。 */
fun statusOf(task: TaskItem, now: LocalDateTime = LocalDateTime.now()): TaskStatus {
val (uploaded, total) = parseDegree(task.CompleteDegree) ?: Pair(-1, -1)
if (total > 0 && uploaded >= total) return TaskStatus.COMPLETED
val begin = parseTaskTime(task.BeginTime)
if (begin != null && now.isBefore(begin)) return TaskStatus.NOT_STARTED
return TaskStatus.IN_PROGRESS
}

View File

@@ -0,0 +1,76 @@
package com.stec.cmd.feature.task
import com.stec.cmd.core.network.ApiCaller
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.TaskApi
import com.stec.cmd.core.network.api.TaskItem
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement
import javax.inject.Inject
import javax.inject.Singleton
/**
* 任务仓储M3-01/022.6 GetUserPlanGroup / 2.7 GetPlanGroup
*
* 契约宽容data 形态以 [taskListFromData] 归一提取——
* 兼容文档口径的 `{"TaskList":[…]}` 对象(含 TaskList 键值为二次 JSON 编码字符串)、
* 平铺数组、以及 data 直接为编码字符串(与 ProjectRepository.projectListFromData
* 对项目列表的宽容模式一致)。
*/
@Singleton
class TaskRepository @Inject constructor(
private val taskApi: TaskApi,
private val apiCaller: ApiCaller,
private val json: Json,
) {
/**
* 2.6 APP 端任务列表(本人测组任务);[projectId] 传 null 时返回本人
* 全部项目当日任务,[planDate] 必填yyyy-MM-dd
*/
suspend fun myTasks(projectId: String?, planDate: String): List<TaskItem> =
taskListFromData(apiCaller.call { taskApi.getUserPlanGroup(projectId, planDate) })
/** 2.7 项目当前监测计划任务列表;[projectId] 传 null 时返回所有进行中项目。 */
suspend fun planTasks(projectId: String?): List<TaskItem> =
taskListFromData(apiCaller.call { taskApi.getPlanGroup(projectId) })
/**
* data → 任务列表归一提取:
* - JsonObject优先取 `TaskList` 键(文档口径);缺键时取首个 JsonArray 值兜底
* (防御键名大小写漂移);
* - 提取物为 JsonArray 直接解码;为 JsonPrimitive 时按二次 JSON 编码字符串解码;
* - data 本身为 JsonArray / JsonPrimitive 同样支持。
* 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。
*/
private fun taskListFromData(data: JsonElement): List<TaskItem> {
val payload = when (data) {
is JsonObject ->
data[KEY_TASK_LIST]
?: data.values.filterIsInstance<JsonArray>().firstOrNull()
?: throw ApiError.EmptyBodyError("任务列表数据结构异常")
else -> data
}
val serializer = ListSerializer(TaskItem.serializer())
return try {
when (payload) {
is JsonArray -> json.decodeFromJsonElement(serializer, payload)
is JsonPrimitive -> json.decodeFromString(serializer, payload.content)
else -> throw ApiError.EmptyBodyError("任务列表数据结构异常")
}
} catch (e: SerializationException) {
throw ApiError.EmptyBodyError("任务列表解析失败:${e.message}")
}
}
private companion object {
/** 文档 2.6/2.7data 内列表键名(确切大小写)。 */
const val KEY_TASK_LIST = "TaskList"
}
}

View File

@@ -0,0 +1,189 @@
package com.stec.cmd.feature.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.feature.mine.userMessage
import com.stec.cmd.session.ProjectSession
import com.stec.cmd.session.SessionRepository
import com.stec.cmd.session.SessionState
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.LocalDate
import java.time.LocalDateTime
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** 任务页分段我的任务2.6 GetUserPlanGroup/ 计划任务2.7 GetPlanGroup。 */
enum class TaskTab {
MY_TASKS,
PLAN_TASKS,
}
/**
* 任务工作台M3-01/02/04/05
* 会话 / 配置 / 选中项目门控 + 双列表独立 UiState + 本地筛选链。
*
* 门控沿用 HomeViewModelSignedIn 且三要素就绪才允许加载;会话离开 SignedIn
* 复位为 Idle**selectedProject 任一变化即复位双列表并重载当前 Tab**
* M2-03「切换项目后任务跟随刷新」承诺的落地
*/
@HiltViewModel
class TaskViewModel @Inject constructor(
private val taskRepository: TaskRepository,
projectSession: ProjectSession,
sessionRepository: SessionRepository,
private val configRepository: ConfigRepository,
) : ViewModel() {
/** 会话状态流Restoring/SignedOut/SignedIn 门控)。 */
val sessionState = sessionRepository.state
/** 连接配置快照流(三要素缺失时引导配置)。 */
val configSnapshot = configRepository.snapshotStateFlow
/** 当前选中项目ProjectSession 共享,切换即联动刷新)。 */
val selectedProject = projectSession.selectedProject
private val _tab = MutableStateFlow(TaskTab.MY_TASKS)
val tab: StateFlow<TaskTab> = _tab.asStateFlow()
/** 状态筛选null=全部M3-05 本地过滤,双列表通用)。 */
private val _statusFilter = MutableStateFlow<TaskStatus?>(null)
val statusFilter: StateFlow<TaskStatus?> = _statusFilter.asStateFlow()
/** 2.6 PlanDate文档必填默认当天日期选择后服务端重查。 */
private val _planDate = MutableStateFlow(LocalDate.now())
val planDate: StateFlow<LocalDate> = _planDate.asStateFlow()
/** 2.7 无日期参数:本地按 EndTime 同日过滤null=全部)。 */
private val _dateFilter = MutableStateFlow<LocalDate?>(null)
val dateFilter: StateFlow<LocalDate?> = _dateFilter.asStateFlow()
private val _myTasks = MutableStateFlow<UiState<List<TaskItem>>>(UiState.Idle)
val myTasks: StateFlow<UiState<List<TaskItem>>> = _myTasks.asStateFlow()
private val _planTasks = MutableStateFlow<UiState<List<TaskItem>>>(UiState.Idle)
val planTasks: StateFlow<UiState<List<TaskItem>>> = _planTasks.asStateFlow()
init {
viewModelScope.launch {
sessionState.collect { state ->
if (state is SessionState.SignedIn) {
if (configRepository.snapshotStateFlow.value.isReady) loadCurrentIfIdle()
} else {
resetLists()
}
}
}
viewModelScope.launch {
configSnapshot.collect { snapshot ->
if (snapshot.isReady && sessionState.value is SessionState.SignedIn) {
loadCurrentIfIdle()
}
}
}
viewModelScope.launch {
selectedProject.collect {
resetLists()
loadCurrentIfIdle()
}
}
}
/** 分段切换:惰性加载目标列表,清掉 2.7 的本地日期过滤(两 Tab 语义不同)。 */
fun selectTab(tab: TaskTab) {
if (_tab.value == tab) return
_tab.value = tab
_dateFilter.value = null
loadCurrentIfIdle()
}
/** 下拉刷新 / 错误重试 / 空态刷新。 */
fun refresh() {
when (_tab.value) {
TaskTab.MY_TASKS -> if (_myTasks.value !is UiState.Loading) loadMyTasks()
TaskTab.PLAN_TASKS -> if (_planTasks.value !is UiState.Loading) loadPlanTasks()
}
}
/** M3-05 状态筛选(本地)。 */
fun updateStatusFilter(status: TaskStatus?) {
_statusFilter.value = status
}
/**
* M3-05 时间筛选2.6 改 PlanDate 服务端重查2.7 本地按 EndTime 过滤。
*/
fun pickDate(date: LocalDate) {
when (_tab.value) {
TaskTab.MY_TASKS -> {
_planDate.value = date
if (selectedProject.value != null && _myTasks.value !is UiState.Loading) {
loadMyTasks()
}
}
TaskTab.PLAN_TASKS -> _dateFilter.value = date
}
}
/** 清除 2.7 本地日期过滤(恢复全部)。 */
fun clearDateFilter() {
_dateFilter.value = null
}
/** 当前 Tab 经状态/日期筛选链后的可见列表UI 只渲染不加工)。 */
fun displayedTasks(): List<TaskItem> {
val state = if (_tab.value == TaskTab.MY_TASKS) _myTasks.value else _planTasks.value
val list = (state as? UiState.Success)?.data.orEmpty()
val now = LocalDateTime.now()
return list.filter { task ->
val statusOk = _statusFilter.value == null || statusOf(task, now) == _statusFilter.value
val dateOk = _tab.value != TaskTab.PLAN_TASKS || _dateFilter.value == null ||
parseTaskTime(task.EndTime)?.toLocalDate() == _dateFilter.value
statusOk && dateOk
}
}
private fun loadCurrentIfIdle() {
if (selectedProject.value == null) return
when (_tab.value) {
TaskTab.MY_TASKS -> if (_myTasks.value is UiState.Idle) loadMyTasks()
TaskTab.PLAN_TASKS -> if (_planTasks.value is UiState.Idle) loadPlanTasks()
}
}
private fun resetLists() {
_myTasks.value = UiState.Idle
_planTasks.value = UiState.Idle
}
private fun loadMyTasks() {
viewModelScope.launch {
_myTasks.value = UiState.Loading
_myTasks.value = try {
UiState.Success(
taskRepository.myTasks(selectedProject.value?.ID, _planDate.value.toString()),
)
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
private fun loadPlanTasks() {
viewModelScope.launch {
_planTasks.value = UiState.Loading
_planTasks.value = try {
UiState.Success(taskRepository.planTasks(selectedProject.value?.ID))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
}

View File

@@ -0,0 +1,229 @@
package com.stec.cmd.feature.task
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.datepicker.MaterialDatePicker
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.FragmentTaskBinding
import com.stec.cmd.feature.mine.ConfigFragment
import com.stec.cmd.feature.mine.LoginFragment
import com.stec.cmd.session.SessionState
import dagger.hilt.android.AndroidEntryPoint
import java.time.Instant
import java.time.ZoneOffset
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
/**
* 任务工作台M3-01/02 双列表入口 / M3-04 状态流转 / M3-05 筛选与下拉刷新)。
*
* 渲染状态机对齐首页Restoring → 中性加载SignedOut → 登录 CTA
* SignedIn → 配置门控 → 未选项目引导空态CTA→首页切换项目
* 当前 Tab 列表加载/空/错误/内容四态。
* 子页(详情/登录/配置沿用「add + hide + addToBackStack」壳层模式。
*/
@AndroidEntryPoint
class TaskWorkspaceFragment : Fragment() {
private var _binding: FragmentTaskBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: TaskViewModel by viewModels()
private val listAdapter by lazy { TaskListAdapter(::openDetail) }
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentTaskBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.recyclerTasks.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerTasks.adapter = listAdapter
binding.swipeRefresh.setColorSchemeResources(R.color.brand_primary)
binding.swipeRefresh.setOnRefreshListener { viewModel.refresh() }
binding.btnGoLogin.setOnClickListener { openOnTop(LoginFragment(), TAG_LOGIN) }
binding.btnGoConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
binding.btnGoHome.setOnClickListener { goHomeTab() }
binding.btnRetry.setOnClickListener { viewModel.refresh() }
binding.btnEmptyRefresh.setOnClickListener { viewModel.refresh() }
binding.chipDate.setOnClickListener { showDatePicker() }
binding.chipDate.setOnCloseIconClickListener { viewModel.clearDateFilter() }
binding.toggleTab.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
viewModel.selectTab(
if (checkedId == R.id.btn_tab_my) TaskTab.MY_TASKS else TaskTab.PLAN_TASKS,
)
}
}
binding.chipGroupFilter.setOnCheckedStateChangeListener { _, checkedIds ->
viewModel.updateStatusFilter(
when (checkedIds.firstOrNull()) {
R.id.chip_filter_not_started -> TaskStatus.NOT_STARTED
R.id.chip_filter_in_progress -> TaskStatus.IN_PROGRESS
R.id.chip_filter_completed -> TaskStatus.COMPLETED
else -> null
},
)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.sessionState.collect { render() } }
launch { viewModel.configSnapshot.collect { render() } }
launch { viewModel.selectedProject.collect { render() } }
launch { viewModel.tab.collect { render() } }
launch { viewModel.statusFilter.collect { render() } }
launch { viewModel.dateFilter.collect { render() } }
launch { viewModel.planDate.collect { render() } }
launch { viewModel.myTasks.collect { render() } }
launch { viewModel.planTasks.collect { render() } }
}
}
}
/** 任一流变化即整体重渲(读 ViewModel 当前值,避免组合流样板)。 */
private fun render() {
val session = viewModel.sessionState.value
val config = viewModel.configSnapshot.value
val selected = viewModel.selectedProject.value
binding.progress.isVisible = false
binding.stateSignedOut.isVisible = false
binding.stateConfig.isVisible = false
binding.stateNoProject.isVisible = false
binding.stateError.isVisible = false
binding.stateEmpty.isVisible = false
when (session) {
// 冷启动恢复中:中性加载,不引导登录
SessionState.Restoring -> binding.progress.isVisible = true
SessionState.SignedOut -> binding.stateSignedOut.isVisible = true
is SessionState.SignedIn -> {
if (!config.isReady) {
binding.stateConfig.isVisible = true
return
}
if (selected == null) {
binding.stateNoProject.isVisible = true
return
}
binding.groupHeader.isVisible = true
binding.tvTaskProject.text = selected.SimpleName?.takeIf { it.isNotBlank() }
?: getString(R.string.home_unnamed_project)
renderList()
}
}
}
private fun renderList() {
val tab = viewModel.tab.value
val state =
if (tab == TaskTab.MY_TASKS) viewModel.myTasks.value else viewModel.planTasks.value
binding.swipeRefresh.isVisible = true
binding.swipeRefresh.isRefreshing = state is UiState.Loading
// 日期 Chip 双语义2.6=PlanDate服务端重查2.7=本地 EndTime 过滤(可清除)
if (tab == TaskTab.MY_TASKS) {
binding.chipDate.text =
getString(R.string.task_filter_date_fmt, viewModel.planDate.value.toString())
binding.chipDate.isCloseIconVisible = false
} else {
val filter = viewModel.dateFilter.value
binding.chipDate.text = filter?.let { getString(R.string.task_filter_date_fmt, it.toString()) }
?: getString(R.string.task_filter_date_any)
binding.chipDate.isCloseIconVisible = filter != null
}
when (state) {
is UiState.Loading -> Unit // 首载由下拉圈呈现
is UiState.Error -> {
binding.tvErrorMessage.text = state.message
binding.stateError.isVisible = true
}
is UiState.Success -> {
val visible = viewModel.displayedTasks()
listAdapter.submitList(visible)
if (visible.isEmpty()) {
binding.tvEmptyMessage.text =
if (tab == TaskTab.MY_TASKS) getString(R.string.task_empty_my)
else getString(R.string.task_empty_plan)
binding.stateEmpty.isVisible = true
}
}
UiState.Idle -> Unit // 会话/配置/项目流将触发自动加载
}
}
/** M3-05 时间筛选入口MaterialDatePickerUTC 毫秒 → 本地语义日期)。 */
private fun showDatePicker() {
val picker = MaterialDatePicker.Builder.datePicker()
.setTitleText(R.string.task_date_picker_title)
.build()
picker.addOnPositiveButtonClickListener { utcMillis ->
viewModel.pickDate(
Instant.ofEpochMilli(utcMillis).atZone(ZoneOffset.UTC).toLocalDate(),
)
}
picker.show(childFragmentManager, TAG_DATE)
}
/** 空态 CTA切回首页 Tab 选项目(导航壳零改动,经底栏 itemId 触发既有切页)。 */
private fun goHomeTab() {
requireActivity().findViewById<BottomNavigationView>(R.id.bottom_nav)
.selectedItemId = R.id.nav_home
}
/** 详情页跳转TaskItem 经 JSON 传参(与 ProjectSwitcherSheet 同款先例)。 */
private fun openDetail(task: TaskItem) {
openOnTop(
TaskDetailFragment.newInstance(json.encodeToString(TaskItem.serializer(), task)),
TAG_DETAIL,
)
}
private fun openOnTop(fragment: Fragment, tag: String) {
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, fragment, tag)
hide(this@TaskWorkspaceFragment)
addToBackStack(null)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private companion object {
const val TAG_LOGIN = "task_login"
const val TAG_CONFIG = "task_config"
const val TAG_DETAIL = "task_detail"
const val TAG_DATE = "task_date_picker"
}
}

View File

@@ -0,0 +1,312 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 任务工作台M3-01/02 双列表入口 / M3-04 状态 Chip / M3-05 筛选与下拉刷新) -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 头部:项目上下文 + 分段切换 + 筛选(仅已登录且选中项目时可见) -->
<LinearLayout
android:id="@+id/group_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/tv_task_project"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingTop="16dp"
android:textColor="?attr/colorOnSurface"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingTop="2dp"
android:alpha="0.6"
android:text="@string/task_project_hint"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/toggle_tab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="12dp"
app:checkedButton="@+id/btn_tab_my"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tab_my"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/task_tab_my" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_tab_plan"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/task_tab_plan" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- M3-05 筛选行:状态单选(本地过滤)+ 日期2.6 服务端重查 / 2.7 本地过滤) -->
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="4dp"
android:scrollbars="none">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chip_group_filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:chipSpacingHorizontal="8dp"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_all"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/task_filter_all" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_not_started"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_filter_not_started" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_in_progress"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_filter_in_progress" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_filter_completed"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_filter_completed" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_date"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_filter_date_any"
app:chipIcon="@drawable/ic_nav_task"
app:chipIconTint="?attr/colorPrimary" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_tasks"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="24dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<!-- 未登录空态 -->
<LinearLayout
android:id="@+id/state_signed_out"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/task_signed_out_title"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:gravity="center"
android:text="@string/task_signed_out_hint"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/task_go_login" />
</LinearLayout>
<!-- 配置缺失空态 -->
<LinearLayout
android:id="@+id/state_config"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/task_config_missing"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_config"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/task_go_config" />
</LinearLayout>
<!-- 未选项目空态CTA→首页切换项目M2-03 联动刷新承诺闭环) -->
<LinearLayout
android:id="@+id/state_no_project"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/task_no_project_title"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:gravity="center"
android:text="@string/task_no_project_hint"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_home"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/task_go_home" />
</LinearLayout>
<!-- 错误态 -->
<LinearLayout
android:id="@+id/state_error"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_retry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/task_retry" />
</LinearLayout>
<!-- 空列表态 -->
<LinearLayout
android:id="@+id/state_empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:clickable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_empty_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.7"
android:gravity="center"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_empty_refresh"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/task_empty_refresh" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,338 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 任务详情M3-03 测组/测点完成度/计划周期 + M3-04 状态来源字段分区展示) -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/task_detail_title"
app:titleTextColor="?attr/colorOnSurface" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingTop="8dp"
android:paddingBottom="32dp">
<!-- 任务标识 -->
<com.google.android.material.card.MaterialCardView
style="@style/TaskDetailCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout style="@style/TaskDetailCardContent">
<TextView
style="@style/TaskDetailSectionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_section_identity" />
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_code" />
<TextView
android:id="@+id/tv_detail_code"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_mold" />
<TextView
android:id="@+id/tv_detail_mold"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_monitoring" />
<TextView
android:id="@+id/tv_detail_monitoring"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_duty" />
<TextView
android:id="@+id/tv_detail_duty"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 项目与工点 -->
<com.google.android.material.card.MaterialCardView
style="@style/TaskDetailCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout style="@style/TaskDetailCardContent">
<TextView
style="@style/TaskDetailSectionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_section_project" />
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_project" />
<TextView
android:id="@+id/tv_detail_project"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_work_point" />
<TextView
android:id="@+id/tv_detail_work_point"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 监测计划 -->
<com.google.android.material.card.MaterialCardView
style="@style/TaskDetailCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout style="@style/TaskDetailCardContent">
<TextView
style="@style/TaskDetailSectionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_section_plan" />
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_plan_id" />
<TextView
android:id="@+id/tv_detail_plan_id"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_period" />
<TextView
android:id="@+id/tv_detail_period"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_begin" />
<TextView
android:id="@+id/tv_detail_begin"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_end" />
<TextView
android:id="@+id/tv_detail_end"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 测组与完成度M3-03 测点维度以完成度呈现2.9 归属 M5 -->
<com.google.android.material.card.MaterialCardView
style="@style/TaskDetailCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout style="@style/TaskDetailCardContent">
<TextView
style="@style/TaskDetailSectionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_section_group" />
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_group" />
<TextView
android:id="@+id/tv_detail_group"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout style="@style/TaskDetailRow">
<TextView
style="@style/TaskDetailLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_label_degree" />
<TextView
android:id="@+id/tv_detail_degree"
style="@style/TaskDetailValue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_degree"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
android:visibility="gone"
app:indicatorColor="?attr/colorPrimary"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 施工工况 -->
<com.google.android.material.card.MaterialCardView
style="@style/TaskDetailCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout style="@style/TaskDetailCardContent">
<TextView
style="@style/TaskDetailSectionTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/task_detail_section_working" />
<TextView
android:id="@+id/tv_detail_working"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 任务卡片M3-01/02 列表条目M3-04 状态 Chip 三态配色) -->
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/card_task"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="12dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_task_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_status"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:clickable="false"
android:focusable="false" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_task_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorPrimary"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_task_mold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:alpha="0.65"
android:textSize="13sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_task_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:alpha="0.75"
android:textSize="13sp" />
<TextView
android:id="@+id/tv_task_period"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.75"
android:textSize="13sp" />
<TextView
android:id="@+id/tv_task_degree"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.75"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -7,4 +7,6 @@
<color name="brand_surface">#FFFFFF</color>
<color name="launcher_background">#1E6FD9</color>
<color name="login_error">#B3261E</color>
<!-- M3-04 已完成任务状态 Chip -->
<color name="brand_success">#2E7D32</color>
</resources>

View File

@@ -26,6 +26,55 @@
<string name="home_empty_refresh">刷新</string>
<string name="sheet_switch_title">切换项目</string>
<!-- M3 任务管理M3-01/02/03/04/05 -->
<string name="task_tab_my">我的任务</string>
<string name="task_tab_plan">计划任务</string>
<string name="task_project_hint">在首页切换项目后,任务列表自动刷新</string>
<string name="task_filter_all">全部</string>
<string name="task_filter_not_started">未开始</string>
<string name="task_filter_in_progress">进行中</string>
<string name="task_filter_completed">已完成</string>
<string name="task_filter_date_fmt">日期:%1$s</string>
<string name="task_filter_date_any">时间</string>
<string name="task_date_picker_title">选择任务日期</string>
<string name="task_signed_out_title">登录后查看您的任务</string>
<string name="task_signed_out_hint">任务与监测计划需要登录后同步</string>
<string name="task_go_login">立即登录</string>
<string name="task_config_missing">尚未配置服务器与密钥,无法加载任务</string>
<string name="task_go_config">去配置</string>
<string name="task_no_project_title">尚未选择项目</string>
<string name="task_no_project_hint">选择项目后即可查看对应任务</string>
<string name="task_go_home">去首页切换项目</string>
<string name="task_retry">重试</string>
<string name="task_empty_my">该日期下暂无任务,可点击日期筛选换一天</string>
<string name="task_empty_plan">当前项目暂无监测计划任务</string>
<string name="task_empty_refresh">刷新</string>
<string name="task_status_not_started">未开始</string>
<string name="task_status_in_progress">进行中</string>
<string name="task_status_completed">已完成</string>
<string name="task_item_period_fmt">周期:%1$s ~ %2$s</string>
<string name="task_item_degree_fmt">完成度:%1$s</string>
<string name="task_detail_title">任务详情</string>
<string name="task_detail_section_identity">任务标识</string>
<string name="task_detail_section_project">项目与工点</string>
<string name="task_detail_section_plan">监测计划</string>
<string name="task_detail_section_group">测组与完成度</string>
<string name="task_detail_section_working">施工工况</string>
<string name="task_detail_label_code">任务编号</string>
<string name="task_detail_label_mold">类型</string>
<string name="task_detail_label_monitoring">监测类型</string>
<string name="task_detail_label_duty">责任人</string>
<string name="task_detail_label_project">项目</string>
<string name="task_detail_label_work_point">工点</string>
<string name="task_detail_label_plan_id">计划 ID</string>
<string name="task_detail_label_period">期数</string>
<string name="task_detail_label_period_fmt">第 %1$s 期</string>
<string name="task_detail_label_begin">开始时间</string>
<string name="task_detail_label_end">截止时间</string>
<string name="task_detail_label_group">测组 ID</string>
<string name="task_detail_label_degree">完成度</string>
<string name="task_detail_unknown">-</string>
<!-- 通用 -->
<string name="action_save">保存</string>

View File

@@ -17,4 +17,44 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.SteCmd</item>
</style>
<!-- 任务详情分区卡片M3-03 -->
<style name="TaskDetailCard">
<item name="android:layout_marginTop">12dp</item>
</style>
<style name="TaskDetailCardContent">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:orientation">vertical</item>
<item name="android:padding">16dp</item>
</style>
<style name="TaskDetailSectionTitle">
<item name="android:textColor">?attr/colorOnSurface</item>
<item name="android:textSize">15sp</item>
<item name="android:textStyle">bold</item>
<item name="android:layout_marginBottom">8dp</item>
</style>
<style name="TaskDetailRow">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:orientation">horizontal</item>
<item name="android:gravity">center_vertical</item>
<item name="android:layout_marginTop">6dp</item>
</style>
<style name="TaskDetailLabel">
<item name="android:layout_height">wrap_content</item>
<item name="android:alpha">0.6</item>
<item name="android:textSize">13sp</item>
<item name="android:layout_marginEnd">12dp</item>
</style>
<style name="TaskDetailValue">
<item name="android:layout_height">wrap_content</item>
<item name="android:textColor">?attr/colorOnSurface</item>
<item name="android:textSize">14sp</item>
</style>
</resources>

View File

@@ -2,6 +2,7 @@ package com.stec.cmd.core.network
import com.stec.cmd.core.network.api.AuthApi
import com.stec.cmd.core.network.api.ProjectApi
import com.stec.cmd.core.network.api.TaskApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -78,6 +79,11 @@ object NetworkModule {
@Singleton
fun provideProjectApi(retrofit: Retrofit): ProjectApi = retrofit.create(ProjectApi::class.java)
/** 任务接口M32.6/2.7)。 */
@Provides
@Singleton
fun provideTaskApi(retrofit: Retrofit): TaskApi = retrofit.create(TaskApi::class.java)
/** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */
private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/"

View File

@@ -0,0 +1,52 @@
package com.stec.cmd.core.network.api
import com.stec.cmd.core.network.ApiEnvelope
import kotlinx.serialization.json.JsonElement
import retrofit2.http.GET
import retrofit2.http.Query
/**
* 任务接口(接口管理规范 V4.5 · 测试环境 https://jcd.stec.p-q.co
* - 2.6 获取 APP 端任务列表M3-01/03/04/05
* - 2.7 获取项目当前监测计划的任务列表M3-02/03/04/05
*
* 三要素请求头SecretKey/SystemCode/Token由 AuthInterceptor 统一注入。
*
* 契约宽容:文档约定 data 为 {"TaskList":[…]};与项目列表(任务 M2 先例)同理
* 保留形态偏差可能,故以 [JsonElement] 宽容承载,
* 由 app 层 TaskRepository.taskListFromData 归一提取。
*/
interface TaskApi {
/**
* 2.6 APP 端任务列表:一个项目的一个监测计划的一个测组为一条任务。
* [planDate] 必填yyyy-MM-dd[projectId]/[workPointId] 传 null 时
* Retrofit 省略参数(空=本人全部项目当日任务)。
*/
@GET(PATH_GET_USER_PLAN_GROUP)
suspend fun getUserPlanGroup(
@Query(QUERY_PROJECT_ID) projectId: String? = null,
@Query(QUERY_PLAN_DATE) planDate: String,
@Query(QUERY_WORK_POINT_ID) workPointId: String? = null,
): ApiEnvelope<JsonElement>
/**
* 2.7 项目当前监测计划任务列表(离当前时间最近的一个监测计划)。
* [projectId] 传 null 时返回所有「进行中」项目的计划任务。
*/
@GET(PATH_GET_PLAN_GROUP)
suspend fun getPlanGroup(
@Query(QUERY_PROJECT_ID) projectId: String? = null,
): ApiEnvelope<JsonElement>
companion object {
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----
const val PATH_GET_USER_PLAN_GROUP = "OutWebApi/api/GetUserPlanGroup"
const val PATH_GET_PLAN_GROUP = "OutWebApi/api/GetPlanGroup"
/** 查询参数名2.6/2.7 共用 ProjectIDPlanDate 为 2.6 必填)。 */
const val QUERY_PROJECT_ID = "ProjectID"
const val QUERY_PLAN_DATE = "PlanDate"
const val QUERY_WORK_POINT_ID = "WorkPointID"
}
}

View File

@@ -0,0 +1,51 @@
package com.stec.cmd.core.network.api
import kotlinx.serialization.Serializable
/**
* 任务 DTO接口管理规范 V4.5 · 2.6/2.7 返回值示例,一条任务 = 一个测组)。
*
* 字段名与平台契约一致PascalCase文档未给类型全 `String?` 容错,
* 响应侧配合 Json.ignoreUnknownKeys 容忍字段演进。
* 字段来源差异:[PlanNum] 仅 2.6、[PeriodsNum] 仅 2.7(同为期数)、
* [WorkPointID]/[WorkPointName] 仅 2.6。
*/
@Serializable
data class TaskItem(
/** 项目简称。 */
val SimpleName: String? = null,
/** 任务编号:项目编号-计划期数-测组编号。 */
val TaskCode: String? = null,
/** 项目 ID。 */
val ProjectID: String? = null,
/** 监测计划 ID。 */
val PlanID: String? = null,
/** 期数2.6:计划对应期数值)。 */
val PlanNum: String? = null,
/** 期数2.7 字段名)。 */
val PeriodsNum: String? = null,
/** 施工工况。 */
val Working: String? = null,
/** 类型:常规 | 加测。 */
val Mold: String? = null,
/** 责任人。 */
val DutyName: String? = null,
/** 测组 ID。 */
val GroupID: String? = null,
/** 工点 ID仅 2.6)。 */
val WorkPointID: String? = null,
/** 工点名称(仅 2.6)。 */
val WorkPointName: String? = null,
/** 监测类型:水平位移/竖向位移/测斜/收敛/水位/轴力。 */
val MonitoringType: String? = null,
/** 计划周期开始上一期计划截止时间yyyy-MM-dd HH:mm。 */
val BeginTime: String? = null,
/** 计划周期截止yyyy-MM-dd HH:mm。 */
val EndTime: String? = null,
/** 完成度:本测组已上传点数/测组总点数。 */
val CompleteDegree: String? = null,
) {
/** 归一期数2.6 取 PlanNum2.7 取 PeriodsNum。 */
val period: String?
get() = PlanNum?.takeIf { it.isNotBlank() } ?: PeriodsNum?.takeIf { it.isNotBlank() }
}