task(740424d0-c60b-47bd-a601-ded7a24bf7a6): Add M5 survey point data module (2.9/2.14/2.15 + manual entry)

补偿提交:commitOnArchive 连续第三轮未落盘(M3/M4 同款故障),由主控在子会话工作树直接补交。

M5 采集·测点数据模块四功能点:
- M5-01 (2.9) PointApi/PointModels/PointRepository 归一提取 + SurveyGroupPointSource
  平台测点选择器(pointId 贯通 payload)+ 测组监测点列表页(TaskDetail 入口)
- M5-02 (2.14) + M5-03 (2.15) 采集页上下文信息卡(并发拉取、失败降级不阻塞)
- M5-04 手动录入:buildManual 同构 payload 入同一条 Room 上传队列
  (entrySource=MANUAL),复核确认 + 已入队反馈
- upload_queue entry_source 列迁移 v1→v2(ALTER TABLE 非破坏迁移)
This commit is contained in:
阿猫
2026-09-04 02:26:32 +08:00
parent 3cb82169b6
commit 65a693052e
25 changed files with 1839 additions and 34 deletions

View File

@@ -28,7 +28,7 @@ annotation class FakeBleDriverImpl
* 采集·蓝牙 Hilt 装配:
* - [BleDriver] 单例实为 [SwitchableBleDriver](演示/系统按开关路由);
* - 限定符定义在 app 层core:ble 保持零 DI 依赖;
* - 测点源默认绑定本地手动实现M5 平台测点源交付后在此替换绑定)。
* - 测点源绑定平台实现M5-01 按任务测组拉 2.9,无任务回退本地手动测点)。
*/
@Module
@InstallIn(SingletonComponent::class)
@@ -63,5 +63,5 @@ abstract class BleBindModule {
@Binds
@Singleton
abstract fun bindPointSource(impl: ManualPointSource): MeasurementPointSource
abstract fun bindPointSource(impl: SurveyGroupPointSource): MeasurementPointSource
}

View File

