diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt index dbc262c..70642b2 100644 --- a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt +++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt @@ -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 = 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 我的"), diff --git a/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt b/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt deleted file mode 100644 index 557a73e..0000000 --- a/app/src/main/kotlin/com/stec/cmd/TaskFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stec.cmd - -import dagger.hilt.android.AndroidEntryPoint - -/** 任务占位(M3 任务管理,S2 挂载任务列表)。 */ -@AndroidEntryPoint -class TaskFragment : PlaceholderFragment() diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt new file mode 100644 index 0000000..d4af28e --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskDetailFragment.kt @@ -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 同款先例),纯展示无网络交互—— + * 故不设 ViewModel(MVVM 最小状态机原则,见 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) } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskListAdapter.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskListAdapter.kt new file mode 100644 index 0000000..be37593 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskListAdapter.kt @@ -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(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() { + 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 + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskModels.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskModels.kt new file mode 100644 index 0000000..17f1b87 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskModels.kt @@ -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:mm(2.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+)") + +/** 解析计划时间;主格式失败时退回 ISO(LocalDateTime 默认格式)兜底。 */ +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() +} + +/** 解析完成度为 (已上传, 总数);不可解析返回 null(UI 透传原文)。 */ +fun parseDegree(raw: String?): Pair? { + 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 +} + +/** 完成度百分比(0–100);总数为 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 +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskRepository.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskRepository.kt new file mode 100644 index 0000000..9bf948d --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskRepository.kt @@ -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/02,2.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 = + taskListFromData(apiCaller.call { taskApi.getUserPlanGroup(projectId, planDate) }) + + /** 2.7 项目当前监测计划任务列表;[projectId] 传 null 时返回所有进行中项目。 */ + suspend fun planTasks(projectId: String?): List = + taskListFromData(apiCaller.call { taskApi.getPlanGroup(projectId) }) + + /** + * data → 任务列表归一提取: + * - JsonObject:优先取 `TaskList` 键(文档口径);缺键时取首个 JsonArray 值兜底 + * (防御键名大小写漂移); + * - 提取物为 JsonArray 直接解码;为 JsonPrimitive 时按二次 JSON 编码字符串解码; + * - data 本身为 JsonArray / JsonPrimitive 同样支持。 + * 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。 + */ + private fun taskListFromData(data: JsonElement): List { + val payload = when (data) { + is JsonObject -> + data[KEY_TASK_LIST] + ?: data.values.filterIsInstance().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.7:data 内列表键名(确切大小写)。 */ + const val KEY_TASK_LIST = "TaskList" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskViewModel.kt new file mode 100644 index 0000000..9b0d686 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskViewModel.kt @@ -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 + 本地筛选链。 + * + * 门控沿用 HomeViewModel:SignedIn 且三要素就绪才允许加载;会话离开 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 = _tab.asStateFlow() + + /** 状态筛选(null=全部,M3-05 本地过滤,双列表通用)。 */ + private val _statusFilter = MutableStateFlow(null) + val statusFilter: StateFlow = _statusFilter.asStateFlow() + + /** 2.6 PlanDate(文档必填,默认当天;日期选择后服务端重查)。 */ + private val _planDate = MutableStateFlow(LocalDate.now()) + val planDate: StateFlow = _planDate.asStateFlow() + + /** 2.7 无日期参数:本地按 EndTime 同日过滤(null=全部)。 */ + private val _dateFilter = MutableStateFlow(null) + val dateFilter: StateFlow = _dateFilter.asStateFlow() + + private val _myTasks = MutableStateFlow>>(UiState.Idle) + val myTasks: StateFlow>> = _myTasks.asStateFlow() + + private val _planTasks = MutableStateFlow>>(UiState.Idle) + val planTasks: StateFlow>> = _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 { + 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()) + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/task/TaskWorkspaceFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskWorkspaceFragment.kt new file mode 100644 index 0000000..8309b40 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/task/TaskWorkspaceFragment.kt @@ -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 时间筛选入口(MaterialDatePicker,UTC 毫秒 → 本地语义日期)。 */ + 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(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" + } +} diff --git a/app/src/main/res/layout/fragment_task.xml b/app/src/main/res/layout/fragment_task.xml new file mode 100644 index 0000000..0d37480 --- /dev/null +++ b/app/src/main/res/layout/fragment_task.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_task_detail.xml b/app/src/main/res/layout/fragment_task_detail.xml new file mode 100644 index 0000000..42d80df --- /dev/null +++ b/app/src/main/res/layout/fragment_task_detail.xml @@ -0,0 +1,338 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_task.xml b/app/src/main/res/layout/item_task.xml new file mode 100644 index 0000000..e91ffd7 --- /dev/null +++ b/app/src/main/res/layout/item_task.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index aeb9a40..ce5e110 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FFFFFF #1E6FD9 #B3261E + + #2E7D32 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3fdc8f7..0a17e87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,55 @@ 刷新 切换项目 + + 我的任务 + 计划任务 + 在首页切换项目后,任务列表自动刷新 + 全部 + 未开始 + 进行中 + 已完成 + 日期:%1$s + 时间 + 选择任务日期 + 登录后查看您的任务 + 任务与监测计划需要登录后同步 + 立即登录 + 尚未配置服务器与密钥,无法加载任务 + 去配置 + 尚未选择项目 + 选择项目后即可查看对应任务 + 去首页切换项目 + 重试 + 该日期下暂无任务,可点击日期筛选换一天 + 当前项目暂无监测计划任务 + 刷新 + 未开始 + 进行中 + 已完成 + 周期:%1$s ~ %2$s + 完成度:%1$s + 任务详情 + 任务标识 + 项目与工点 + 监测计划 + 测组与完成度 + 施工工况 + 任务编号 + 类型 + 监测类型 + 责任人 + 项目 + 工点 + 计划 ID + 期数 + 第 %1$s 期 + 开始时间 + 截止时间 + 测组 ID + 完成度 + - + 保存 diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 911078a..18b3244 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -17,4 +17,44 @@ @drawable/ic_launcher_foreground @style/Theme.SteCmd + + + + + + + + + + + + + diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt index 4a7964d..7192c2f 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt @@ -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) + /** 任务接口(M3,2.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/" diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskApi.kt new file mode 100644 index 0000000..5677c02 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskApi.kt @@ -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 + + /** + * 2.7 项目当前监测计划任务列表(离当前时间最近的一个监测计划)。 + * [projectId] 传 null 时返回所有「进行中」项目的计划任务。 + */ + @GET(PATH_GET_PLAN_GROUP) + suspend fun getPlanGroup( + @Query(QUERY_PROJECT_ID) projectId: String? = null, + ): ApiEnvelope + + 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 共用 ProjectID;PlanDate 为 2.6 必填)。 */ + const val QUERY_PROJECT_ID = "ProjectID" + const val QUERY_PLAN_DATE = "PlanDate" + const val QUERY_WORK_POINT_ID = "WorkPointID" + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskModels.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskModels.kt new file mode 100644 index 0000000..91281d5 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/TaskModels.kt @@ -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 取 PlanNum,2.7 取 PeriodsNum。 */ + val period: String? + get() = PlanNum?.takeIf { it.isNotBlank() } ?: PeriodsNum?.takeIf { it.isNotBlank() } +}