feat: PlacementGuideView + MeasurementLogService + navigation flow
PlacementGuideView.kt: - 6-sensor Canvas layout (CH0-CH3 vertical + CH4/CH5 lateral wings) - Per-channel UrinAI detection (green/red color coding) - Direction guidance (up/down/left/right) - Check Position → maa → detect → display - Start Measurement when 4+ channels detected MeasurementLogService.kt: - JSON Lines format, daily rotation - Events: measurement, placement, ble_connected/disconnected, error - 90-day auto-purge - App internal storage: vesiscan_logs/YYYY-MM-DD.jsonl AppState.kt + MainActivity.kt: - PLACEMENT_GUIDE screen added to navigation - Flow: personalization → placementGuide → piezoMonitoring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package com.example.medilightv2android.services
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Measurement log auto-save service.
|
||||
*
|
||||
* All measurement results are saved in JSON Lines format to internal storage.
|
||||
* Medical audit trail + QA verification.
|
||||
*
|
||||
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
|
||||
* Each line = one measurement event (JSON)
|
||||
*/
|
||||
class MeasurementLogService private constructor(context: Context) {
|
||||
|
||||
private val logDir: File
|
||||
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
init {
|
||||
logDir = File(context.filesDir, "vesiscan_logs")
|
||||
if (!logDir.exists()) logDir.mkdirs()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: MeasurementLogService? = null
|
||||
|
||||
fun getInstance(context: Context): MeasurementLogService {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: MeasurementLogService(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Log Events --
|
||||
|
||||
/**
|
||||
* Log a spot measurement result.
|
||||
*/
|
||||
fun logMeasurement(
|
||||
volumeMl: Double?,
|
||||
channels: List<ChannelLogEntry>,
|
||||
rawADC: List<List<UShort>>
|
||||
) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "measurement")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("device", Build.MODEL)
|
||||
put("app_version", getAppVersion())
|
||||
}
|
||||
|
||||
if (volumeMl != null) {
|
||||
entry.put("volume_ml", Math.round(volumeMl * 10.0) / 10.0)
|
||||
}
|
||||
|
||||
// Per-channel detection
|
||||
val chArray = JSONArray()
|
||||
for (ch in channels) {
|
||||
val chObj = JSONObject().apply {
|
||||
put("channel", ch.channel)
|
||||
ch.ant?.let { put("ant", it) }
|
||||
ch.post?.let { put("post", it) }
|
||||
ch.urineLen?.let { put("urine_len", it) }
|
||||
ch.score?.let { put("score", Math.round(it * 10.0) / 10.0) }
|
||||
}
|
||||
chArray.put(chObj)
|
||||
}
|
||||
entry.put("channels", chArray)
|
||||
|
||||
// Raw ADC (compact: comma-separated per channel)
|
||||
val adcObj = JSONObject()
|
||||
for ((i, adc) in rawADC.withIndex()) {
|
||||
adcObj.put("CH$i", adc.joinToString(",") { it.toString() })
|
||||
}
|
||||
entry.put("adc", adcObj)
|
||||
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log Placement Guide result.
|
||||
*/
|
||||
fun logPlacement(
|
||||
channelDetected: List<Boolean>,
|
||||
direction: String
|
||||
) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "placement")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
val arr = JSONArray()
|
||||
for (d in channelDetected) arr.put(d)
|
||||
put("detected", arr)
|
||||
put("detected_count", channelDetected.count { it })
|
||||
put("direction", direction)
|
||||
}
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log BLE connection/disconnection.
|
||||
*/
|
||||
fun logConnection(event: String, deviceName: String = "") {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "ble_$event")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error.
|
||||
*/
|
||||
fun logError(message: String) {
|
||||
val entry = JSONObject().apply {
|
||||
put("event", "error")
|
||||
put("timestamp", timestampFormatter.format(Date()))
|
||||
put("message", message)
|
||||
}
|
||||
appendLog(entry)
|
||||
}
|
||||
|
||||
// -- File Operations --
|
||||
|
||||
private fun appendLog(entry: JSONObject) {
|
||||
try {
|
||||
val line = entry.toString() + "\n"
|
||||
val fileName = dateFormatter.format(Date()) + ".jsonl"
|
||||
val file = File(logDir, fileName)
|
||||
file.appendText(line)
|
||||
} catch (e: Exception) {
|
||||
// Silently fail — logging should never crash the app
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's log file */
|
||||
val todayLogFile: File
|
||||
get() = File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||
|
||||
/** All log files, newest first */
|
||||
fun listLogFiles(): List<File> {
|
||||
return logDir.listFiles { f -> f.extension == "jsonl" }
|
||||
?.sortedByDescending { it.name }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
/** Read a log file as lines */
|
||||
fun readLog(file: File): List<String> {
|
||||
return try {
|
||||
file.readLines().filter { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's log as text */
|
||||
fun todayLogText(): String {
|
||||
return try {
|
||||
todayLogFile.readText()
|
||||
} catch (e: Exception) {
|
||||
"(no logs today)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Total log size in bytes */
|
||||
val totalLogSize: Long
|
||||
get() = listLogFiles().sumOf { it.length() }
|
||||
|
||||
/** Purge logs older than [keepDays] days (default 90) */
|
||||
fun purgeOldLogs(keepDays: Int = 90) {
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
|
||||
val cutoffStr = dateFormatter.format(calendar.time)
|
||||
for (file in listLogFiles()) {
|
||||
val name = file.nameWithoutExtension
|
||||
if (name < cutoffStr) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAppVersion(): String {
|
||||
return try {
|
||||
val context = logDir.parentFile // filesDir parent hack — not ideal
|
||||
"1.0" // Fallback; ideally inject from BuildConfig
|
||||
} catch (e: Exception) {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Data class for channel log entries */
|
||||
data class ChannelLogEntry(
|
||||
val channel: Int,
|
||||
val ant: Int? = null,
|
||||
val post: Int? = null,
|
||||
val urineLen: Int? = null,
|
||||
val score: Double? = null
|
||||
)
|
||||
Reference in New Issue
Block a user