@@ -2,7 +2,9 @@ package com.stec.cmd.feature.collect
import androidx.annotation.StringRes
import com.stec.cmd.core.ble.ScannedDevice
import com.stec.cmd.core.network.api.ControlPointItem
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.core.network.api.WorkPointItem
import com.stec.cmd.protocol.ie1000.Ie1000Reading
import com.stec.cmd.protocol.vm208.Vm208Quality
import com.stec.cmd.protocol.vm208.Vm208Reading
@@ -48,6 +50,17 @@ data class CollectTarget(
val pointId: String = "",
)
/**
* 采集上下文信息卡数据M5-02/M5-03当前项目的基准点与工点
* 仅作采集参考信息展示,拉取失败时卡片降级、不阻塞采集主动线。
*/
data class CollectContextUi(
/** 基准点清单2.14 ControlPointList水平位移等计算的基准。 */
val controlPoints: List<ControlPointItem> = emptyList(),
/** 工点清单2.15 WorkPointList工点/测项结构)。 */
val workPoints: List<WorkPointItem> = emptyList(),
)
/** 采集会话状态Idle → Preparing连接中→ Running就绪采集中→ Idle。 */
sealed interface CollectSessionState {
data object Idle : CollectSessionState

View File

@@ -3,6 +3,7 @@ package com.stec.cmd.feature.collect
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import com.stec.cmd.core.network.api.TaskItem
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -56,6 +57,42 @@ object CollectPayloadBuilder {
is CollectSample.Vm208 -> sample.frequency.toString()
}
/**
* M5-04 手动录入/补录 payload五必填字段与蓝牙路径完全一致
* ProjectID/PointID/Value/MonitorDate/MonitoringPlanID设备字段以
* MANUAL/手动录入 标识M6 上传通道按平台约定精化时可丢弃扩展键)。
* [monitorDateText] 须为 [MONITOR_DATE_FORMAT] 口径UI 默认当前时间可改)。
*/
fun buildManual(
pointName: String,
pointId: String,
value: String,
monitorDateText: String,
task: TaskItem?,
projectId: String?,
): String {
val root: JsonObject = buildJsonObject {
put("ProjectID", projectId.orEmpty())
put("PointID", pointId)
put("Value", value)
put("MonitorDate", monitorDateText)
put("MonitoringPlanID", task?.PlanID.orEmpty())
// —— 扩展现场字段(与蓝牙路径同键名,来源标识 MANUAL——
put("DeviceType", SOURCE_MANUAL)
put("DeviceName", SOURCE_MANUAL_LABEL)
put("PointName", pointName)
task?.TaskCode?.let { put("TaskCode", it) }
put("Extra", buildJsonObject { put("source", SOURCE_MANUAL) })
}
return root.toString()
}
/** 手动录入来源标识DeviceType/Extra.source。 */
const val SOURCE_MANUAL = "MANUAL"
/** 手动录入设备名占位DeviceName/队列 deviceId。 */
const val SOURCE_MANUAL_LABEL = "手动录入"
private fun extraJson(sample: CollectSample): JsonObject = when (sample) {
is CollectSample.Ie1000 -> buildJsonObject {
put("temperature", sample.temperature.toString())

View File

@@ -18,6 +18,7 @@ import com.stec.cmd.session.SessionRepository
import com.stec.cmd.session.SessionState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -31,7 +32,9 @@ import javax.inject.Inject
* 采集工作台M4-01/02/08 交互编排 + 会话/连接/数据流转发)。
*
* 门控沿用 M3SignedIn 且三要素就绪才可见扫描区;会话离开 SignedIn 复位;
* selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7
* selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7
* 并并发刷新采集上下文(基准点 2.14 / 工点 2.15M5-02/03
* 选定任务按测组拉平台测点2.9M5-01平台测点 ID 贯通上传 payload。
*/
@HiltViewModel
class CollectViewModel @Inject constructor(
@@ -39,6 +42,7 @@ class CollectViewModel @Inject constructor(
private val sessionManager: CollectSessionManager,
private val taskRepository: TaskRepository,
private val pointSource: MeasurementPointSource,
private val pointRepository: PointRepository,
projectSession: ProjectSession,
sessionRepository: SessionRepository,
private val configRepository: ConfigRepository,
@@ -87,9 +91,22 @@ class CollectViewModel @Inject constructor(
val points: StateFlow<List<MeasurementPoint>> = pointSource.points
/** 平台测点拉取中2.9 按任务测组)。 */
private val _pointsLoading = MutableStateFlow(false)
val pointsLoading: StateFlow<Boolean> = _pointsLoading.asStateFlow()
private val _selectedPoint = MutableStateFlow("")
val selectedPoint: StateFlow<String> = _selectedPoint.asStateFlow()
/** 当前选中测点的平台 ID手动文本为空串上传 PointID 占位)。 */
private val _selectedPointId = MutableStateFlow("")
val selectedPointId: StateFlow<String> = _selectedPointId.asStateFlow()
// ---- M5-02/03 采集上下文(基准点/工点)----
private val _contextInfo = MutableStateFlow<UiState<CollectContextUi>>(UiState.Idle)
val contextInfo: StateFlow<UiState<CollectContextUi>> = _contextInfo.asStateFlow()
// ---- 会话/连接/数据CollectSessionManager 转发)----
val collectSession = sessionManager.session
@@ -112,7 +129,10 @@ class CollectViewModel @Inject constructor(
if (state is SessionState.SignedIn) {
if (configRepository.snapshotStateFlow.value.isReady &&
_tasks.value is UiState.Idle
) refreshTasks()
) {
refreshTasks()
refreshContext()
}
} else {
resetSessionUi()
}
@@ -123,15 +143,23 @@ class CollectViewModel @Inject constructor(
if (snapshot.isReady &&
sessionState.value is SessionState.SignedIn &&
_tasks.value is UiState.Idle
) refreshTasks()
) {
refreshTasks()
refreshContext()
}
}
}
viewModelScope.launch {
selectedProject.collect {
_tasks.value = UiState.Idle
_selectedTask.value = null
_selectedPoint.value = ""
_selectedPointId.value = ""
if (it != null && sessionState.value is SessionState.SignedIn) {
refreshTasks()
refreshContext()
} else {
_contextInfo.value = UiState.Idle
}
}
}
@@ -215,16 +243,72 @@ class CollectViewModel @Inject constructor(
fun selectTask(task: TaskItem?) {
_selectedTask.value = task
_selectedPoint.value = ""
_selectedPointId.value = ""
refreshPoints(task)
}
/** 手动录入测点进本地源历史留痕M5 平台测点源交付后由其替换)。 */
/**
* M5-01选定任务按测组拉平台测点2.9)。无 GroupID 不拉取;
* 失败仅提示,选择任务动线不被阻塞(测点可手输)。
*/
private fun refreshPoints(task: TaskItem?) {
if (task?.GroupID.isNullOrBlank()) return
if (_pointsLoading.value) return
viewModelScope.launch {
_pointsLoading.value = true
try {
pointSource.pointsOf(task)
} catch (e: ApiError) {
_events.tryEmit(
CollectEvent.Message(R.string.collect_points_load_failed_fmt, listOf(e.userMessage())),
)
} finally {
_pointsLoading.value = false
}
}
}
/** 从平台测点对话框选定测点:点名回填输入框,平台 ID 随会话贯通。 */
fun pickPoint(point: MeasurementPoint) {
_selectedPoint.value = point.name
_selectedPointId.value = point.id
}
/** 手输测点(或输入框文本变化):按名称匹配平台测点取 ID匹配不到按手动测点占位。 */
fun selectPoint(name: String) {
_selectedPoint.value = name.trim()
val trimmed = name.trim()
_selectedPoint.value = trimmed
_selectedPointId.value = pointSource.points.value
.firstOrNull { it.name == trimmed }?.id.orEmpty()
}
/** M5-02/03并发拉取当前项目基准点2.14与工点2.15),失败仅降级上下文卡。 */
fun refreshContext() {
val projectId = selectedProject.value?.ID
if (projectId.isNullOrBlank()) {
_contextInfo.value = UiState.Idle
return
}
if (_contextInfo.value is UiState.Loading) return
viewModelScope.launch {
_contextInfo.value = UiState.Loading
_contextInfo.value = try {
val control = async { pointRepository.controlPoints(projectId) }
val work = async { pointRepository.workPoints(projectId) }
UiState.Success(CollectContextUi(controlPoints = control.await(), workPoints = work.await()))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
// ---- 会话 ----
/** 开始采集M4-08须已选设备且填写测点测点录入本地源留痕。 */
/**
* 开始采集M4-08须已选设备且填写测点平台测点带 ID 贯通M5-01
* 非平台点仅本地留痕(历史行为)。
*/
fun startCollect(pointInput: String) {
val device = _selectedDevice.value ?: run {
_events.tryEmit(CollectEvent.Message(R.string.collect_need_device))
@@ -236,8 +320,18 @@ class CollectViewModel @Inject constructor(
return
}
selectPoint(point)
viewModelScope.launch { pointSource.record(point) }
sessionManager.start(device, CollectTarget(task = _selectedTask.value, pointName = point))
val isPlatformPoint = pointSource.points.value.any { it.name == point }
if (!isPlatformPoint) {
viewModelScope.launch { pointSource.record(point) }
}
sessionManager.start(
device,
CollectTarget(
task = _selectedTask.value,
pointName = point,
pointId = _selectedPointId.value,
),
)
}
fun stopCollect() = sessionManager.stop()
@@ -265,6 +359,9 @@ class CollectViewModel @Inject constructor(
_selectedDevice.value = null
_tasks.value = UiState.Idle
_selectedTask.value = null
_selectedPoint.value = ""
_selectedPointId.value = ""
_contextInfo.value = UiState.Idle
if (collectSession.value !is CollectSessionState.Idle) stopCollect()
}

View File

@@ -79,6 +79,15 @@ class CollectWorkspaceFragment : Fragment() {
if (binding.switchAutoReconnect.isPressed) viewModel.setAutoReconnect(checked)
}
binding.btnPickTask.setOnClickListener { showTaskPicker() }
binding.btnPickPoint.setOnClickListener { showPointPicker() }
binding.etPoint.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) viewModel.selectPoint(binding.etPoint.text?.toString().orEmpty())
}
binding.btnControlDetail.setOnClickListener { showControlPointDetails() }
binding.btnWorkpointDetail.setOnClickListener { showWorkPointDetails() }
binding.btnManualEntry.setOnClickListener {
openOnTop(com.stec.cmd.feature.collect.ManualEntryFragment())
}
binding.btnStartCollect.setOnClickListener {
viewModel.startCollect(binding.etPoint.text?.toString().orEmpty())
}
@@ -106,6 +115,8 @@ class CollectWorkspaceFragment : Fragment() {
launch { viewModel.tasks.collect { render() } }
launch { viewModel.selectedTask.collect { render() } }
launch { viewModel.selectedPoint.collect { render() } }
launch { viewModel.pointsLoading.collect { render() } }
launch { viewModel.contextInfo.collect { render() } }
launch { viewModel.collectSession.collect { render() } }
launch { viewModel.gattState.collect { render() } }
launch { viewModel.latestSample.collect { renderSample(it) } }
@@ -152,6 +163,11 @@ class CollectWorkspaceFragment : Fragment() {
binding.btnOpenSettings.isVisible = granted == false
binding.btnGrantPermission.isVisible = granted != false
// M5-02/03 上下文卡与 M5-04 手动录入入口:门控通过即常驻(与蓝牙权限无关)
binding.cardContext.isVisible = true
binding.cardManual.isVisible = true
renderContext()
val inSession = viewModel.collectSession.value !is CollectSessionState.Idle
binding.cardConnect.isVisible = granted == true
binding.cardSession.isVisible = granted == true
@@ -183,6 +199,7 @@ class CollectWorkspaceFragment : Fragment() {
val idle = running is CollectSessionState.Idle
binding.btnPickTask.isVisible = idle
binding.tilPoint.isVisible = idle
binding.rowPoint.isVisible = idle
binding.btnStartCollect.isVisible = idle
binding.btnStopCollect.isVisible = !idle
if (idle) {
@@ -190,6 +207,14 @@ class CollectWorkspaceFragment : Fragment() {
binding.btnPickTask.text = task?.let { taskTitle(it) }
?: getString(R.string.collect_pick_task)
binding.btnStartCollect.isEnabled = selected != null
binding.btnPickPoint.isEnabled = !viewModel.pointsLoading.value
binding.btnPickPoint.text = getString(
if (viewModel.pointsLoading.value) {
R.string.collect_points_loading
} else {
R.string.collect_pick_point
},
)
if (binding.etPoint.text.isNullOrBlank() && viewModel.selectedPoint.value.isNotEmpty()) {
binding.etPoint.setText(viewModel.selectedPoint.value)
}
@@ -296,6 +321,124 @@ class CollectWorkspaceFragment : Fragment() {
task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(),
)
/** M5-02/03 上下文卡:加载/降级/数据三态渲染,任何失败不动采集主动线。 */
private fun renderContext() {
when (val state = viewModel.contextInfo.value) {
is UiState.Loading -> {
binding.progressContext.isVisible = true
binding.tvContextError.isVisible = false
binding.rowControl.isVisible = false
binding.rowWorkpoint.isVisible = false
}
is UiState.Error -> {
binding.progressContext.isVisible = false
binding.tvContextError.text = getString(
R.string.collect_context_error_fmt,
state.message,
)
binding.tvContextError.isVisible = true
binding.rowControl.isVisible = false
binding.rowWorkpoint.isVisible = false
}
is UiState.Success -> {
binding.progressContext.isVisible = false
binding.tvContextError.isVisible = false
binding.rowControl.isVisible = true
binding.rowWorkpoint.isVisible = true
binding.tvControlSummary.text = getString(
R.string.collect_context_control_count_fmt,
state.data.controlPoints.size,
)
binding.btnControlDetail.isEnabled = state.data.controlPoints.isNotEmpty()
binding.tvWorkpointSummary.text = getString(
R.string.collect_context_workpoint_count_fmt,
state.data.workPoints.size,
)
binding.btnWorkpointDetail.isEnabled = state.data.workPoints.isNotEmpty()
}
else -> {
binding.progressContext.isVisible = false
binding.tvContextError.isVisible = false
binding.rowControl.isVisible = false
binding.rowWorkpoint.isVisible = false
}
}
}
/** M5-01 平台测点选择对话框(按任务测组 2.9 数据;空列表引导手输)。 */
private fun showPointPicker() {
val points = viewModel.points.value
if (points.isEmpty()) {
showSnackbar(getString(R.string.collect_point_picker_empty))
return
}
val subtitle = viewModel.selectedTask.value?.MonitoringType
?.takeIf { it.isNotBlank() }
?: getString(R.string.collect_task_not_bound)
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.collect_point_dialog_title_fmt, subtitle))
.setItems(points.map { it.name }.toTypedArray()) { _, which ->
val point = points[which]
viewModel.pickPoint(point)
binding.etPoint.setText(point.name)
}
.show()
}
/** M5-02 基准点明细对话框(点名/X/Y/H。 */
private fun showControlPointDetails() {
val data = (viewModel.contextInfo.value as? UiState.Success)?.data ?: return
if (data.controlPoints.isEmpty()) {
showSnackbar(getString(R.string.collect_context_control_empty))
return
}
val lines = data.controlPoints.map {
getString(
R.string.collect_control_item_fmt,
it.Point.orDash(),
it.PointX.orDash(),
it.PointY.orDash(),
it.PointZ.orDash(),
)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.collect_control_dialog_title)
.setItems(lines.toTypedArray(), null)
.show()
}
/** M5-03 工点明细对话框(名称/起止时间;当前任务工点置顶标记)。 */
private fun showWorkPointDetails() {
val data = (viewModel.contextInfo.value as? UiState.Success)?.data ?: return
if (data.workPoints.isEmpty()) {
showSnackbar(getString(R.string.collect_context_workpoint_empty))
return
}
val currentWorkPointId = viewModel.selectedTask.value?.WorkPointID
val lines = data.workPoints.map { workPoint ->
val text = getString(
R.string.collect_workpoint_item_fmt,
workPoint.Name.orDash(),
workPoint.StartDate.orDash(),
workPoint.EndDate.orDash(),
)
if (currentWorkPointId != null && workPoint.ID == currentWorkPointId) {
getString(R.string.collect_workpoint_current_mark_fmt, text)
} else {
text
}
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.collect_workpoint_dialog_title)
.setItems(lines.toTypedArray(), null)
.show()
}
private fun String?.orDash(): String = this?.takeIf { it.isNotBlank() } ?: "-"
private fun showEvent(event: CollectEvent) {
val text = when (event) {
is CollectEvent.Connected ->
@@ -330,6 +473,8 @@ class CollectWorkspaceFragment : Fragment() {
binding.cardSession.isVisible = false
binding.cardData.isVisible = false
binding.cardExcitation.isVisible = false
binding.cardContext.isVisible = false
binding.cardManual.isVisible = false
if (action == GateAction.NONE) return
binding.tvGateHint.setText(
when (action) {

View File

@@ -0,0 +1,275 @@
package com.stec.cmd.feature.collect
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 com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
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.FragmentManualEntryBinding
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.LocalDateTime
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
/**
* 手动录入/补录M5-04无法蓝牙采集时人工录入观测值
* 复核确认后与蓝牙采集同构 payload 入同一条上传队列entrySource=MANUAL
*/
@AndroidEntryPoint
class ManualEntryFragment : Fragment() {
private var _binding: FragmentManualEntryBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: ManualEntryViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentManualEntryBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
binding.etDate.setText(nowText())
binding.btnPickTask.setOnClickListener { showTaskPicker() }
binding.btnPickPoint.setOnClickListener { showPointPicker() }
binding.etPoint.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) syncPointId(binding.etPoint.text?.toString().orEmpty())
}
binding.btnReview.setOnClickListener { reviewAndEnqueue() }
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.events.collect { event ->
when (event) {
is ManualEntryEvent.Message -> showSnackbar(event.text)
}
}
}
launch { viewModel.sessionState.collect { render() } }
launch { viewModel.configSnapshot.collect { render() } }
launch { viewModel.selectedProject.collect { render() } }
launch { viewModel.tasks.collect { render() } }
launch { viewModel.selectedTask.collect { render() } }
launch { viewModel.pointsLoading.collect { render() } }
}
}
}
/** 门控渲染 + 表单态;门控通过且任务未加载时自动拉 2.7 任务列表。 */
private fun render() = with(viewModel) {
when {
sessionState.value is SessionState.Restoring -> showGate(GateAction.NONE)
sessionState.value is SessionState.SignedOut -> showGate(GateAction.LOGIN)
!configSnapshot.value.isReady -> showGate(GateAction.CONFIG)
selectedProject.value == null -> showGate(GateAction.PROJECT)
else -> {
binding.cardGate.isVisible = false
binding.cardForm.isVisible = true
if (tasks.value is UiState.Idle) refreshTasks()
}
}
val task = selectedTask.value
binding.btnPickTask.text = task?.let { taskTitle(it) }
?: getString(R.string.manual_entry_pick_task)
binding.btnPickPoint.isEnabled = !pointsLoading.value
binding.btnPickPoint.text = getString(
if (pointsLoading.value) R.string.collect_points_loading else R.string.manual_entry_pick_point,
)
}
/** 门控卡:与采集页动线一致(登录/配置/去首页选项目)。 */
private fun showGate(action: GateAction) {
binding.cardGate.isVisible = action != GateAction.NONE
binding.cardForm.isVisible = false
if (action == GateAction.NONE) return
binding.tvGateHint.setText(
when (action) {
GateAction.LOGIN -> R.string.collect_gate_signed_out
GateAction.CONFIG -> R.string.collect_gate_no_config
else -> R.string.collect_gate_no_project
},
)
binding.btnGateAction.setText(
when (action) {
GateAction.LOGIN -> R.string.collect_gate_go_login
GateAction.CONFIG -> R.string.collect_gate_go_config
else -> R.string.collect_gate_go_home
},
)
binding.btnGateAction.setOnClickListener {
when (action) {
GateAction.LOGIN -> openOnTop(LoginFragment())
GateAction.CONFIG -> openOnTop(ConfigFragment())
else -> {
requireActivity()
.findViewById<BottomNavigationView>(R.id.bottom_nav)
.selectedItemId = R.id.nav_home
}
}
}
}
/** 任务选择对话框2.7 计划任务 + 不绑定选项)。 */
private fun showTaskPicker() {
val state = viewModel.tasks.value
if (state is UiState.Error) {
viewModel.refreshTasks()
return
}
val tasks = (state as? UiState.Success)?.data.orEmpty()
val titles = tasks.map(::taskTitle) + getString(R.string.manual_entry_task_none)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.manual_entry_pick_task)
.setItems(titles.toTypedArray()) { _, which ->
viewModel.selectTask(tasks.getOrNull(which))
}
.show()
}
/** 平台测点选择对话框(按任务测组 2.9 数据;空列表引导手输)。 */
private fun showPointPicker() {
val points = viewModel.points.value
if (points.isEmpty()) {
showSnackbar(getString(R.string.collect_point_picker_empty))
return
}
val subtitle = viewModel.selectedTask.value?.MonitoringType
?.takeIf { it.isNotBlank() }
?: getString(R.string.collect_task_not_bound)
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.collect_point_dialog_title_fmt, subtitle))
.setItems(points.map { it.name }.toTypedArray()) { _, which ->
val point = points[which]
binding.etPoint.setText(point.name)
syncPointId(point.name)
}
.show()
}
/** 手输/选择测点后同步平台 ID点名为平台点时带 ID 入 payload。 */
private fun syncPointId(name: String) {
val trimmed = name.trim()
viewModel.points.value
.firstOrNull { it.name == trimmed }
?.let { point -> viewModel.pickPoint(point) }
}
/** 格式校验 → 复核对话框 → 确认入队。 */
private fun reviewAndEnqueue() {
val pointName = binding.etPoint.text?.toString()?.trim().orEmpty()
if (pointName.isEmpty()) {
showSnackbar(getString(R.string.manual_entry_need_point))
return
}
val value = binding.etValue.text?.toString()?.trim().orEmpty()
if (value.isEmpty()) {
showSnackbar(getString(R.string.manual_entry_need_value))
return
}
value.toDoubleOrNull() ?: run {
showSnackbar(getString(R.string.manual_entry_value_invalid))
return
}
val dateText = binding.etDate.text?.toString()?.trim().orEmpty()
if (dateText.isEmpty()) {
showSnackbar(getString(R.string.manual_entry_need_date))
return
}
runCatching { LocalDateTime.parse(dateText, CollectPayloadBuilder.MONITOR_DATE_FORMAT) }
.onFailure {
showSnackbar(getString(R.string.manual_entry_date_invalid))
return
}
val taskText = viewModel.selectedTask.value?.let { taskTitle(it) }
?: getString(R.string.manual_entry_task_none)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.manual_entry_review_title)
.setMessage(
getString(R.string.manual_entry_review_fmt, taskText, pointName, value, dateText),
)
.setPositiveButton(R.string.manual_entry_review_confirm) { _, _ ->
enqueue(pointName, value, dateText)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun enqueue(pointName: String, value: String, dateText: String) {
val pointId = viewModel.points.value
.firstOrNull { it.name == pointName }?.id.orEmpty()
viewLifecycleOwner.lifecycleScope.launch {
val queued = viewModel.enqueue(
pointName = pointName,
pointId = pointId,
value = value,
monitorDateText = dateText,
)
if (queued) {
showSnackbar(getString(R.string.manual_entry_queued))
binding.etValue.setText("")
binding.etDate.setText(nowText())
} else {
showSnackbar(getString(R.string.manual_entry_queued_failed))
}
}
}
private fun nowText(): String =
LocalDateTime.now().format(CollectPayloadBuilder.MONITOR_DATE_FORMAT)
private fun taskTitle(task: TaskItem): String = getString(
R.string.collect_task_fmt,
task.TaskCode?.takeIf { it.isNotBlank() } ?: task.SimpleName.orEmpty(),
task.MonitoringType.orEmpty(),
task.WorkPointName?.takeIf { it.isNotBlank() } ?: task.period.orEmpty(),
)
private fun showSnackbar(text: String) {
Snackbar.make(binding.root, text, Snackbar.LENGTH_SHORT).show()
}
/** 子页跳转(登录/配置add + hide + addToBackStack壳层同款模式。 */
private fun openOnTop(fragment: Fragment) {
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, fragment, "manual_entry_sub")
hide(this@ManualEntryFragment)
addToBackStack(null)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/** 门控动作类型。 */
private enum class GateAction {
NONE,
LOGIN,
CONFIG,
PROJECT,
}
}

View File

@@ -0,0 +1,151 @@
package com.stec.cmd.feature.collect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.database.UploadEntrySource
import com.stec.cmd.core.database.UploadQueueDao
import com.stec.cmd.core.database.UploadQueueEntity
import com.stec.cmd.core.database.UploadStatus
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.feature.task.TaskRepository
import com.stec.cmd.session.ProjectSession
import com.stec.cmd.session.SessionRepository
import com.stec.cmd.session.SessionState
import com.stec.cmd.config.ConfigRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* 手动录入/补录M5-04表单数据编排 + 与蓝牙采集同队列入队。
*
* 任务列表复用 2.7(采集页同源);测点经 [MeasurementPointSource](选任务按
* 测组拉 2.9,无任务手输);入队 payload 与蓝牙路径同构CollectPayloadBuilder
* entrySource=MANUAL 与 BLE 共存一条 upload_queueM6 上传通道统一消费。
*/
@HiltViewModel
class ManualEntryViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val pointSource: MeasurementPointSource,
private val uploadQueueDao: UploadQueueDao,
private val projectSession: ProjectSession,
sessionRepository: SessionRepository,
private val configRepository: ConfigRepository,
) : ViewModel() {
/** 会话状态流Restoring/SignedOut/SignedIn 门控)。 */
val sessionState = sessionRepository.state
/** 连接配置快照流(三要素缺失时引导配置)。 */
val configSnapshot = configRepository.snapshotStateFlow
/** 当前选中项目(页面门控与 payload 归属)。 */
val selectedProject = projectSession.selectedProject
private val _tasks = MutableStateFlow<UiState<List<TaskItem>>>(UiState.Idle)
val tasks: StateFlow<UiState<List<TaskItem>>> = _tasks.asStateFlow()
private val _selectedTask = MutableStateFlow<TaskItem?>(null)
val selectedTask: StateFlow<TaskItem?> = _selectedTask.asStateFlow()
val points: StateFlow<List<MeasurementPoint>> = pointSource.points
private val _pointsLoading = MutableStateFlow(false)
val pointsLoading: StateFlow<Boolean> = _pointsLoading.asStateFlow()
private val _events = MutableSharedFlow<ManualEntryEvent>(extraBufferCapacity = 8)
val events: SharedFlow<ManualEntryEvent> = _events.asSharedFlow()
private var taskJob: Job? = null
/** 拉取当前项目计划任务2.7,采集页同源);项目/会话就绪时由 Fragment 触发。 */
fun refreshTasks() {
if (_tasks.value is UiState.Loading) return
taskJob?.cancel()
taskJob = viewModelScope.launch {
_tasks.value = UiState.Loading
_tasks.value = try {
UiState.Success(taskRepository.planTasks(selectedProject.value?.ID))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
/** 选定任务清空测点选择并按测组拉平台测点2.9);失败仅提示不阻塞手输。 */
fun selectTask(task: TaskItem?) {
_selectedTask.value = task
viewModelScope.launch {
_pointsLoading.value = true
try {
pointSource.pointsOf(task)
} catch (e: ApiError) {
_events.tryEmit(ManualEntryEvent.Message(e.userMessage()))
} finally {
_pointsLoading.value = false
}
}
}
/**
* 复核确认后入队UI 已做格式校验):构造与蓝牙采集同构 payload
* entrySource=MANUAL 入同一条 Room 上传队列pendingM6 通道消费)。
* @return 入队是否成功
*/
suspend fun enqueue(
pointName: String,
pointId: String,
value: String,
monitorDateText: String,
): Boolean = withContext(Dispatchers.IO) {
try {
val payload = CollectPayloadBuilder.buildManual(
pointName = pointName,
pointId = pointId,
value = value,
monitorDateText = monitorDateText,
task = _selectedTask.value,
projectId = projectSession.selectedProject.value?.ID,
)
uploadQueueDao.enqueue(
UploadQueueEntity(
deviceType = CollectPayloadBuilder.SOURCE_MANUAL,
deviceId = CollectPayloadBuilder.SOURCE_MANUAL_LABEL,
dataType = _selectedTask.value?.MonitoringType?.takeIf { it.isNotBlank() }
?: DATA_TYPE_MANUAL,
payloadJson = payload,
status = UploadStatus.PENDING,
createdAt = System.currentTimeMillis(),
entrySource = UploadEntrySource.MANUAL,
),
)
true
} catch (e: Exception) {
false
}
}
private companion object {
/** 无任务绑定时队列 dataType 占位。 */
const val DATA_TYPE_MANUAL = "手动录入"
}
}
/** 手动录入页一次性事件。 */
sealed interface ManualEntryEvent {
/** 提示文本(如测点拉取失败但仍可手输)。 */
data class Message(val text: String) : ManualEntryEvent
}

View File

@@ -7,39 +7,56 @@ import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/** 测点候选:[id] 为平台测点 IDM5 交付前手动测点为空串)。 */
/** 测点候选:[id] 为平台测点 ID手动录入测点为空串,上传 PointID 占位)。 */
data class MeasurementPoint(
val id: String,
val name: String,
)
/**
* 测点源抽象M4-08 预留接口)。
* 测点源抽象M4-08 预留接口M5 回填平台实现)。
*
* M4 交付 [ManualPointSource] 本地手动源M5 子任务回填真实平台测点源
* (按任务测组拉取 GetPointBySurveyGroup后经 Hilt 替换绑定,采集会话零改动
* 采集会话零改动UI 下拉/对话框读 [points],选任务经 [pointsOf] 按测组刷新,
* 自由文本经 [record] 留痕
*/
interface MeasurementPointSource {
/** 可用测点流(UI 下拉/历史展示M5 实现按任务测组实时刷新)。 */
/** 可用测点流(最近一次 [pointsOf] 的平台测组测点,或本地手动测点)。 */
val points: StateFlow<List<MeasurementPoint>>
/** 拉取 [task] 测组可用测点task 为 null 时返回本地手动测点。 */
/** 拉取 [task] 测组可用测点task 为 null 或无 GroupID 时返回本地手动测点。 */
suspend fun pointsOf(task: TaskItem?): List<MeasurementPoint>
/** 手动录入测点;空名/重复返回既有项或 null。 */
suspend fun record(name: String): MeasurementPoint?
}
/** 本地手动测点源(会话内存态,应用进程内保留历史录入)。 */
/**
* 平台测点源M5-01选任务按测组拉 2.9 监测点列表,平台测点 ID 贯通
* CollectTarget.pointId → 上传 payload PointID无任务/无 GroupID/拉取失败时
* 保留本地手动测点(承接原 ManualPointSource 行为,动线不断)。
*/
@Singleton
class ManualPointSource @Inject constructor() : MeasurementPointSource {
class SurveyGroupPointSource @Inject constructor(
private val pointRepository: PointRepository,
) : MeasurementPointSource {
private val platform = MutableStateFlow<List<MeasurementPoint>>(emptyList())
private val manual = MutableStateFlow<List<MeasurementPoint>>(emptyList())
override val points: StateFlow<List<MeasurementPoint>> = manual.asStateFlow()
override val points: StateFlow<List<MeasurementPoint>> = platform.asStateFlow()
override suspend fun pointsOf(task: TaskItem?): List<MeasurementPoint> = manual.value
override suspend fun pointsOf(task: TaskItem?): List<MeasurementPoint> {
val groupId = task?.GroupID?.takeIf { it.isNotBlank() } ?: run {
platform.value = emptyList()
return manual.value
}
val fetched = pointRepository.pointsBySurveyGroup(groupId).map { item ->
MeasurementPoint(id = item.ID.orEmpty(), name = item.displayName.orEmpty())
}.filter { it.name.isNotEmpty() }
platform.value = fetched
return fetched
}
override suspend fun record(name: String): MeasurementPoint? {
val trimmed = name.trim()

View File

@@ -0,0 +1,92 @@
package com.stec.cmd.feature.collect
import com.stec.cmd.core.network.ApiCaller
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.ControlPointItem
import com.stec.cmd.core.network.api.MonitorPointItem
import com.stec.cmd.core.network.api.PointApi
import com.stec.cmd.core.network.api.WorkPointItem
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
/**
* 测点数据仓储M5-01/02/032.9 GetPointBySurveyGroup / 2.14 ControlPoint / 2.15 GetWorkPoint
*
* 契约宽容data 形态以各列表归一提取函数统一处理——
* 兼容文档口径的 `{"XxxList":[…]}` 对象、平铺数组、以及二次 JSON 编码字符串
* ProjectRepository.projectListFromData / TaskRepository.taskListFromData 先例,
* 缺键时取对象内首个 JsonArray 兜底,防御键名大小写漂移)。
*/
@Singleton
class PointRepository @Inject constructor(
private val pointApi: PointApi,
private val apiCaller: ApiCaller,
private val json: Json,
) {
/** 2.9 测组监测点列表;[groupId] 必填(任务 GroupID。 */
suspend fun pointsBySurveyGroup(groupId: String): List<MonitorPointItem> =
listFromData(apiCaller.call { pointApi.getPointBySurveyGroup(groupId) }, KEY_POINT_LIST) {
ListSerializer(MonitorPointItem.serializer())
}
/** 2.14 项目基准点列表;[projectId] 必填。 */
suspend fun controlPoints(projectId: String): List<ControlPointItem> =
listFromData(apiCaller.call { pointApi.getControlPoints(projectId) }, KEY_CONTROL_POINT_LIST) {
ListSerializer(ControlPointItem.serializer())
}
/** 2.15 工点列表;[projectId]/[workPointId] 传 null 时返回本人全部进行中工点。 */
suspend fun workPoints(projectId: String? = null, workPointId: String? = null): List<WorkPointItem> =
listFromData(
apiCaller.call { pointApi.getWorkPoints(projectId, workPointId) },
KEY_WORK_POINT_LIST,
) {
ListSerializer(WorkPointItem.serializer())
}
/**
* data → 列表归一提取TaskRepository.taskListFromData 同款宽容链):
* - JsonObject优先取 [key](文档口径);缺键取首个 JsonArray 值兜底;
* - JsonArray文档口径的平铺数组
* - JsonPrimitive二次 JSON 编码的列表字符串。
* 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。
*/
private inline fun <reified T : Any> listFromData(
data: JsonElement,
key: String,
serializer: () -> ListSerializer<T>,
): List<T> {
val payload = when (data) {
is JsonObject ->
data[key]
?: data.values.filterIsInstance<JsonArray>().firstOrNull()
?: throw ApiError.EmptyBodyError("测点数据结构异常")
else -> data
}
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.9/2.14/2.15data 内列表键名(确切大小写)。 */
const val KEY_POINT_LIST = "PointList"
const val KEY_CONTROL_POINT_LIST = "ControlPointList"
const val KEY_WORK_POINT_LIST = "WorkPointList"
}
}

View File

@@ -0,0 +1,111 @@
package com.stec.cmd.feature.collect
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.stec.cmd.R
import com.stec.cmd.core.network.api.MonitorPointItem
import com.stec.cmd.databinding.ItemSurveyPointBinding
/**
* 测组监测点列表M5-012.9 PointList点名/简称 + 初值/上次观测 +
* 报警值 + 观测时间/轴力类型;无值字段以「-」占位。
*/
class SurveyPointListAdapter :
ListAdapter<MonitorPointItem, SurveyPointListAdapter.PointViewHolder>(DIFF) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PointViewHolder =
PointViewHolder(
ItemSurveyPointBinding.inflate(LayoutInflater.from(parent.context), parent, false),
)
override fun onBindViewHolder(holder: PointViewHolder, position: Int) =
holder.bind(getItem(position))
inner class PointViewHolder(
private val binding: ItemSurveyPointBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: MonitorPointItem) {
val context = binding.root.context
binding.tvPointTitle.text = context.getString(
R.string.survey_point_item_title_fmt,
item.PointName.orDash(context),
item.PointSimpleName.orDash(context),
)
binding.tvPointMeta.text = buildString {
append(
context.getString(
R.string.survey_point_initial_fmt,
item.initialSummary(context),
),
)
append("\n")
append(
context.getString(
R.string.survey_point_last_fmt,
item.lastMonitorSummary(context),
),
)
}
binding.tvPointMonitor.text = buildString {
append(
context.getString(
R.string.survey_point_monitor_fmt,
item.MonitorDate.orDash(context),
),
)
item.AxiaType?.takeIf { it.isNotBlank() }?.let {
append(" · ")
append(context.getString(R.string.survey_point_axia_fmt, it))
}
}
binding.tvPointAlarm.isVisible =
!item.RateAlarm.isNullOrBlank() || !item.TotalAlarm.isNullOrBlank()
binding.tvPointAlarm.text = context.getString(
R.string.survey_point_alarm_fmt,
item.RateAlarm.orDash(context),
item.TotalAlarm.orDash(context),
)
}
}
private companion object {
val DIFF = object : DiffUtil.ItemCallback<MonitorPointItem>() {
override fun areItemsTheSame(oldItem: MonitorPointItem, newItem: MonitorPointItem) =
oldItem.ID == newItem.ID && oldItem.PointName == newItem.PointName
override fun areContentsTheSame(oldItem: MonitorPointItem, newItem: MonitorPointItem) =
oldItem == newItem
}
}
}
/** 空值占位。 */
private fun String?.orDash(context: android.content.Context): String =
this?.takeIf { it.isNotBlank() } ?: context.getString(R.string.collect_point_value_none)
/** 初值列表摘要多条以「Attribute=值」拼接。 */
private fun MonitorPointItem.initialSummary(context: android.content.Context): String =
InitialValueData.takeIf { it.isNotEmpty() }
?.joinToString(" / ") { value ->
listOfNotNull(
value.Attribute,
value.valueText ?: context.getString(R.string.collect_point_value_none),
).joinToString("=")
}
?: context.getString(R.string.collect_point_value_none)
/** 上次观值列表摘要LastMonitorData/Date 归一访问器)。 */
private fun MonitorPointItem.lastMonitorSummary(context: android.content.Context): String =
lastMonitorValues.takeIf { it.isNotEmpty() }
?.joinToString(" / ") { value ->
listOfNotNull(
value.Attribute,
value.valueText ?: context.getString(R.string.collect_point_value_none),
).joinToString("=")
}
?: context.getString(R.string.collect_point_value_none)

View File

@@ -0,0 +1,103 @@
package com.stec.cmd.feature.collect
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.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.api.MonitorPointItem
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.FragmentSurveyPointListBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
/**
* 测组监测点列表页M5-012.9任务详情M3-03「查看测点」入口的落地页。
*
* 传参沿用 TaskDetailFragment 的 arguments JSON 先例;无独立门控卡——
* 入口均在任务/采集页门控之后,拉取失败以错误态+重试呈现。
*/
@AndroidEntryPoint
class SurveyPointListFragment : Fragment() {
private var _binding: FragmentSurveyPointListBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: SurveyPointListViewModel by viewModels()
private val adapter by lazy { SurveyPointListAdapter() }
/** 传参解析失败按空任务处理(本端自编码 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 = FragmentSurveyPointListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
binding.toolbar.title = getString(
R.string.survey_point_title_fmt,
task?.MonitoringType?.takeIf { it.isNotBlank() } ?: "-",
)
binding.recyclerPoints.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerPoints.adapter = adapter
binding.btnRetry.setOnClickListener { viewModel.refresh() }
viewModel.bind(task)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.points.collect { render(it) }
}
}
}
private fun render(state: UiState<List<MonitorPointItem>>) {
binding.progressPoints.isVisible = state is UiState.Loading
binding.boxError.isVisible = state is UiState.Error
if (state is UiState.Error) {
binding.tvError.text = getString(R.string.survey_point_error_fmt, state.message)
}
val points = (state as? UiState.Success)?.data.orEmpty()
binding.tvEmpty.isVisible = state is UiState.Success && points.isEmpty()
binding.recyclerPoints.isVisible = points.isNotEmpty()
adapter.submitList(points)
}
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): SurveyPointListFragment =
SurveyPointListFragment().apply {
arguments = Bundle().apply { putString(ARG_TASK_JSON, taskJson) }
}
}
}

View File

@@ -0,0 +1,54 @@
package com.stec.cmd.feature.collect
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.MonitorPointItem
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.feature.mine.userMessage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 测组监测点列表M5-01按任务测组拉取 2.9 监测点列表。
* 任务经 [bind] 注入TaskDetailFragment newInstance(taskJson) 先例)。
*/
@HiltViewModel
class SurveyPointListViewModel @Inject constructor(
private val pointRepository: PointRepository,
) : ViewModel() {
private val _points = MutableStateFlow<UiState<List<MonitorPointItem>>>(UiState.Idle)
val points: StateFlow<UiState<List<MonitorPointItem>>> = _points.asStateFlow()
private var groupId: String? = null
/** 绑定任务并立即拉取GroupID 缺失时直接落错误态(入口侧已隐藏,防御兜底)。 */
fun bind(task: TaskItem?) {
groupId = task?.GroupID?.takeIf { it.isNotBlank() }
refresh()
}
/** 拉取/重试测点列表。 */
fun refresh() {
val id = groupId
if (id == null) {
_points.value = UiState.Error("任务缺少测组 ID无法获取监测点")
return
}
if (_points.value is UiState.Loading) return
viewModelScope.launch {
_points.value = UiState.Loading
_points.value = try {
UiState.Success(pointRepository.pointsBySurveyGroup(id))
} catch (e: ApiError) {
UiState.Error(e.userMessage())
}
}
}
}

View File

@@ -6,9 +6,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.stec.cmd.R
import com.stec.cmd.core.network.api.TaskItem
import com.stec.cmd.databinding.FragmentTaskDetailBinding
import com.stec.cmd.feature.collect.SurveyPointListFragment
import kotlinx.serialization.json.Json
/**
@@ -42,6 +44,17 @@ class TaskDetailFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
render()
// M5-01有测组 ID 的任务可跳转测组监测点列表2.9
binding.btnViewPoints.isVisible = task?.GroupID?.isNotBlank() == true
binding.btnViewPoints.setOnClickListener {
val raw = arguments?.getString(ARG_TASK_JSON) ?: return@setOnClickListener
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, SurveyPointListFragment.newInstance(raw))
hide(this@TaskDetailFragment)
addToBackStack(null)
}
}
}
private fun render() {

View File

@@ -234,6 +234,97 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M5-02/03 采集上下文:基准点 + 工点(数据不可得时降级提示,不阻塞采集) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_context"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_context_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_context"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<TextView
android:id="@+id/tv_context_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?attr/colorError"
android:textSize="12sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/row_control"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_control_summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/collect_context_control_label"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_control_detail"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/collect_context_detail" />
</LinearLayout>
<LinearLayout
android:id="@+id/row_workpoint"
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_workpoint_summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/collect_context_workpoint_label"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_workpoint_detail"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/collect_context_detail" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M4-08 采集会话:绑定任务与测点 + 开始/停止 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_session"
@@ -265,22 +356,39 @@
android:layout_marginTop="10dp"
android:text="@string/collect_pick_task" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_point"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
<LinearLayout
android:id="@+id/row_point"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/collect_point_hint">
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_point"
android:layout_width="match_parent"
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_point"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
android:layout_weight="1"
android:hint="@string/collect_point_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_point"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_point"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/collect_pick_point" />
</LinearLayout>
<TextView
android:id="@+id/tv_bound_target"
@@ -536,5 +644,37 @@
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- M5-04 手动录入/补录入口(无法蓝牙采集时人工录入,同一上传队列) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_manual"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_manual_entry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/collect_manual_entry" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:alpha="0.6"
android:text="@string/collect_manual_entry_hint"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 手动录入/补录M5-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/manual_entry_title"
app:titleTextColor="?attr/colorOnSurface" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
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
android:id="@+id/card_gate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:visibility="gone"
app:cardCornerRadius="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_gate_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_gate_action"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/collect_gate_go_login" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
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">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_task"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/manual_entry_pick_task" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_point"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/manual_entry_point_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_point"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_pick_point"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/manual_entry_pick_point" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_value"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:hint="@string/manual_entry_value_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_date"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:hint="@string/manual_entry_date_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_review"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/manual_entry_review" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.6"
android:text="@string/collect_manual_entry_hint"
android:textSize="12sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 测组监测点列表M5-012.9 GetPointBySurveyGroup -->
<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/survey_point_title_fmt"
app:titleTextColor="?attr/colorOnSurface" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_points"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingHorizontal="20dp"
android:paddingTop="8dp"
android:paddingBottom="32dp" />
<TextView
android:id="@+id/tv_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:alpha="0.6"
android:text="@string/survey_point_empty"
android:textSize="14sp"
android:visibility="gone" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_points"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:indeterminate="true"
android:visibility="gone"
app:trackCornerRadius="3dp" />
<LinearLayout
android:id="@+id/box_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="13sp" />
<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="12dp"
android:text="@string/survey_point_retry" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -305,6 +305,16 @@
app:indicatorColor="?attr/colorPrimary"
app:trackCornerRadius="4dp"
app:trackThickness="8dp" />
<!-- M5-01有测组 ID 的任务可跳转测点列表 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_view_points"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/survey_point_open"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 测组监测点条目M5-01点名/简称 + 初值/上次观测 + 报警值 + 观测时间/轴力类型 -->
<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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="14dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/tv_point_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_point_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:alpha="0.75"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_point_monitor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.75"
android:textSize="12sp" />
<TextView
android:id="@+id/tv_point_alarm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="?attr/colorError"
android:textSize="12sp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -165,7 +165,7 @@
<string name="collect_task_none">不绑定任务</string>
<string name="collect_task_fmt">%1$s · %2$s · %3$s</string>
<string name="collect_point_label">测点</string>
<string name="collect_point_hint">输入或选择测点名M5 平台测点源待接入)</string>
<string name="collect_point_hint">输入或选择测点名</string>
<string name="collect_start">开始采集</string>
<string name="collect_stop">停止采集</string>
<string name="collect_need_device">请先选择设备并连接</string>
@@ -194,4 +194,62 @@
<string name="collect_event_reconnecting_fmt">第 %1$d 次重连(约 %2$d 秒后重试)</string>
<string name="collect_scan_failed_fmt">扫描失败code=%1$d</string>
<string name="collect_task_not_bound">未绑定任务</string>
<!-- M5-01 测组监测点列表 -->
<string name="collect_pick_point">选择</string>
<string name="collect_point_dialog_title_fmt">选择测点(%1$s</string>
<string name="collect_point_picker_empty">当前任务暂无平台测点,可直接输入点名</string>
<string name="collect_points_loading">测点加载中…</string>
<string name="collect_points_load_failed_fmt">测点拉取失败:%1$s</string>
<string name="collect_point_value_none">-</string>
<!-- M5-02/03 采集上下文 -->
<string name="collect_context_title">采集上下文</string>
<string name="collect_context_control_label">基准点(水平位移计算基准)</string>
<string name="collect_context_workpoint_label">工点</string>
<string name="collect_context_detail">查看明细</string>
<string name="collect_context_control_count_fmt">基准点 %1$d 个(水平位移计算基准)</string>
<string name="collect_context_workpoint_count_fmt">工点 %1$d 个</string>
<string name="collect_context_error_fmt">上下文数据获取失败:%1$s不影响采集</string>
<string name="collect_control_dialog_title">基准点明细</string>
<string name="collect_control_item_fmt">%1$s X:%2$s Y:%3$s H:%4$s</string>
<string name="collect_workpoint_dialog_title">工点列表</string>
<string name="collect_workpoint_item_fmt">%1$s%2$s ~ %3$s</string>
<string name="collect_workpoint_current_mark_fmt">▶ %1$s当前任务工点</string>
<!-- M5-04 手动录入/补录 -->
<string name="collect_manual_entry">手动录入 / 补录</string>
<string name="collect_manual_entry_hint">无法蓝牙采集时人工录入,与蓝牙采集数据走同一条上传队列</string>
<string name="manual_entry_title">手动录入 / 补录</string>
<string name="manual_entry_pick_task">选择任务</string>
<string name="manual_entry_task_none">不绑定任务</string>
<string name="manual_entry_pick_point">选择测点</string>
<string name="manual_entry_point_hint">输入或选择测点名</string>
<string name="manual_entry_value_hint">本次观测值(数值)</string>
<string name="manual_entry_date_hint">监测时间yyyy-MM-dd HH:mm:ss</string>
<string name="manual_entry_review">复核并入队</string>
<string name="manual_entry_review_title">录入复核</string>
<string name="manual_entry_review_fmt">任务:%1$s\n测点%2$s\n数值%3$s\n监测时间%4$s\n\n确认后进入上传队列待上传</string>
<string name="manual_entry_review_confirm">确认入队</string>
<string name="manual_entry_queued">已入队待上传</string>
<string name="manual_entry_queued_failed">入队失败,请重试</string>
<string name="manual_entry_need_point">请先选择或输入测点名</string>
<string name="manual_entry_need_value">请输入数值</string>
<string name="manual_entry_value_invalid">数值格式不正确</string>
<string name="manual_entry_need_date">请输入监测时间</string>
<string name="manual_entry_date_invalid">时间格式应为 yyyy-MM-dd HH:mm:ss</string>
<string name="manual_entry_points_load_failed_fmt">测点拉取失败:%1$s可直接输入点名</string>
<!-- M5-01 测组测点列表页 -->
<string name="survey_point_title_fmt">测组测点(%1$s</string>
<string name="survey_point_empty">该测组暂无监测点</string>
<string name="survey_point_error_fmt">加载失败:%1$s</string>
<string name="survey_point_retry">重试</string>
<string name="survey_point_open">查看测点</string>
<string name="survey_point_item_title_fmt">%1$s%2$s</string>
<string name="survey_point_initial_fmt">初值:%1$s</string>
<string name="survey_point_last_fmt">上次观测:%1$s</string>
<string name="survey_point_monitor_fmt">上次观测时间:%1$s</string>
<string name="survey_point_alarm_fmt">报警:速率 %1$s 累计 %2$s</string>
<string name="survey_point_axia_fmt">轴力类型:%1$s</string>
</resources>

View File

@@ -14,7 +14,7 @@ import androidx.room.RoomDatabase
entities = [
UploadQueueEntity::class,
],
version = 1,
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {

View File

@@ -2,6 +2,8 @@ package com.stec.cmd.core.database
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -14,6 +16,15 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object DatabaseModule {
/** v1→v2upload_queue 加 entry_source 列M5-04 手动录入来源标识),保数据非破坏迁移。 */
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE upload_queue ADD COLUMN entry_source TEXT NOT NULL DEFAULT 'BLE'",
)
}
}
@Provides
@Singleton
fun provideAppDatabase(
@@ -22,7 +33,7 @@ object DatabaseModule {
context,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME,
).build()
).addMigrations(MIGRATION_1_2).build()
@Provides
@Singleton

View File

@@ -49,6 +49,10 @@ data class UploadQueueEntity(
/** 上传成功时间epoch 毫秒),未上传为 null。 */
@ColumnInfo(name = "uploaded_at")
val uploadedAt: Long? = null,
/** 入队来源BLE 蓝牙采集 / MANUAL 手动录入(见 [UploadEntrySource]M5-04。 */
@ColumnInfo(name = "entry_source", defaultValue = UploadEntrySource.BLE)
val entrySource: String = UploadEntrySource.BLE,
)
/** 队列状态常量S0 用 Int 避免 Room TypeConverter 样板S1 可按需替换为枚举)。 */
@@ -65,3 +69,12 @@ object UploadStatus {
/** 失败待重试。 */
const val FAILED = 3
}
/** 入队来源常量M5-04手动录入与蓝牙采集共用一条队列来源字段区分。 */
object UploadEntrySource {
/** 蓝牙采集M4 动线,历史数据缺省值)。 */
const val BLE = "BLE"
/** 手动录入/补录M5-04无法蓝牙采集时人工录入。 */
const val MANUAL = "MANUAL"
}

View File

@@ -1,6 +1,7 @@
package com.stec.cmd.core.network
import com.stec.cmd.core.network.api.AuthApi
import com.stec.cmd.core.network.api.PointApi
import com.stec.cmd.core.network.api.ProjectApi
import com.stec.cmd.core.network.api.TaskApi
import dagger.Module
@@ -84,6 +85,11 @@ object NetworkModule {
@Singleton
fun provideTaskApi(retrofit: Retrofit): TaskApi = retrofit.create(TaskApi::class.java)
/** 测点数据接口M52.9/2.14/2.15)。 */
@Provides
@Singleton
fun providePointApi(retrofit: Retrofit): PointApi = retrofit.create(PointApi::class.java)
/** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */
private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/"

View File

@@ -0,0 +1,55 @@
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.9 获取测组包含的监测点列表M5-01
* - 2.14 获取基准点M5-02水平位移等计算的基准
* - 2.15 获取工点数据M5-03目录页 2.15 笔误 ControlPoint以正文为准
*
* 三要素请求头SecretKey/SystemCode/Token由 AuthInterceptor 统一注入。
*
* 契约宽容:文档约定 data 为 {"PointList"/"ControlPointList"/"WorkPointList":[…]}
* 与项目/任务列表(任务 M2/M3 先例)同理保留形态偏差可能,故以 [JsonElement]
* 宽容承载,由 app 层 PointRepository 归一提取。
*/
interface PointApi {
/** 2.9 测组监测点列表:[groupId] 必填(测组 ID来自 2.6/2.7 任务 GroupID。 */
@GET(PATH_GET_POINT_BY_SURVEY_GROUP)
suspend fun getPointBySurveyGroup(
@Query(QUERY_GROUP_ID) groupId: String,
): ApiEnvelope<JsonElement>
/** 2.14 项目基准点列表:[projectId] 必填。 */
@GET(PATH_GET_CONTROL_POINT)
suspend fun getControlPoints(
@Query(QUERY_PROJECT_ID) projectId: String,
): ApiEnvelope<JsonElement>
/**
* 2.15 工点列表数据:[projectId]/[workPointId] 均非必填,传 null 时 Retrofit
* 省略参数(空 = 本人全部「进行中」项目工点)。
*/
@GET(PATH_GET_WORK_POINT)
suspend fun getWorkPoints(
@Query(QUERY_PROJECT_ID) projectId: String? = null,
@Query(QUERY_WORK_POINT_ID) workPointId: String? = null,
): ApiEnvelope<JsonElement>
companion object {
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----
const val PATH_GET_POINT_BY_SURVEY_GROUP = "OutWebApi/api/GetPointBySurveyGroup"
const val PATH_GET_CONTROL_POINT = "OutWebApi/api/ControlPoint"
const val PATH_GET_WORK_POINT = "OutWebApi/api/GetWorkPoint"
/** 查询参数名2.14/2.15 复用 ProjectID2.15 双参可空)。 */
const val QUERY_GROUP_ID = "GroupID"
const val QUERY_PROJECT_ID = "ProjectID"
const val QUERY_WORK_POINT_ID = "WorkPointID"
}
}

View File

@@ -0,0 +1,102 @@
package com.stec.cmd.core.network.api
import kotlinx.serialization.Serializable
/**
* 测点数据 DTO接口管理规范 V4.5 · 2.9/2.14/2.15 返回值示例)。
*
* 字段名与平台契约一致PascalCase文档未给类型全 `String?` 容错,
* 响应侧配合 Json.ignoreUnknownKeys 容忍字段演进。
*/
/**
* 2.9 监测点条目data.PointList
*
* 文档字段拼写歧义正文属性表列「LastMonitorDate 上次观值列表」,
* 2.9.6.2 属性说明与变更记录均为「LastMonitorData」——两键都声明
* 由 [lastMonitorValues] 归一,响应侧任一拼写均可承接。
*/
@Serializable
data class MonitorPointItem(
/** 监测点 ID上传 MonitorValue.PointID 的取值来源)。 */
val ID: String? = null,
/** 点名简称。 */
val PointSimpleName: String? = null,
/** 点名。 */
val PointName: String? = null,
/** 初值列表2.9.6.1:测斜=孔深值、轴力=轴力、其他=高程)。 */
val InitialValueData: List<PointValueItem> = emptyList(),
/** 速率报警值。 */
val RateAlarm: String? = null,
/** 累计报警值。 */
val TotalAlarm: String? = null,
/** 上次观值列表(文档 LastMonitorData/LastMonitorDate 双拼写,见类注释)。 */
val LastMonitorData: List<PointValueItem> = emptyList(),
/** 同 [LastMonitorData],文档正文属性表拼写,双键兼容。 */
val LastMonitorDate: List<PointValueItem> = emptyList(),
/** 上次观测时间。 */
val MonitorDate: String? = null,
/** 支撑轴力监测类型(钢支撑/钢筋混凝土支撑 4 种),非轴力测点为空。 */
val AxiaType: String? = null,
) {
/** 归一上次观值列表:优先 LastMonitorData2.9.6.2 口径),空则回退 LastMonitorDate。 */
val lastMonitorValues: List<PointValueItem>
get() = LastMonitorData.ifEmpty { LastMonitorDate }
/** 展示点名PointName 优先,空则回退点名简称。 */
val displayName: String?
get() = PointName?.takeIf { it.isNotBlank() } ?: PointSimpleName?.takeIf { it.isNotBlank() }
}
/**
* 2.9 初值/上次观值条目InitialValueData 与 LastMonitorData 共用一模型):
* - InitialValueData[Attribute]=孔深值/轴力/高程,[initialValue]=初值;
* - LastMonitorData[Attribute]=孔深值/传感器编号/高程,[lastMonitorValue]=上次观测值。
*/
@Serializable
data class PointValueItem(
/** 属性(含义随监测类型,见类注释)。 */
val Attribute: String? = null,
/** 初值InitialValueData。 */
val InitialValue: String? = null,
/** 上次观测值LastMonitorData。 */
val LastMonitorValue: String? = null,
) {
/** 取本条数值文本:初值优先,其次上次观测值。 */
val valueText: String?
get() = InitialValue?.takeIf { it.isNotBlank() } ?: LastMonitorValue?.takeIf { it.isNotBlank() }
}
/** 2.14 基准点条目data.ControlPointList水平位移等计算的基准坐标。 */
@Serializable
data class ControlPointItem(
/** 点名。 */
val Point: String? = null,
/** X 值。 */
val PointX: String? = null,
/** Y 值。 */
val PointY: String? = null,
/** H 值。 */
val PointZ: String? = null,
)
/** 2.15 工点条目data.WorkPointList现场负责人本人的「进行中」工点。 */
@Serializable
data class WorkPointItem(
/** 工点 ID。 */
val ID: String? = null,
/** 工点名称。 */
val Name: String? = null,
/** 项目 ID。 */
val ProjectID: String? = null,
/** 项目全称。 */
val ProjectName: String? = null,
/** 经度。 */
val Lon: String? = null,
/** 纬度(文档字段名即为 Dim。 */
val Dim: String? = null,
/** 开工时间yyyy-MM-dd。 */
val StartDate: String? = null,
/** 结束时间yyyy-MM-dd。 */
val EndDate: String? = null,
)