CLINICAL: 흐름 재설계 — 한 측정 = 한 라벨 = 한 폴더
이전: 한 세션이 9 conditions × 6 steps를 모두 묶고 마지막에 zip
새로: 한 측정마다 라벨 입력 → 측정 → 자동 finalize → 다음 측정 라벨
사용자 요청: "session 들어가면 일단 BLE 연결을 하고, 연결 이후에 내가 어떤 자세로
어느 위치 측정할건지를 직접 적고 테스트를 완료한 뒤에 로그가 저장되도록"
변경:
- ClinicalSession 모델: 단일 측정 단위로 재정의 (subjectId/device/posture/step
/examiner/notes/startedAt/endedAt). folderName에 라벨 전체 + timestamp 포함:
"S001_A_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
- ClinicalSessionStore: 단순화. zip 제거. startMeasurement/endMeasurement.
inClinicalFlow boolean 추가 (DeviceScan/Back 라우팅용).
lastSubjectId/Examiner/Notes 기억 (다음 측정 시 자동 채움).
lastFinalizedFolder (UI에 "Saved: ..." 표시용).
- 3 로거: stepLogDir() → currentLogDir() 단순화. 한 폴더에 모든 파일.
- ClinicalHomeView 전면 재작성:
* BLE 연결 상태 카드 (미연결 시 Connect 버튼 → DeviceScan)
* 직전 측정 결과 표시 (lastFinalizedFolder)
* 라벨 입력 폼 (Subject + Device/Posture/Step segmented control + Examiner + Notes)
* 라벨 preview 실시간
* Start Measurement 버튼 (BLE 연결 + Subject ID 있을 때만 활성)
- AppState.deviceConnected: 임상 흐름이면 ClinicalHome 복귀 (Personalization 우회)
- MainActivity Back handler:
* DEVICE_SCAN Back → inClinicalFlow면 ClinicalHome
* PLACEMENT_GUIDE/PIEZO_MONITORING Back → 활성 세션이면 endMeasurement +
ClinicalHome 복귀 (폴더 finalize)
* CLINICAL_HOME Back → inClinicalFlow 해제 + HOME
데이터 구조 (한 측정당):
Downloads/VesiScan_Sessions/{subjectId}_{device}_{posture}_{step}_{timestamp}/
├─ meta.json (라벨 + 앱/펌웨어/디바이스 버전 + 시작/종료 시각)
├─ adc.csv
├─ placements.jsonl (alignment) or events.jsonl (BV)
└─ ble.log
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,11 @@ class AppState(private val context: Context) {
|
||||
|
||||
fun deviceConnected() {
|
||||
isDeviceConnected = true
|
||||
// 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회)
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow) {
|
||||
currentScreen = AppScreen.CLINICAL_HOME
|
||||
return
|
||||
}
|
||||
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||
AppScreen.NIRS_CALIBRATION
|
||||
} else {
|
||||
|
||||
@@ -95,18 +95,28 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
|
||||
when (appState.currentScreen) {
|
||||
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
|
||||
AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME
|
||||
// DeviceScan Back: 임상 흐름이면 ClinicalHome으로, 아니면 HOME
|
||||
AppScreen.DEVICE_SCAN ->
|
||||
appState.currentScreen =
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow)
|
||||
AppScreen.CLINICAL_HOME
|
||||
else AppScreen.HOME
|
||||
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
||||
// 임상 모드면 측정 후 ClinicalHome으로 복귀
|
||||
// 임상 모드 측정 중이면 endMeasurement → ClinicalHome 복귀
|
||||
AppScreen.PLACEMENT_GUIDE ->
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null)
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) {
|
||||
com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
else appState.backFromPlacementGuide()
|
||||
} else appState.backFromPlacementGuide()
|
||||
AppScreen.PIEZO_MONITORING ->
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null)
|
||||
if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) {
|
||||
com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context)
|
||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||
else appState.currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
AppScreen.CLINICAL_HOME -> appState.currentScreen = AppScreen.HOME
|
||||
} else appState.currentScreen = AppScreen.PLACEMENT_GUIDE
|
||||
AppScreen.CLINICAL_HOME -> {
|
||||
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false
|
||||
appState.currentScreen = AppScreen.HOME
|
||||
}
|
||||
// 모니터링 하위 화면 → 모니터링
|
||||
AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
|
||||
@@ -5,10 +5,18 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 임상 R&D 측정 세션 모델.
|
||||
* 임상 R&D 측정 — "한 측정 단위" 모델.
|
||||
*
|
||||
* 한 피험자의 한 측정 세션 = 3 devices × 3 postures × 6 steps = 54 measurements.
|
||||
* Dev 모드에서만 접근 가능. 세션 폴더로 모든 로그가 자동 라우팅됨.
|
||||
* 한 ClinicalSession = 한 라벨 = 한 폴더 = 한 측정 사이클.
|
||||
* 사용자가 BLE 연결 후 매번 라벨(Subject/Device/Posture/Step)을 입력하고
|
||||
* 측정을 진행하면, 측정 종료 시점에 폴더가 finalize됨.
|
||||
*
|
||||
* 데이터 구조 (한 측정당):
|
||||
* Downloads/VesiScan_Sessions/{folderName}/
|
||||
* ├─ meta.json
|
||||
* ├─ adc.csv
|
||||
* ├─ placements.jsonl (alignment) or events.jsonl (BV)
|
||||
* └─ ble.log
|
||||
*/
|
||||
|
||||
enum class ClinicalDevice(val label: String) {
|
||||
@@ -20,63 +28,36 @@ enum class ClinicalPosture(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 condition 내 측정 단계.
|
||||
* 측정 단계.
|
||||
* - ALIGN_0CM ~ ALIGN_4CM: 정렬모드 알고리즘 개발용 (치골 위 0~4cm)
|
||||
* - BV: cradle 부착 후 본측정 (방광 추정 알고리즘 개발용)
|
||||
*/
|
||||
enum class ClinicalStep(val label: String, val folder: String, val isAlignment: Boolean) {
|
||||
ALIGN_0CM("0 cm", "align_0cm", true),
|
||||
ALIGN_1CM("1 cm", "align_1cm", true),
|
||||
ALIGN_2CM("2 cm", "align_2cm", true),
|
||||
ALIGN_3CM("3 cm", "align_3cm", true),
|
||||
ALIGN_4CM("4 cm", "align_4cm", true),
|
||||
BV("BV", "bv", false)
|
||||
enum class ClinicalStep(val label: String, val isAlignment: Boolean) {
|
||||
ALIGN_0CM("0 cm", true),
|
||||
ALIGN_1CM("1 cm", true),
|
||||
ALIGN_2CM("2 cm", true),
|
||||
ALIGN_3CM("3 cm", true),
|
||||
ALIGN_4CM("4 cm", true),
|
||||
BV("BV", false)
|
||||
}
|
||||
|
||||
data class ClinicalSession(
|
||||
val sessionId: String, // "S001" — 사용자 입력 라벨 (익명)
|
||||
val subjectId: String, // "S001"
|
||||
val device: ClinicalDevice,
|
||||
val posture: ClinicalPosture,
|
||||
val step: ClinicalStep,
|
||||
val examiner: String = "",
|
||||
val notes: String = "",
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
var endedAt: Long? = null,
|
||||
var currentDevice: ClinicalDevice? = null,
|
||||
var currentPosture: ClinicalPosture? = null,
|
||||
var currentStep: ClinicalStep? = null,
|
||||
val completedKeys: MutableSet<String> = mutableSetOf()
|
||||
var endedAt: Long? = null
|
||||
) {
|
||||
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
|
||||
/** Downloads/VesiScan_Sessions/{folderName}/ 의 폴더명 */
|
||||
/** "S001_A_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */
|
||||
val folderName: String
|
||||
get() = "${sessionId}_${folderFmt.format(Date(startedAt))}"
|
||||
get() = "${subjectId}_${device.name}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}"
|
||||
|
||||
/** "A_SUPINE_ALIGN_0CM" — 라벨/완료 추적용 키 */
|
||||
fun stepKey(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): String =
|
||||
"${d.name}_${p.name}_${s.name}"
|
||||
|
||||
fun currentStepKey(): String? {
|
||||
val d = currentDevice ?: return null
|
||||
val p = currentPosture ?: return null
|
||||
val s = currentStep ?: return null
|
||||
return stepKey(d, p, s)
|
||||
}
|
||||
|
||||
fun markCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep) {
|
||||
completedKeys.add(stepKey(d, p, s))
|
||||
}
|
||||
|
||||
fun markCurrentCompleted() {
|
||||
currentStepKey()?.let { completedKeys.add(it) }
|
||||
}
|
||||
|
||||
fun isCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): Boolean =
|
||||
stepKey(d, p, s) in completedKeys
|
||||
|
||||
fun conditionCompletedCount(d: ClinicalDevice, p: ClinicalPosture): Int =
|
||||
ClinicalStep.values().count { isCompleted(d, p, it) }
|
||||
|
||||
val totalSteps: Int =
|
||||
ClinicalDevice.values().size * ClinicalPosture.values().size * ClinicalStep.values().size
|
||||
|
||||
val completedCount: Int get() = completedKeys.size
|
||||
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
||||
val label: String
|
||||
get() = "${subjectId}_${device.name}_${posture.name}_${step.name}"
|
||||
}
|
||||
|
||||
@@ -40,9 +40,10 @@ object AdcCsvLogger {
|
||||
* 일반 모드: 기존 세션 파일 사용.
|
||||
*/
|
||||
private fun getFile(): File {
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
if (clinicalStepDir != null) {
|
||||
return File(clinicalStepDir, "adc.csv")
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
return File(clinicalDir, "adc.csv")
|
||||
}
|
||||
if (sessionFile == null) newSession()
|
||||
return sessionFile!!
|
||||
|
||||
@@ -85,21 +85,22 @@ class BleDebugLogger private constructor(private val context: Context) {
|
||||
// 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일)
|
||||
// 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일)
|
||||
private var liveLogFile: File? = null
|
||||
private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지
|
||||
private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지
|
||||
private fun ensureLiveLogFile(): File {
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
if (clinicalStepDir != null) {
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
val currentLabel = ClinicalSessionStore.currentLabel()
|
||||
// step이 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogStepLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalStepDir, "ble.log")
|
||||
liveLogStepLabel = currentLabel
|
||||
// 측정 단위가 바뀌었으면 새 파일로 전환
|
||||
if (liveLogFile == null || liveLogLabel != currentLabel) {
|
||||
liveLogFile = File(clinicalDir, "ble.log")
|
||||
liveLogLabel = currentLabel
|
||||
}
|
||||
} else if (liveLogFile == null || liveLogStepLabel.isNotEmpty()) {
|
||||
} else if (liveLogFile == null || liveLogLabel.isNotEmpty()) {
|
||||
// 일반 모드 진입 또는 임상 모드 종료
|
||||
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
||||
liveLogStepLabel = ""
|
||||
liveLogLabel = ""
|
||||
}
|
||||
return liveLogFile!!
|
||||
}
|
||||
|
||||
@@ -3,89 +3,83 @@ package com.example.medilightv2android.services
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.example.medilightv2android.models.ClinicalSession
|
||||
import com.example.medilightv2android.models.ClinicalStep
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* 임상 세션 전역 store. Logger들이 세션 존재 여부와 폴더 경로를 조회.
|
||||
* 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store.
|
||||
*
|
||||
* - currentSession != null 이면 임상 모드 진입 상태
|
||||
* - 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)는
|
||||
* sessionLogDir()이 반환하는 폴더로 저장 라우팅
|
||||
* - endSession()에서 자동 zip + meta.json 마무리
|
||||
* 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면
|
||||
* Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨.
|
||||
* 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가
|
||||
* 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨.
|
||||
*
|
||||
* zip은 사용하지 않음 — 폴더 자체가 분석 단위.
|
||||
*/
|
||||
object ClinicalSessionStore {
|
||||
|
||||
/** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */
|
||||
@Volatile
|
||||
var inClinicalFlow: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var currentSession: ClinicalSession? = null
|
||||
private set
|
||||
|
||||
fun startSession(session: ClinicalSession) {
|
||||
/** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */
|
||||
@Volatile
|
||||
var lastFinalizedFolder: File? = null
|
||||
private set
|
||||
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (subjectId/examiner/notes) */
|
||||
var lastSubjectId: String = ""
|
||||
var lastExaminer: String = ""
|
||||
var lastNotes: String = ""
|
||||
|
||||
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||
currentSession = session
|
||||
// 세션 루트 폴더 생성
|
||||
sessionRootDir()?.mkdirs()
|
||||
lastSubjectId = session.subjectId
|
||||
lastExaminer = session.examiner
|
||||
lastNotes = session.notes
|
||||
currentLogDir()?.mkdirs()
|
||||
writeMeta(context)
|
||||
}
|
||||
|
||||
fun updateCurrent(
|
||||
device: com.example.medilightv2android.models.ClinicalDevice? = null,
|
||||
posture: com.example.medilightv2android.models.ClinicalPosture? = null,
|
||||
step: ClinicalStep? = null
|
||||
) {
|
||||
val s = currentSession ?: return
|
||||
device?.let { s.currentDevice = it }
|
||||
posture?.let { s.currentPosture = it }
|
||||
step?.let { s.currentStep = it }
|
||||
}
|
||||
|
||||
fun markCurrentCompleted() {
|
||||
currentSession?.markCurrentCompleted()
|
||||
}
|
||||
|
||||
/** 세션 루트: Downloads/VesiScan_Sessions/{folderName}/ */
|
||||
fun sessionRootDir(): File? {
|
||||
/** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */
|
||||
fun currentLogDir(): File? {
|
||||
val s = currentSession ?: return null
|
||||
val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
return File(File(downloads, "VesiScan_Sessions"), s.folderName)
|
||||
}
|
||||
|
||||
/** 현재 step의 로그 폴더: {sessionRoot}/{device}_{posture}/{step.folder}/ */
|
||||
fun stepLogDir(): File? {
|
||||
val s = currentSession ?: return null
|
||||
val root = sessionRootDir() ?: return null
|
||||
val d = s.currentDevice?.name ?: "UNK"
|
||||
val p = s.currentPosture?.name ?: "UNK"
|
||||
val st = s.currentStep?.folder ?: "UNK"
|
||||
return File(File(root, "${d}_${p}"), st).apply { mkdirs() }
|
||||
}
|
||||
/** clinical_label 컬럼/필드용. 비활성 시 "" */
|
||||
fun currentLabel(): String = currentSession?.label ?: ""
|
||||
|
||||
/** "S001_A_SUPINE_ALIGN_0CM" — CSV row 라벨 / JSONL 필드용 */
|
||||
fun currentLabel(): String {
|
||||
val s = currentSession ?: return ""
|
||||
val d = s.currentDevice?.name ?: "?"
|
||||
val p = s.currentPosture?.name ?: "?"
|
||||
val st = s.currentStep?.name ?: "?"
|
||||
return "${s.sessionId}_${d}_${p}_${st}"
|
||||
}
|
||||
|
||||
/** meta.json 작성/갱신 */
|
||||
fun writeMeta(context: Context) {
|
||||
val s = currentSession ?: return
|
||||
val root = sessionRootDir() ?: return
|
||||
root.mkdirs()
|
||||
val dir = currentLogDir() ?: return
|
||||
dir.mkdirs()
|
||||
val meta = JSONObject().apply {
|
||||
put("session_id", s.sessionId)
|
||||
put("subject_id", s.subjectId)
|
||||
put("device", s.device.name)
|
||||
put("posture", s.posture.name)
|
||||
put("step", s.step.name)
|
||||
put("is_alignment", s.step.isAlignment)
|
||||
put("examiner", s.examiner)
|
||||
put("notes", s.notes)
|
||||
put("label", s.label)
|
||||
put("started_at", s.startedAt)
|
||||
put("started_at_iso", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
||||
s.endedAt?.let { put("ended_at", it) }
|
||||
put("started_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
||||
s.endedAt?.let {
|
||||
put("ended_at", it)
|
||||
put("ended_at_iso",
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it)))
|
||||
put("duration_ms", it - s.startedAt)
|
||||
}
|
||||
put("app_version_name", try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
|
||||
} catch (_: Exception) { "unknown" })
|
||||
@@ -97,48 +91,23 @@ object ClinicalSessionStore {
|
||||
put("device_manufacturer", android.os.Build.MANUFACTURER)
|
||||
put("android_release", android.os.Build.VERSION.RELEASE)
|
||||
put("android_sdk", android.os.Build.VERSION.SDK_INT)
|
||||
put("completed_steps", org.json.JSONArray().apply {
|
||||
s.completedKeys.forEach { put(it) }
|
||||
})
|
||||
put("completed_count", s.completedCount)
|
||||
put("total_steps", s.totalSteps)
|
||||
}
|
||||
File(root, "meta.json").writeText(meta.toString(2))
|
||||
File(dir, "meta.json").writeText(meta.toString(2))
|
||||
}
|
||||
|
||||
/** 세션 종료: endedAt 기록 → meta.json 갱신 → zip 생성 */
|
||||
fun endSession(context: Context): File? {
|
||||
/** 측정 종료: endedAt 기록 → meta.json 갱신 → currentSession=null */
|
||||
fun endMeasurement(context: Context): File? {
|
||||
val s = currentSession ?: return null
|
||||
s.endedAt = System.currentTimeMillis()
|
||||
writeMeta(context)
|
||||
val root = sessionRootDir() ?: return null
|
||||
if (!root.exists()) return null
|
||||
val zipFile = File(root.parentFile, "${s.folderName}.zip")
|
||||
try {
|
||||
ZipOutputStream(FileOutputStream(zipFile)).use { zos ->
|
||||
zipDirectory(root, root.name, zos)
|
||||
}
|
||||
} catch (_: Exception) { return null }
|
||||
val dir = currentLogDir()
|
||||
lastFinalizedFolder = dir
|
||||
currentSession = null
|
||||
return zipFile
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 세션 폐기 (zip 없이 currentSession만 비움) */
|
||||
fun discardSession() {
|
||||
/** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */
|
||||
fun discardMeasurement() {
|
||||
currentSession = null
|
||||
}
|
||||
|
||||
private fun zipDirectory(dir: File, prefix: String, zos: ZipOutputStream) {
|
||||
val files = dir.listFiles() ?: return
|
||||
for (f in files) {
|
||||
val entryName = "$prefix/${f.name}"
|
||||
if (f.isDirectory) {
|
||||
zipDirectory(f, entryName, zos)
|
||||
} else {
|
||||
zos.putNextEntry(ZipEntry(entryName))
|
||||
f.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,9 +169,10 @@ class MeasurementLogService private constructor(private val context: Context) {
|
||||
private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") {
|
||||
try {
|
||||
val line = entry.toString() + "\n"
|
||||
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||
val file = if (clinicalStepDir != null) {
|
||||
File(clinicalStepDir, clinicalFileName)
|
||||
val clinicalDir = ClinicalSessionStore.currentLogDir()
|
||||
val file = if (clinicalDir != null) {
|
||||
clinicalDir.mkdirs()
|
||||
File(clinicalDir, clinicalFileName)
|
||||
} else {
|
||||
File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||
}
|
||||
|
||||
+180
-224
@@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -22,38 +23,46 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.medilightv2android.AppScreen
|
||||
import com.example.medilightv2android.AppState
|
||||
import com.example.medilightv2android.ble.BleManager
|
||||
import com.example.medilightv2android.models.*
|
||||
import com.example.medilightv2android.services.ClinicalSessionStore
|
||||
import com.example.medilightv2android.ui.theme.*
|
||||
|
||||
/**
|
||||
* Clinical R&D 측정 세션 화면.
|
||||
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
|
||||
*
|
||||
* 진행: 세션 시작 → device/posture 선택 → 5 alignment 위치 + BV 측정 → 다음 condition → ... → 세션 종료(zip)
|
||||
*
|
||||
* 데이터 라우팅: ClinicalSessionStore.currentSession 이 active일 때,
|
||||
* AdcCsvLogger / MeasurementLogService / BleDebugLogger가 모두 세션 step 폴더로 자동 저장.
|
||||
* 흐름:
|
||||
* 1. BLE 연결 (미연결 시 Connect 버튼 → DeviceScan)
|
||||
* 2. 측정 라벨 입력 (Subject/Device/Posture/Step/Examiner/Notes)
|
||||
* 3. Start Measurement → PlacementGuide(alignment) or PiezoMonitoring(BV)
|
||||
* 4. 측정 후 Back → 자동 endMeasurement → 폴더 finalize
|
||||
* 5. 다음 측정 라벨 입력 또는 종료
|
||||
*/
|
||||
@Composable
|
||||
fun ClinicalHomeView(appState: AppState) {
|
||||
val context = LocalContext.current
|
||||
var sessionTick by remember { mutableIntStateOf(0) } // 세션 상태 변경 시 강제 recompose
|
||||
val session = ClinicalSessionStore.currentSession
|
||||
val bleManager = remember { BleManager.getInstance(context) }
|
||||
val isConnected by bleManager.isConnected
|
||||
|
||||
if (session == null) {
|
||||
ClinicalSessionStartCard(
|
||||
onStart = { id, ex, notes ->
|
||||
ClinicalSessionStore.startSession(
|
||||
ClinicalSession(sessionId = id, examiner = ex, notes = notes)
|
||||
)
|
||||
ClinicalSessionStore.writeMeta(context)
|
||||
sessionTick++
|
||||
},
|
||||
onCancel = { appState.currentScreen = AppScreen.HOME }
|
||||
)
|
||||
return
|
||||
// 진입 시 flag set, 화면 떠날 때 unset (DEVICE_SCAN/측정화면 라우팅용)
|
||||
DisposableEffect(Unit) {
|
||||
ClinicalSessionStore.inClinicalFlow = true
|
||||
onDispose {
|
||||
// 측정 진행을 위해 다른 화면(DEVICE_SCAN/PLACEMENT_GUIDE/PIEZO_MONITORING)으로 갈 땐 flag 유지.
|
||||
// HOME으로 복귀 시에만 unset → MainActivity의 HOME 진입 시 처리.
|
||||
}
|
||||
}
|
||||
|
||||
// 라벨 입력 state — 마지막 입력값 기억 (Store에서 자동 채움)
|
||||
var subjectId by remember { mutableStateOf(ClinicalSessionStore.lastSubjectId) }
|
||||
var device by remember { mutableStateOf(ClinicalDevice.A) }
|
||||
var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) }
|
||||
var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) }
|
||||
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
||||
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
||||
|
||||
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -63,75 +72,67 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
) {
|
||||
// Top bar
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { appState.currentScreen = AppScreen.HOME }) {
|
||||
IconButton(onClick = {
|
||||
ClinicalSessionStore.inClinicalFlow = false
|
||||
appState.currentScreen = AppScreen.HOME
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text("Clinical Session — ${session.sessionId}",
|
||||
fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text("Examiner: ${session.examiner.ifEmpty { "(none)" }}",
|
||||
fontSize = 12.sp, color = MlSecondaryText)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("${session.completedCount} / ${session.totalSteps}",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||
Text("Clinical Measurement", fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 9-conditions grid (3 devices × 3 postures)
|
||||
Text("Conditions (3 devices × 3 postures)",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// BLE 연결 상태
|
||||
Card(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White)
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isConnected) Color(0xFF4CAF50).copy(alpha = 0.15f)
|
||||
else Color(0xFFFF9800).copy(alpha = 0.15f)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
// Header row
|
||||
Row {
|
||||
Box(modifier = Modifier.width(80.dp)) {}
|
||||
for (p in ClinicalPosture.values()) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).padding(4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(p.label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isConnected) {
|
||||
Icon(Icons.Default.CheckCircle, "Connected",
|
||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("BLE Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||
Text(bleManager.connectedDeviceName.value,
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
} else {
|
||||
Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp,
|
||||
color = Color(0xFFFF9800), modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = { appState.currentScreen = AppScreen.DEVICE_SCAN },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) { Text("Connect", color = Color.White) }
|
||||
}
|
||||
for (d in ClinicalDevice.values()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(d.label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.width(80.dp).padding(4.dp))
|
||||
for (p in ClinicalPosture.values()) {
|
||||
val done = session.conditionCompletedCount(d, p)
|
||||
val isSelected = session.currentDevice == d && session.currentPosture == p
|
||||
val bg = when {
|
||||
isSelected -> MlPrimary
|
||||
done == ClinicalStep.values().size -> Color(0xFF4CAF50).copy(alpha = 0.3f)
|
||||
done > 0 -> Color(0xFFFFC107).copy(alpha = 0.3f)
|
||||
else -> Color.Gray.copy(alpha = 0.1f)
|
||||
}
|
||||
val fg = if (isSelected) Color.White else Color.Black
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bg)
|
||||
.clickable {
|
||||
ClinicalSessionStore.updateCurrent(device = d, posture = p, step = null)
|
||||
sessionTick++
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("$done / ${ClinicalStep.values().size}",
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Bold, color = fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 직전 측정 결과 표시
|
||||
if (lastFolder != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Card(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, null,
|
||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
|
||||
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,147 +140,28 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Steps for current condition
|
||||
val d = session.currentDevice
|
||||
val p = session.currentPosture
|
||||
if (d != null && p != null) {
|
||||
Text("Steps — ${d.label} × ${p.label}",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
for (step in ClinicalStep.values()) {
|
||||
val completed = session.isCompleted(d, p, step)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable {
|
||||
ClinicalSessionStore.updateCurrent(step = step)
|
||||
ClinicalSessionStore.writeMeta(context)
|
||||
// 진입: alignment → PlacementGuide / BV → PiezoMonitoring
|
||||
// 단 BLE 연결이 우선. 미연결 시 DeviceScan으로.
|
||||
appState.currentScreen = if (appState.isDeviceConnected) {
|
||||
if (step.isAlignment) AppScreen.PLACEMENT_GUIDE
|
||||
else AppScreen.PIEZO_MONITORING
|
||||
} else AppScreen.DEVICE_SCAN
|
||||
sessionTick++
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (completed) Color(0xFF4CAF50).copy(alpha = 0.15f) else Color.White
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (completed) {
|
||||
Icon(Icons.Default.CheckCircle, "Done",
|
||||
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.size(20.dp)) {}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(step.label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
if (step.isAlignment) "Alignment 측정" else "BV 본측정",
|
||||
fontSize = 12.sp, color = MlSecondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Mark complete 버튼 (현재 step만)
|
||||
val currentStep = session.currentStep
|
||||
if (currentStep != null && !session.isCompleted(d, p, currentStep)) {
|
||||
Button(
|
||||
onClick = {
|
||||
session.markCompleted(d, p, currentStep)
|
||||
ClinicalSessionStore.writeMeta(context)
|
||||
Toast.makeText(context,
|
||||
"${d.label} × ${p.label} × ${currentStep.label} marked complete",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
sessionTick++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
|
||||
) {
|
||||
Text("Mark Current Step Complete (${currentStep.label})",
|
||||
color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// End session
|
||||
var showEndConfirm by remember { mutableStateOf(false) }
|
||||
Button(
|
||||
onClick = { showEndConfirm = true },
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.8f))
|
||||
) {
|
||||
Text("End Session → Create ZIP", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
if (showEndConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEndConfirm = false },
|
||||
title = { Text("End Session") },
|
||||
text = { Text("Session ${session.sessionId} 종료하고 ZIP을 만들까요?\n완료: ${session.completedCount}/${session.totalSteps}") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val zip = ClinicalSessionStore.endSession(context)
|
||||
showEndConfirm = false
|
||||
if (zip != null) {
|
||||
Toast.makeText(context,
|
||||
"Saved: ${zip.absolutePath}",
|
||||
Toast.LENGTH_LONG).show()
|
||||
}
|
||||
appState.currentScreen = AppScreen.HOME
|
||||
}) { Text("End + Zip", color = Color.Red) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEndConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 라벨 입력 폼
|
||||
Text("Measurement Label", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Saved to: Downloads/VesiScan_Sessions/${session.folderName}/",
|
||||
fontSize = 10.sp, color = MlSecondaryText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClinicalSessionStartCard(
|
||||
onStart: (id: String, examiner: String, notes: String) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
var sessionId by remember { mutableStateOf("") }
|
||||
var examiner by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MlBackground)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("Start Clinical Session", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = sessionId,
|
||||
onValueChange = { sessionId = it },
|
||||
value = subjectId,
|
||||
onValueChange = { subjectId = it },
|
||||
label = { Text("Subject ID (예: S001)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LabeledSegmented("Device", ClinicalDevice.values().toList(), device, { device = it }) { it.label }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LabeledSegmented("Posture", ClinicalPosture.values().toList(), posture, { posture = it }) { it.label }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LabeledSegmented("Step", ClinicalStep.values().toList(), step, { step = it }) { it.label }
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = examiner,
|
||||
onValueChange = { examiner = it },
|
||||
@@ -288,25 +170,99 @@ private fun ClinicalSessionStartCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes,
|
||||
onValueChange = { notes = it },
|
||||
label = { Text("Notes (선택)") },
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp)
|
||||
modifier = Modifier.fillMaxWidth().height(80.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Cancel") }
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 라벨 preview
|
||||
val previewLabel = if (subjectId.isNotBlank())
|
||||
"${subjectId}_${device.name}_${posture.name}_${step.name}"
|
||||
else "(Subject ID 입력 필요)"
|
||||
Text("Label: $previewLabel",
|
||||
fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Start Measurement
|
||||
Button(
|
||||
onClick = {
|
||||
ClinicalSessionStore.startMeasurement(
|
||||
context,
|
||||
ClinicalSession(
|
||||
subjectId = subjectId.trim(),
|
||||
device = device,
|
||||
posture = posture,
|
||||
step = step,
|
||||
examiner = examiner.trim(),
|
||||
notes = notes.trim()
|
||||
)
|
||||
)
|
||||
appState.currentScreen = if (step.isAlignment) AppScreen.PLACEMENT_GUIDE
|
||||
else AppScreen.PIEZO_MONITORING
|
||||
},
|
||||
enabled = isConnected && subjectId.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { if (sessionId.isNotBlank()) onStart(sessionId.trim(), examiner.trim(), notes.trim()) },
|
||||
enabled = sessionId.isNotBlank(),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||
) { Text("Start", color = Color.White, fontWeight = FontWeight.Bold) }
|
||||
Text("Start Measurement", color = Color.White,
|
||||
fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800))
|
||||
} else if (subjectId.isBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("→ Subject ID 입력 필요", fontSize = 11.sp, color = Color(0xFFFF9800))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text("저장 위치: Downloads/VesiScan_Sessions/",
|
||||
fontSize = 10.sp, color = MlSecondaryText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> LabeledSegmented(
|
||||
label: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
onSelect: (T) -> Unit,
|
||||
labelOf: (T) -> String
|
||||
) {
|
||||
Column {
|
||||
Text(label, fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (opt in options) {
|
||||
val isSel = opt == selected
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSel) MlPrimary else Color.Gray.copy(alpha = 0.12f))
|
||||
.clickable { onSelect(opt) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
labelOf(opt),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isSel) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user