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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 交互编排 + 会话/连接/数据流转发)。
|
||||
*
|
||||
* 门控沿用 M3:SignedIn 且三要素就绪才可见扫描区;会话离开 SignedIn 复位;
|
||||
* selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7)。
|
||||
* selectedProject 变化复位任务列表并重拉(当前监测计划任务 2.7),
|
||||
* 并并发刷新采集上下文(基准点 2.14 / 工点 2.15,M5-02/03)。
|
||||
* 选定任务按测组拉平台测点(2.9,M5-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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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_queue,M6 上传通道统一消费。
|
||||
*/
|
||||
@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 上传队列(pending,M6 通道消费)。
|
||||
* @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
|
||||
}
|
||||
@@ -7,39 +7,56 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** 测点候选:[id] 为平台测点 ID(M5 交付前手动测点为空串)。 */
|
||||
/** 测点候选:[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()
|
||||
|
||||
@@ -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/03,2.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.15:data 内列表键名(确切大小写)。 */
|
||||
const val KEY_POINT_LIST = "PointList"
|
||||
const val KEY_CONTROL_POINT_LIST = "ControlPointList"
|
||||
const val KEY_WORK_POINT_LIST = "WorkPointList"
|
||||
}
|
||||
}
|
||||
@@ -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-01,2.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)
|
||||
@@ -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-01,2.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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
171
app/src/main/res/layout/fragment_manual_entry.xml
Normal file
171
app/src/main/res/layout/fragment_manual_entry.xml
Normal 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>
|
||||
80
app/src/main/res/layout/fragment_survey_point_list.xml
Normal file
80
app/src/main/res/layout/fragment_survey_point_list.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 测组监测点列表(M5-01,2.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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
51
app/src/main/res/layout/item_survey_point.xml
Normal file
51
app/src/main/res/layout/item_survey_point.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user