feat: consent screen, reminder system, medical metrics, sex field, design tokens, code quality

A. Reminder: ReminderService (AlarmManager chain), ReminderSettingsView,
   bell icon in Piezo top bar, reschedule on catheterization
B. Consent: ConsentView (3 mandatory items), onboarding→consent→registration flow,
   persistent consent storage, sex field in User + Registration Step 2
C. Diary: 24hUV, NUV, NPI, max capacity, day/night frequency metrics card
D. Design: DesignTokens.kt, 48dp touch targets, DonutChart semantics, pressScale modifier
E. Quality: BuildConfig.DEBUG log wrapping, DateFormatter caching, background I/O for diary store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 14:26:02 +09:00
parent 1d5c6f15f8
commit 0e13f2bbb6
19 changed files with 965 additions and 68 deletions
+1
View File
@@ -38,6 +38,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
+8
View File
@@ -20,6 +20,10 @@
<!-- Voice Recognition --> <!-- Voice Recognition -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Alarm / Vibration -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Internet (AI 음성 fallback) --> <!-- Internet (AI 음성 fallback) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -48,6 +52,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".services.ReminderReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>
@@ -11,11 +11,10 @@ import com.example.medilightv2android.models.*
import com.example.medilightv2android.services.* import com.example.medilightv2android.services.*
enum class AppScreen { enum class AppScreen {
ONBOARDING, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN, ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PIN_SETUP, PIN_ENTRY, NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PIN_SETUP, PIN_ENTRY,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY, VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
VOIDING_DIARY, VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS
URINE_CAMERA
} }
class AppState(private val context: Context) { class AppState(private val context: Context) {
@@ -32,6 +31,7 @@ class AppState(private val context: Context) {
private val historyStore = MeasurementHistoryStore.getInstance(context) private val historyStore = MeasurementHistoryStore.getInstance(context)
private val notificationService = NotificationService.getInstance(context) private val notificationService = NotificationService.getInstance(context)
private val bleManager = BleManager.getInstance(context) private val bleManager = BleManager.getInstance(context)
private val reminderService = ReminderService.getInstance(context)
init { init {
loadState() loadState()
@@ -49,6 +49,7 @@ class AppState(private val context: Context) {
currentScreen = when { currentScreen = when {
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING !storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
!storage.hasConsented -> AppScreen.CONSENT
!storage.isRegistered -> AppScreen.REGISTRATION !storage.isRegistered -> AppScreen.REGISTRATION
!storage.hasPin -> AppScreen.PIN_SETUP !storage.hasPin -> AppScreen.PIN_SETUP
else -> AppScreen.PIN_ENTRY else -> AppScreen.PIN_ENTRY
@@ -57,6 +58,12 @@ class AppState(private val context: Context) {
fun completeOnboarding() { fun completeOnboarding() {
storage.hasCompletedOnboarding = true storage.hasCompletedOnboarding = true
currentScreen = AppScreen.CONSENT
}
fun completeConsent() {
storage.hasConsented = true
storage.consentTimestamp = System.currentTimeMillis()
currentScreen = AppScreen.REGISTRATION currentScreen = AppScreen.REGISTRATION
} }
@@ -164,6 +171,7 @@ class AppState(private val context: Context) {
bladderLevel = newLevel bladderLevel = newLevel
storage.bladderLevel = 0 storage.bladderLevel = 0
notificationService.cancelAll() notificationService.cancelAll()
reminderService.reschedule()
if (catheterCount.intValue > 0) { if (catheterCount.intValue > 0) {
catheterCount.intValue-- catheterCount.intValue--
@@ -30,6 +30,8 @@ import com.example.medilightv2android.ui.views.personalization.PiezoPersonalizat
import com.example.medilightv2android.ui.views.pin.PinMode import com.example.medilightv2android.ui.views.pin.PinMode
import com.example.medilightv2android.ui.views.pin.PinView import com.example.medilightv2android.ui.views.pin.PinView
import com.example.medilightv2android.ui.views.registration.RegistrationView import com.example.medilightv2android.ui.views.registration.RegistrationView
import com.example.medilightv2android.ui.views.consent.ConsentView
import com.example.medilightv2android.ui.views.reminder.ReminderSettingsView
import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -65,6 +67,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
) { screen -> ) { screen ->
when (screen) { when (screen) {
AppScreen.ONBOARDING -> OnboardingView(appState) AppScreen.ONBOARDING -> OnboardingView(appState)
AppScreen.CONSENT -> ConsentView(appState)
AppScreen.REGISTRATION -> RegistrationView(appState) AppScreen.REGISTRATION -> RegistrationView(appState)
AppScreen.HOME -> HomeView(appState) AppScreen.HOME -> HomeView(appState)
AppScreen.SENSOR_SELECT -> SensorSelectView(appState) AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
@@ -84,6 +87,9 @@ fun MedilightApp(modifier: Modifier = Modifier) {
}, },
onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING } onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING }
) )
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
onBack = { appState.backToMonitoring() }
)
} }
} }
} }
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import com.example.medilightv2android.BuildConfig
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import java.util.UUID import java.util.UUID
@@ -27,6 +28,9 @@ class BleManager private constructor(private val context: Context) {
companion object { companion object {
private const val TAG = "BleManager" private const val TAG = "BleManager"
private inline fun logd(msg: () -> String) { if (BuildConfig.DEBUG) Log.d(TAG, msg()) }
private inline fun logw(msg: () -> String) { if (BuildConfig.DEBUG) Log.w(TAG, msg()) }
private inline fun loge(msg: () -> String) { if (BuildConfig.DEBUG) Log.e(TAG, msg()) }
@Volatile @Volatile
private var instance: BleManager? = null private var instance: BleManager? = null
@@ -87,7 +91,7 @@ class BleManager private constructor(private val context: Context) {
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
if (bluetoothLeScanner == null) { if (bluetoothLeScanner == null) {
Log.e(TAG, "BluetoothLeScanner is null") loge { "BluetoothLeScanner is null" }
isScanning.value = false isScanning.value = false
return return
} }
@@ -98,9 +102,9 @@ class BleManager private constructor(private val context: Context) {
try { try {
bluetoothLeScanner?.startScan(null, settings, scanCallback) bluetoothLeScanner?.startScan(null, settings, scanCallback)
Log.d(TAG, "BLE scan started") logd { "BLE scan started" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to start scan: ${e.message}") loge { "Failed to start scan: ${e.message}" }
isScanning.value = false isScanning.value = false
return return
} }
@@ -115,7 +119,7 @@ class BleManager private constructor(private val context: Context) {
try { try {
bluetoothLeScanner?.stopScan(scanCallback) bluetoothLeScanner?.stopScan(scanCallback)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to stop scan: ${e.message}") logw { "Failed to stop scan: ${e.message}" }
} }
isScanning.value = false isScanning.value = false
scanTimer?.let { handler.removeCallbacks(it) } scanTimer?.let { handler.removeCallbacks(it) }
@@ -134,13 +138,13 @@ class BleManager private constructor(private val context: Context) {
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi) discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
} else { } else {
discoveredDevices.add(BleDevice(device, result.rssi)) discoveredDevices.add(BleDevice(device, result.rssi))
Log.d(TAG, "Discovered: $name (${device.address}) rssi=${result.rssi}") logd { "Discovered: $name (${device.address}) rssi=${result.rssi}" }
} }
} }
} }
override fun onScanFailed(errorCode: Int) { override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "Scan failed with error code: $errorCode") loge { "Scan failed with error code: $errorCode" }
handler.post { handler.post {
isScanning.value = false isScanning.value = false
connectionError.value = "Scan failed (error $errorCode)" connectionError.value = "Scan failed (error $errorCode)"
@@ -151,16 +155,16 @@ class BleManager private constructor(private val context: Context) {
// Connection // Connection
fun connect(bleDevice: BleDevice) { fun connect(bleDevice: BleDevice) {
stopScan() stopScan()
Log.d(TAG, "Connecting to ${bleDevice.name} (${bleDevice.address})") logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection // Clean up any existing GATT connection
bluetoothGatt?.let { gatt -> bluetoothGatt?.let { gatt ->
Log.d(TAG, "Closing existing GATT connection") logd { "Closing existing GATT connection" }
try { try {
gatt.disconnect() gatt.disconnect()
gatt.close() gatt.close()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error closing old GATT: ${e.message}") logw { "Error closing old GATT: ${e.message}" }
} }
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
@@ -174,9 +178,9 @@ class BleManager private constructor(private val context: Context) {
try { try {
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
Log.d(TAG, "connectGatt called, gatt=${bluetoothGatt != null}") logd { "connectGatt called, gatt=${bluetoothGatt != null}" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "connectGatt failed: ${e.message}") loge { "connectGatt failed: ${e.message}" }
handler.post { handler.post {
connectionError.value = "Connection failed: ${e.message}" connectionError.value = "Connection failed: ${e.message}"
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false) if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
@@ -188,7 +192,7 @@ class BleManager private constructor(private val context: Context) {
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
val timeout = Runnable { val timeout = Runnable {
if (!isConnected.value) { if (!isConnected.value) {
Log.w(TAG, "Connection timeout") logw { "Connection timeout" }
bluetoothGatt?.let { gatt -> bluetoothGatt?.let { gatt ->
try { try {
gatt.disconnect() gatt.disconnect()
@@ -247,25 +251,25 @@ class BleManager private constructor(private val context: Context) {
val characteristic = txCharacteristic val characteristic = txCharacteristic
val gatt = bluetoothGatt val gatt = bluetoothGatt
if (characteristic == null) { if (characteristic == null) {
Log.e(TAG, "sendRaw: txCharacteristic is null!") loge { "sendRaw: txCharacteristic is null!" }
return return
} }
if (gatt == null) { if (gatt == null) {
Log.e(TAG, "sendRaw: bluetoothGatt is null!") loge { "sendRaw: bluetoothGatt is null!" }
return return
} }
val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII) else "?" val cmdPreview = if (data.size >= 4) String(data, 0, 4, Charsets.US_ASCII) else "?"
Log.d(TAG, "sendRaw: $cmdPreview (${data.size} bytes)") logd { "sendRaw: $cmdPreview (${data.size} bytes)" }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
Log.d(TAG, "sendRaw result: $result") logd { "sendRaw result: $result" }
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
characteristic.value = data characteristic.value = data
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val ok = gatt.writeCharacteristic(characteristic) val ok = gatt.writeCharacteristic(characteristic)
Log.d(TAG, "sendRaw success: $ok") logd { "sendRaw success: $ok" }
} }
} }
@@ -328,7 +332,7 @@ class BleManager private constructor(private val context: Context) {
private val gattCallback = object : BluetoothGattCallback() { private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
Log.d(TAG, "onConnectionStateChange: status=$status newState=$newState") logd { "onConnectionStateChange: status=$status newState=$newState" }
handler.post { handler.post {
// Cancel connection timeout // Cancel connection timeout
connectionTimer?.let { handler.removeCallbacks(it) } connectionTimer?.let { handler.removeCallbacks(it) }
@@ -336,7 +340,7 @@ class BleManager private constructor(private val context: Context) {
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) { if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
// Connection failed with error // Connection failed with error
Log.e(TAG, "GATT connection failed with status: $status") loge { "GATT connection failed with status: $status" }
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
rxCharacteristic = null rxCharacteristic = null
@@ -354,7 +358,7 @@ class BleManager private constructor(private val context: Context) {
when (newState) { when (newState) {
BluetoothProfile.STATE_CONNECTED -> { BluetoothProfile.STATE_CONNECTED -> {
Log.d(TAG, "Connected to ${gatt.device.address}") logd { "Connected to ${gatt.device.address}" }
isReconnecting.value = false isReconnecting.value = false
isUserDisconnect = false isUserDisconnect = false
connectionError.value = null connectionError.value = null
@@ -373,7 +377,7 @@ class BleManager private constructor(private val context: Context) {
onConnectionStateChanged?.invoke(true) onConnectionStateChanged?.invoke(true)
} }
BluetoothProfile.STATE_DISCONNECTED -> { BluetoothProfile.STATE_DISCONNECTED -> {
Log.d(TAG, "Disconnected from ${gatt.device.address}") logd { "Disconnected from ${gatt.device.address}" }
stopBatteryPolling() stopBatteryPolling()
bluetoothGatt = null bluetoothGatt = null
txCharacteristic = null txCharacteristic = null
@@ -399,21 +403,21 @@ class BleManager private constructor(private val context: Context) {
} }
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d(TAG, "onServicesDiscovered: status=$status") logd { "onServicesDiscovered: status=$status" }
if (status != BluetoothGatt.GATT_SUCCESS) { if (status != BluetoothGatt.GATT_SUCCESS) {
handler.post { connectionError.value = "Service discovery failed (status $status)" } handler.post { connectionError.value = "Service discovery failed (status $status)" }
return return
} }
val service = gatt.getService(SERVICE_UUID) val service = gatt.getService(SERVICE_UUID)
if (service == null) { if (service == null) {
Log.e(TAG, "NUS service not found!") loge { "NUS service not found!" }
handler.post { connectionError.value = "Device service not found" } handler.post { connectionError.value = "Device service not found" }
return return
} }
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID) txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID) rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
Log.d(TAG, "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}") logd { "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}" }
rxCharacteristic?.let { rx -> rxCharacteristic?.let { rx ->
gatt.setCharacteristicNotification(rx, true) gatt.setCharacteristicNotification(rx, true)
@@ -428,12 +432,12 @@ class BleManager private constructor(private val context: Context) {
gatt.writeDescriptor(descriptor) gatt.writeDescriptor(descriptor)
} }
} else { } else {
Log.w(TAG, "CCCD descriptor not found for RX characteristic") logw { "CCCD descriptor not found for RX characteristic" }
// No CCCD but still mark ready if characteristics exist // No CCCD but still mark ready if characteristics exist
handler.post { handler.post {
if (txCharacteristic != null && rxCharacteristic != null) { if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true isServiceReady.value = true
Log.d(TAG, "Service ready (no CCCD)") logd { "Service ready (no CCCD)" }
} }
} }
} }
@@ -442,24 +446,24 @@ class BleManager private constructor(private val context: Context) {
handler.post { handler.post {
if (txCharacteristic != null) { if (txCharacteristic != null) {
isServiceReady.value = true isServiceReady.value = true
Log.d(TAG, "Service ready (no RX)") logd { "Service ready (no RX)" }
} }
} }
} }
} }
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
Log.d(TAG, "onDescriptorWrite: status=$status uuid=${descriptor.uuid}") logd { "onDescriptorWrite: status=$status uuid=${descriptor.uuid}" }
if (descriptor.uuid == CCCD_UUID) { if (descriptor.uuid == CCCD_UUID) {
handler.post { handler.post {
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == BluetoothGatt.GATT_SUCCESS) {
if (txCharacteristic != null && rxCharacteristic != null) { if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true isServiceReady.value = true
startBatteryPolling() startBatteryPolling()
Log.d(TAG, "Service ready! (CCCD written)") logd { "Service ready! (CCCD written)" }
} }
} else { } else {
Log.e(TAG, "CCCD write failed with status: $status") loge { "CCCD write failed with status: $status" }
connectionError.value = "Notification setup failed (status $status)" connectionError.value = "Notification setup failed (status $status)"
} }
} }
@@ -486,7 +490,7 @@ class BleManager private constructor(private val context: Context) {
private fun processReceivedData(data: ByteArray) { private fun processReceivedData(data: ByteArray) {
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII) val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
Log.d(TAG, "Received: $prefix (${data.size} bytes), callback=${ logd { "Received: $prefix (${data.size} bytes), callback=${
when { when {
prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != null}" prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != null}"
prefix.startsWith("rta") -> "onNirsPowerOnReceived=${onNirsPowerOnReceived != null}" prefix.startsWith("rta") -> "onNirsPowerOnReceived=${onNirsPowerOnReceived != null}"
@@ -494,7 +498,7 @@ class BleManager private constructor(private val context: Context) {
prefix.startsWith("rag") -> "onNirsMagReceived=${onNirsMagReceived != null}" prefix.startsWith("rag") -> "onNirsMagReceived=${onNirsMagReceived != null}"
else -> "n/a" else -> "n/a"
} }
}") }" }
when (prefix) { when (prefix) {
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke() "rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke() "rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
@@ -507,7 +511,7 @@ class BleManager private constructor(private val context: Context) {
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100) batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
} }
} }
else -> Log.w(TAG, "Unknown prefix: '$prefix' hex=${data.take(8).joinToString(" ") { "%02X".format(it) }}") else -> logw { "Unknown prefix: '$prefix' hex=${data.take(8).joinToString(" ") { "%02X".format(it) }}" }
} }
} }
} }
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import com.example.medilightv2android.models.NirsMagResponse import com.example.medilightv2android.models.NirsMagResponse
import com.example.medilightv2android.models.NirsMcjPacket import com.example.medilightv2android.models.NirsMcjPacket
import com.example.medilightv2android.models.NirsConstants import com.example.medilightv2android.models.NirsConstants
import com.example.medilightv2android.BuildConfig
import com.example.medilightv2android.models.NirsOxyResult import com.example.medilightv2android.models.NirsOxyResult
class NirsManager private constructor() { class NirsManager private constructor() {
@@ -42,7 +43,7 @@ class NirsManager private constructor() {
currentOxy.value = result.oxyFiltered currentOxy.value = result.oxyFiltered
sampleCount.intValue++ sampleCount.intValue++
if (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0) { if (BuildConfig.DEBUG && (sampleCount.intValue <= 3 || sampleCount.intValue % 50 == 0)) {
android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " + android.util.Log.d("NirsManager", "Sample #${sampleCount.intValue}: " +
"hasGain=${calcEngine.hasGain}, " + "hasGain=${calcEngine.hasGain}, " +
"led[0..5]=${packet.ledValues.take(6)}, " + "led[0..5]=${packet.ledValues.take(6)}, " +
@@ -4,6 +4,9 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -17,11 +20,16 @@ data class VoidingRecord(
val method: String = "manual", // "manual", "voice", "camera" val method: String = "manual", // "manual", "voice", "camera"
val timestamp: Long = System.currentTimeMillis() val timestamp: Long = System.currentTimeMillis()
) { ) {
companion object {
private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
}
val timeString: String val timeString: String
get() = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) get() = timeFormat.format(Date(timestamp))
val dateString: String val dateString: String
get() = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(timestamp)) get() = dateFormat.format(Date(timestamp))
val displayText: String val displayText: String
get() = when { get() = when {
@@ -35,6 +43,7 @@ data class VoidingRecord(
/** /**
* 로컬 배뇨 기록 저장소 (SharedPreferences + JSON) * 로컬 배뇨 기록 저장소 (SharedPreferences + JSON)
*/ */
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
object VoidingRecordStore { object VoidingRecordStore {
private const val PREFS_NAME = "voiding_records" private const val PREFS_NAME = "voiding_records"
private const val KEY_RECORDS = "records" private const val KEY_RECORDS = "records"
@@ -61,12 +70,16 @@ object VoidingRecordStore {
} }
fun clear(context: Context) { fun clear(context: Context) {
GlobalScope.launch(Dispatchers.IO) {
prefs(context).edit().remove(KEY_RECORDS).apply() prefs(context).edit().remove(KEY_RECORDS).apply()
} }
}
private fun save(context: Context, records: List<VoidingRecord>) { private fun save(context: Context, records: List<VoidingRecord>) {
GlobalScope.launch(Dispatchers.IO) {
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply() prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
} }
}
fun getToday(context: Context): List<VoidingRecord> { fun getToday(context: Context): List<VoidingRecord> {
val todayStart = Calendar.getInstance().apply { val todayStart = Calendar.getInstance().apply {
@@ -16,7 +16,9 @@ data class NirsMcjPacket(
fun parse(data: ByteArray): NirsMcjPacket? { fun parse(data: ByteArray): NirsMcjPacket? {
if (data.size < 72) { if (data.size < 72) {
if (com.example.medilightv2android.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72") android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
}
return null return null
} }
@@ -40,13 +42,17 @@ data class NirsMcjPacket(
for (i in 0 until 24) { for (i in 0 until 24) {
val offset = base + 20 + (i * 2) val offset = base + 20 + (i * 2)
if (offset + 1 >= data.size) { if (offset + 1 >= data.size) {
if (com.example.medilightv2android.BuildConfig.DEBUG) {
android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}") android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${data.size}")
}
return null return null
} }
ledValues[i] = u16(offset) ledValues[i] = u16(offset)
} }
if (com.example.medilightv2android.BuildConfig.DEBUG) {
android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}") android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
}
return NirsMcjPacket( return NirsMcjPacket(
battery, temperature, battery, temperature,
@@ -1,9 +1,12 @@
package com.example.medilightv2android.models package com.example.medilightv2android.models
enum class Sex { MALE, FEMALE }
data class User( data class User(
var firstName: String = "", var firstName: String = "",
var lastName: String = "", var lastName: String = "",
var age: Int = 0, var age: Int = 0,
var sex: Sex? = null,
var heightCm: Double = 0.0, var heightCm: Double = 0.0,
var weightKg: Double = 0.0 var weightKg: Double = 0.0
) )
@@ -0,0 +1,195 @@
package com.example.medilightv2android.services
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.example.medilightv2android.R
import java.util.Calendar
class ReminderService private constructor(private val context: Context) {
companion object {
@Volatile
private var instance: ReminderService? = null
fun getInstance(context: Context): ReminderService =
instance ?: synchronized(this) {
instance ?: ReminderService(context.applicationContext).also { instance = it }
}
private const val CHANNEL_ID = "catheter_reminder"
private const val CHANNEL_NAME = "배뇨 알림"
private const val NOTIFICATION_ID = 2001
private const val REQUEST_CODE = 3001
private const val PREFS_NAME = "reminder_prefs"
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
init {
createNotificationChannel()
}
// Settings
var isEnabled: Boolean
get() = prefs.getBoolean("enabled", false)
set(value) {
prefs.edit().putBoolean("enabled", value).apply()
if (value) scheduleNext() else cancelAll()
}
var intervalMinutes: Int
get() = prefs.getInt("interval_minutes", 180) // default 3h
set(value) { prefs.edit().putInt("interval_minutes", value).apply() }
var startHour: Int
get() = prefs.getInt("start_hour", 7)
set(value) { prefs.edit().putInt("start_hour", value).apply() }
var endHour: Int
get() = prefs.getInt("end_hour", 22)
set(value) { prefs.edit().putInt("end_hour", value).apply() }
var useSound: Boolean
get() = prefs.getBoolean("use_sound", true)
set(value) { prefs.edit().putBoolean("use_sound", value).apply() }
var useVibration: Boolean
get() = prefs.getBoolean("use_vibration", true)
set(value) { prefs.edit().putBoolean("use_vibration", value).apply() }
var flexMinutes: Int
get() = prefs.getInt("flex_minutes", 0)
set(value) { prefs.edit().putInt("flex_minutes", value).apply() }
fun scheduleNext() {
if (!isEnabled) return
val now = Calendar.getInstance()
val nextTime = Calendar.getInstance().apply {
timeInMillis = now.timeInMillis + intervalMinutes * 60 * 1000L
}
// 시간대 체크: endHour 이후면 다음날 startHour로
if (nextTime.get(Calendar.HOUR_OF_DAY) >= endHour ||
nextTime.get(Calendar.HOUR_OF_DAY) < startHour) {
nextTime.apply {
if (get(Calendar.HOUR_OF_DAY) >= endHour) {
add(Calendar.DAY_OF_YEAR, 1)
}
set(Calendar.HOUR_OF_DAY, startHour)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
}
}
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} else {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
} catch (_: SecurityException) {
// Fallback to inexact alarm
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, nextTime.timeInMillis, pendingIntent
)
}
}
fun reschedule() {
if (isEnabled) {
cancelAll()
scheduleNext()
}
}
fun cancelAll() {
val intent = Intent(context, ReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, REQUEST_CODE, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
}
fun showNotification() {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("배뇨 시간")
.setContentText("도뇨 시간입니다. 배뇨를 진행해 주세요.")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
if (useSound) {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
}
try {
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
} catch (_: SecurityException) { }
if (useVibration) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
}
} catch (_: Exception) { }
}
// Chain: schedule next alarm
scheduleNext()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH
).apply {
description = "배뇨 알리미 알림"
enableVibration(true)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
}
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
ReminderService.getInstance(context).showNotification()
}
}
@@ -81,6 +81,26 @@ class UserStorage private constructor(context: Context) {
get() = prefs.getBoolean("has_completed_onboarding", false) get() = prefs.getBoolean("has_completed_onboarding", false)
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() } set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
var hasConsented: Boolean
get() = prefs.getBoolean("has_consented", false)
set(value) { prefs.edit().putBoolean("has_consented", value).apply() }
var consentTimestamp: Long
get() = prefs.getLong("consent_timestamp", 0L)
set(value) { prefs.edit().putLong("consent_timestamp", value).apply() }
var consentPersonalInfo: Boolean
get() = prefs.getBoolean("consent_personal_info", false)
set(value) { prefs.edit().putBoolean("consent_personal_info", value).apply() }
var consentMedicalInfo: Boolean
get() = prefs.getBoolean("consent_medical_info", false)
set(value) { prefs.edit().putBoolean("consent_medical_info", value).apply() }
var consentDataCollection: Boolean
get() = prefs.getBoolean("consent_data_collection", false)
set(value) { prefs.edit().putBoolean("consent_data_collection", value).apply() }
var piezoSettings: PiezoSettings var piezoSettings: PiezoSettings
get() { get() {
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings() val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.medilightv2android.models.UrgencyLevel import com.example.medilightv2android.models.UrgencyLevel
@@ -28,8 +30,13 @@ fun DonutChartView(
) )
val gradientColors = urgency.gradientColors val gradientColors = urgency.gradientColors
val pct = (progress * 100).toInt()
Canvas(modifier = Modifier.size(size)) { Canvas(
modifier = Modifier
.size(size)
.semantics { contentDescription = "Bladder fill $pct percent, ${urgency.message}" }
) {
val strokeWidth = lineWidth.toPx() val strokeWidth = lineWidth.toPx()
// Background track // Background track
@@ -0,0 +1,58 @@
package com.example.medilightv2android.ui.theme
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
fun Modifier.pressScale(onClick: () -> Unit): Modifier = composed {
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, label = "pressScale")
val alpha by animateFloatAsState(if (pressed) 0.85f else 1f, label = "pressAlpha")
this
.graphicsLayer { scaleX = scale; scaleY = scale; this.alpha = alpha }
.pointerInput(Unit) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
},
onTap = { onClick() }
)
}
}
object DesignTokens {
// Button
val ButtonHeight = 56.dp
val ButtonHeightSmall = 44.dp
// Touch targets
val MinTouchTarget = 48.dp
// Corner radius
val CornerRadiusSmall = 8.dp
val CornerRadiusMedium = 14.dp
val CornerRadiusLarge = 16.dp
val CornerRadiusPill = 20.dp
// Spacing
val SpacingXs = 4.dp
val SpacingSm = 8.dp
val SpacingMd = 12.dp
val SpacingLg = 16.dp
val SpacingXl = 20.dp
val SpacingXxl = 24.dp
val SpacingPage = 32.dp
// Icon sizes
val IconSmall = 16.dp
val IconMedium = 20.dp
val IconDefault = 24.dp
val IconLarge = 48.dp
}
@@ -0,0 +1,192 @@
package com.example.medilightv2android.ui.views.consent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState
import com.example.medilightv2android.services.UserStorage
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.*
@Composable
fun ConsentView(appState: AppState) {
val context = LocalContext.current
val storage = remember { UserStorage.getInstance(context) }
var consentPersonal by remember { mutableStateOf(false) }
var consentMedical by remember { mutableStateOf(false) }
var consentData by remember { mutableStateOf(false) }
val allAgreed = consentPersonal && consentMedical && consentData
Column(
modifier = Modifier
.fillMaxSize()
.background(MlBackground)
) {
Spacer(modifier = Modifier.height(48.dp))
BladdyIdleRiveView(
size = 100.dp,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"이용 동의",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 32.dp)
)
Text(
"서비스 이용을 위해 아래 항목에 동의해 주세요.",
fontSize = 14.sp,
color = MlSecondaryText,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 전체 동의
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
val newVal = !allAgreed
consentPersonal = newVal
consentMedical = newVal
consentData = newVal
}
.padding(vertical = 8.dp)
) {
Checkbox(
checked = allAgreed,
onCheckedChange = {
consentPersonal = it
consentMedical = it
consentData = it
},
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
)
Spacer(modifier = Modifier.width(8.dp))
Text("전체 동의", fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.2f))
ConsentItem(
title = "[필수] 개인정보 수집 및 이용 동의",
description = "이름, 나이, 성별, 신체정보(키·체중)를 앱 내 기능 제공 목적으로 수집합니다. 수집된 정보는 기기에만 저장되며 외부로 전송되지 않습니다.",
checked = consentPersonal,
onCheckedChange = { consentPersonal = it }
)
ConsentItem(
title = "[필수] 의료정보 활용 동의",
description = "방광 측정 데이터, 배뇨일지, NIRS 산소포화도 등 건강 관련 데이터를 모니터링 및 기록 목적으로 활용합니다.",
checked = consentMedical,
onCheckedChange = { consentMedical = it }
)
ConsentItem(
title = "[필수] 데이터 저장 동의",
description = "측정 기록 및 배뇨일지 데이터가 기기 내부 저장소에 저장됩니다. 앱 삭제 시 모든 데이터가 삭제됩니다.",
checked = consentData,
onCheckedChange = { consentData = it }
)
Spacer(modifier = Modifier.height(16.dp))
}
// 동의 버튼
Button(
onClick = {
storage.consentPersonalInfo = consentPersonal
storage.consentMedicalInfo = consentMedical
storage.consentDataCollection = consentData
appState.completeConsent()
},
enabled = allAgreed,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 48.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = PaddingValues()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = if (allAgreed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary))
else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))),
shape = RoundedCornerShape(16.dp)
),
contentAlignment = Alignment.Center
) {
Text(
"동의하고 계속하기",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
}
@Composable
private fun ConsentItem(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MlCardBackground, RoundedCornerShape(12.dp))
.clickable { onCheckedChange(!checked) }
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = MlPrimary)
)
Spacer(modifier = Modifier.width(8.dp))
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
Text(
description,
fontSize = 12.sp,
color = MlSecondaryText,
modifier = Modifier.padding(start = 48.dp, top = 4.dp)
)
}
}
@@ -271,11 +271,14 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp), .padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { appState.goHome() }, modifier = Modifier.size(if (isCompact) 44.dp else 48.dp)) { IconButton(onClick = { appState.goHome() }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(if (isCompact) 44.dp else 48.dp)) { IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Description, "배뇨일지", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Description, contentDescription = "배뇨일지", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
}
IconButton(onClick = { appState.currentScreen = AppScreen.REMINDER_SETTINGS }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Notifications, contentDescription = "배뇨 알리미", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
if (!isCompact) { if (!isCompact) {
Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp)) Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
@@ -308,10 +311,10 @@ fun PiezoMonitoringView(appState: AppState) {
} }
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { appState.showHistory() }, modifier = Modifier.size(if (isCompact) 44.dp else 48.dp)) { IconButton(onClick = { appState.showHistory() }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.History, "History", tint = MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.History, "History", tint = MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(if (isCompact) 44.dp else 48.dp)) { IconButton(onClick = { showSettings = !showSettings }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Settings, "Settings", tint = if (showSettings) MlPrimary else MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Settings, "Settings", tint = if (showSettings) MlPrimary else MlSecondaryText, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
@@ -86,8 +86,10 @@ fun VivamyoMonitoringView(appState: AppState) {
if (packet != null) { if (packet != null) {
nirsManager.processMcjPacket(packet) nirsManager.processMcjPacket(packet)
} else { } else {
if (com.example.medilightv2android.BuildConfig.DEBUG) {
android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}") android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
} }
}
handler.postDelayed({ handler.postDelayed({
if (nirsManager.isStreaming.value) { if (nirsManager.isStreaming.value) {
bleManager.sendMcjQuery() bleManager.sendMcjQuery()
@@ -239,7 +241,7 @@ fun VivamyoMonitoringView(appState: AppState) {
IconButton(onClick = { IconButton(onClick = {
nirsManager.reset() nirsManager.reset()
appState.goHome() appState.goHome()
}, modifier = Modifier.size(if (isCompact) 36.dp else 48.dp)) { }, modifier = Modifier.size(48.dp)) {
Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp)) Icon(Icons.Default.Home, "Home", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
} }
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) { Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
@@ -6,11 +6,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -19,11 +21,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.measure.VoidingRecord import com.example.medilightv2android.measure.VoidingRecord
import com.example.medilightv2android.measure.VoidingRecordStore import com.example.medilightv2android.measure.VoidingRecordStore
import com.example.medilightv2android.ui.theme.MlCardBackground
import com.example.medilightv2android.ui.theme.MlPrimary
import com.example.medilightv2android.ui.theme.MlSecondaryText
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* 배뇨일지 목록 화면 — 로컬 기록 조회 * 배뇨일지 목록 화면 — 로컬 기록 조회 + 의료 지표
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -32,17 +37,23 @@ fun VoidingDiaryView(onBack: () -> Unit) {
var records by remember { mutableStateOf(VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }) } var records by remember { mutableStateOf(VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }) }
var showDeleteConfirm by remember { mutableStateOf<String?>(null) } var showDeleteConfirm by remember { mutableStateOf<String?>(null) }
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
val displayDateFormat = remember { SimpleDateFormat("M월 d일 (E)", Locale.KOREA) }
val grouped = remember(records) { val grouped = remember(records) {
records.groupBy { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(it.timestamp)) } records.groupBy { dateFormat.format(Date(it.timestamp)) }
.toSortedMap(compareByDescending { it }) // 최신 날짜가 위 .toSortedMap(compareByDescending { it })
} }
// 의료 지표 계산 (최근 24시간 기준)
val metrics = remember(records) { calculateMedicalMetrics(records) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("배뇨일지", fontWeight = FontWeight.SemiBold) }, title = { Text("배뇨일지", fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "뒤로") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로") }
} }
) )
} }
@@ -50,7 +61,7 @@ fun VoidingDiaryView(onBack: () -> Unit) {
if (records.isEmpty()) { if (records.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Description, null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f)) Icon(Icons.Default.Description, contentDescription = null, Modifier.size(48.dp), Color.Gray.copy(alpha = 0.5f))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("기록이 없습니다", color = Color.Gray) Text("기록이 없습니다", color = Color.Gray)
} }
@@ -61,11 +72,14 @@ fun VoidingDiaryView(onBack: () -> Unit) {
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// 의료 지표 카드
item { MedicalMetricsCard(metrics) }
grouped.forEach { (date, dayRecords) -> grouped.forEach { (date, dayRecords) ->
item { item {
val displayDate = try { val displayDate = try {
val d = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(date) val d = dateFormat.parse(date)
SimpleDateFormat("M월 d일 (E)", Locale.KOREA).format(d!!) displayDateFormat.format(d!!)
} catch (_: Exception) { date } } catch (_: Exception) { date }
Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp, Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp,
@@ -85,17 +99,17 @@ fun VoidingDiaryView(onBack: () -> Unit) {
when { when {
record.volume != null -> { record.volume != null -> {
Icon(Icons.Default.WaterDrop, null, Modifier.size(16.dp), Color(0xFF007AFF)) Icon(Icons.Default.WaterDrop, contentDescription = null, Modifier.size(16.dp), Color(0xFF007AFF))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("${record.volume} ml", fontWeight = FontWeight.SemiBold) Text("${record.volume} ml", fontWeight = FontWeight.SemiBold)
} }
record.activity == "awake" -> { record.activity == "awake" -> {
Icon(Icons.Default.WbSunny, null, Modifier.size(16.dp), Color(0xFFFF9800)) Icon(Icons.Default.WbSunny, contentDescription = null, Modifier.size(16.dp), Color(0xFFFF9800))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("기상") Text("기상")
} }
record.activity == "sleep" -> { record.activity == "sleep" -> {
Icon(Icons.Default.DarkMode, null, Modifier.size(16.dp), Color(0xFF9C27B0)) Icon(Icons.Default.DarkMode, contentDescription = null, Modifier.size(16.dp), Color(0xFF9C27B0))
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("취침") Text("취침")
} }
@@ -103,7 +117,6 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
// 입력 방식 뱃지
val methodLabel = when (record.method) { val methodLabel = when (record.method) {
"voice" -> "음성" "voice" -> "음성"
"camera" -> "카메라" "camera" -> "카메라"
@@ -115,9 +128,11 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// 삭제 IconButton(
IconButton(onClick = { showDeleteConfirm = record.id }) { onClick = { showDeleteConfirm = record.id },
Icon(Icons.Default.Delete, "삭제", Modifier.size(20.dp), Color.Gray) modifier = Modifier.size(48.dp)
) {
Icon(Icons.Default.Delete, contentDescription = "삭제", Modifier.size(20.dp), Color.Gray)
} }
} }
} }
@@ -149,3 +164,97 @@ fun VoidingDiaryView(onBack: () -> Unit) {
) )
} }
} }
data class DiaryMetrics(
val totalVolume24h: Int, // 24hUV (24-hour urine volume)
val nocturnalVolume: Int, // NUV (nocturnal urine volume)
val npi: Double, // Nocturnal Polyuria Index (NUV / 24hUV)
val maxCapacity: Int, // Max single voiding volume
val daytimeCount: Int, // 주간 횟수
val nighttimeCount: Int // 야간 횟수
)
private fun calculateMedicalMetrics(records: List<VoidingRecord>): DiaryMetrics {
val now = System.currentTimeMillis()
val h24ago = now - 24 * 60 * 60 * 1000L
val recent = records.filter { it.timestamp >= h24ago }.sortedBy { it.timestamp }
val voidingRecords = recent.filter { it.volume != null }
// awake/sleep 경계 찾기
val lastAwake = recent.lastOrNull { it.activity == "awake" }
val lastSleep = recent.lastOrNull { it.activity == "sleep" }
// 야간 = sleep 이후 ~ awake 이전의 기록
val nocturnalRecords = if (lastSleep != null && lastAwake != null) {
if (lastSleep.timestamp < lastAwake.timestamp) {
// sleep → awake 순서: sleep~awake 사이가 야간
voidingRecords.filter { it.timestamp in lastSleep.timestamp..lastAwake.timestamp }
} else {
// awake → sleep 순서: sleep 이후가 현재 야간
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
}
} else if (lastSleep != null) {
voidingRecords.filter { it.timestamp >= lastSleep.timestamp }
} else {
emptyList()
}
val totalVolume = voidingRecords.sumOf { it.volume ?: 0 }
val nocturnalVolume = nocturnalRecords.sumOf { it.volume ?: 0 }
val npi = if (totalVolume > 0) nocturnalVolume.toDouble() / totalVolume else 0.0
val maxCapacity = voidingRecords.maxOfOrNull { it.volume ?: 0 } ?: 0
val nightCount = nocturnalRecords.size
val dayCount = voidingRecords.size - nightCount
return DiaryMetrics(
totalVolume24h = totalVolume,
nocturnalVolume = nocturnalVolume,
npi = npi,
maxCapacity = maxCapacity,
daytimeCount = dayCount,
nighttimeCount = nightCount
)
}
@Composable
private fun MedicalMetricsCard(metrics: DiaryMetrics) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("24시간 의료 지표", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
HorizontalDivider(color = MlSecondaryText.copy(alpha = 0.15f))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricCell("24hUV", "${metrics.totalVolume24h} ml", Modifier.weight(1f))
MetricCell("NUV", "${metrics.nocturnalVolume} ml", Modifier.weight(1f))
MetricCell("NPI", String.format("%.0f%%", metrics.npi * 100), Modifier.weight(1f),
highlight = metrics.npi > 0.33)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricCell("Max", "${metrics.maxCapacity} ml", Modifier.weight(1f))
MetricCell("주간", "${metrics.daytimeCount}회", Modifier.weight(1f))
MetricCell("야간", "${metrics.nighttimeCount}회", Modifier.weight(1f),
highlight = metrics.nighttimeCount >= 2)
}
}
}
}
@Composable
private fun MetricCell(label: String, value: String, modifier: Modifier = Modifier, highlight: Boolean = false) {
Column(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(if (highlight) Color.Red.copy(alpha = 0.08f) else Color.Gray.copy(alpha = 0.06f))
.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(value, fontSize = 16.sp, fontWeight = FontWeight.Bold,
color = if (highlight) Color.Red else Color(0xFF1C1B1F))
Text(label, fontSize = 10.sp, color = MlSecondaryText)
}
}
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState import com.example.medilightv2android.AppState
import com.example.medilightv2android.models.Sex
import com.example.medilightv2android.ui.components.BladdyIdleRiveView import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
@@ -29,6 +30,7 @@ fun RegistrationView(appState: AppState) {
var firstName by remember { mutableStateOf("") } var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") } var lastName by remember { mutableStateOf("") }
var ageText by remember { mutableStateOf("") } var ageText by remember { mutableStateOf("") }
var selectedSex by remember { mutableStateOf<Sex?>(null) }
var heightText by remember { mutableStateOf("") } var heightText by remember { mutableStateOf("") }
var weightText by remember { mutableStateOf("") } var weightText by remember { mutableStateOf("") }
val totalSteps = 3 val totalSteps = 3
@@ -37,7 +39,7 @@ fun RegistrationView(appState: AppState) {
1 -> firstName.trim().isNotEmpty() && lastName.trim().isNotEmpty() 1 -> firstName.trim().isNotEmpty() && lastName.trim().isNotEmpty()
2 -> { 2 -> {
val age = ageText.toIntOrNull() ?: 0 val age = ageText.toIntOrNull() ?: 0
age in 1..120 age in 1..120 && selectedSex != null
} }
3 -> { 3 -> {
val h = heightText.toDoubleOrNull() ?: 0.0 val h = heightText.toDoubleOrNull() ?: 0.0
@@ -87,8 +89,28 @@ fun RegistrationView(appState: AppState) {
RegistrationTextField("Last Name", lastName, { lastName = it }) RegistrationTextField("Last Name", lastName, { lastName = it })
} }
2 -> { 2 -> {
Text("How old are you?", fontSize = 24.sp, fontWeight = FontWeight.Bold) Text("About you", fontSize = 24.sp, fontWeight = FontWeight.Bold)
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number) RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
Spacer(modifier = Modifier.height(4.dp))
Text("Sex", fontSize = 14.sp, color = MlSecondaryText)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf(Sex.MALE to "남성", Sex.FEMALE to "여성").forEach { (sex, label) ->
val selected = selectedSex == sex
OutlinedButton(
onClick = { selectedSex = sex },
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (selected) MlPrimary.copy(alpha = 0.1f) else Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp, if (selected) MlPrimary else MlInputBorder.copy(alpha = 0.3f)
)
) {
Text(label, color = if (selected) MlPrimary else MlSecondaryText, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
} }
3 -> { 3 -> {
Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold) Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold)
@@ -116,6 +138,7 @@ fun RegistrationView(appState: AppState) {
firstName = firstName, firstName = firstName,
lastName = lastName, lastName = lastName,
age = ageText.toIntOrNull() ?: 0, age = ageText.toIntOrNull() ?: 0,
sex = selectedSex,
heightCm = heightText.toDoubleOrNull() ?: 0.0, heightCm = heightText.toDoubleOrNull() ?: 0.0,
weightKg = weightText.toDoubleOrNull() ?: 0.0 weightKg = weightText.toDoubleOrNull() ?: 0.0
) )
@@ -0,0 +1,238 @@
package com.example.medilightv2android.ui.views.reminder
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.services.ReminderService
import com.example.medilightv2android.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReminderSettingsView(onBack: () -> Unit) {
val context = LocalContext.current
val reminderService = remember { ReminderService.getInstance(context) }
var isEnabled by remember { mutableStateOf(reminderService.isEnabled) }
var intervalMinutes by remember { mutableIntStateOf(reminderService.intervalMinutes) }
var startHour by remember { mutableIntStateOf(reminderService.startHour) }
var endHour by remember { mutableIntStateOf(reminderService.endHour) }
var useSound by remember { mutableStateOf(reminderService.useSound) }
var useVibration by remember { mutableStateOf(reminderService.useVibration) }
var flexMinutes by remember { mutableIntStateOf(reminderService.flexMinutes) }
val intervalOptions = listOf(60, 90, 120, 150, 180, 210, 240, 300, 360)
Scaffold(
topBar = {
TopAppBar(
title = { Text("배뇨 알리미", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 알림 ON/OFF
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("알림 활성화", fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text("정해진 간격으로 배뇨 알림을 받습니다", fontSize = 12.sp, color = MlSecondaryText)
}
Switch(
checked = isEnabled,
onCheckedChange = {
isEnabled = it
reminderService.isEnabled = it
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
if (isEnabled) {
// 간격 설정
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("알림 간격", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(formatInterval(intervalMinutes), fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = intervalOptions.indexOf(intervalMinutes).coerceAtLeast(0).toFloat(),
onValueChange = {
val idx = it.toInt().coerceIn(0, intervalOptions.size - 1)
intervalMinutes = intervalOptions[idx]
reminderService.intervalMinutes = intervalMinutes
},
valueRange = 0f..(intervalOptions.size - 1).toFloat(),
steps = intervalOptions.size - 2,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
// 시간대 설정
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("알림 시간대", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("시작", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text("${startHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = startHour.toFloat(),
onValueChange = {
startHour = it.toInt()
reminderService.startHour = startHour
},
valueRange = 0f..23f,
steps = 22,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("종료", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp))
Text("${endHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = endHour.toFloat(),
onValueChange = {
endHour = it.toInt()
reminderService.endHour = endHour
},
valueRange = 0f..23f,
steps = 22,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
// 유동성 설정
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("유동 스케줄링", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Text("배뇨 기록 시 다음 알림 시간을 자동으로 재설정합니다", fontSize = 12.sp, color = MlSecondaryText)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("유동 범위: ", fontSize = 13.sp, color = MlSecondaryText)
Text("±${flexMinutes}분", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = flexMinutes.toFloat(),
onValueChange = {
flexMinutes = it.toInt()
reminderService.flexMinutes = flexMinutes
},
valueRange = 0f..30f,
steps = 5,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
}
}
// 알림 방식
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("알림 방식", fontSize = 14.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("소리", modifier = Modifier.weight(1f))
Switch(
checked = useSound,
onCheckedChange = {
useSound = it
reminderService.useSound = it
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("진동", modifier = Modifier.weight(1f))
Switch(
checked = useVibration,
onCheckedChange = {
useVibration = it
reminderService.useVibration = it
},
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
)
}
}
}
// 저장 & 적용
Button(
onClick = { reminderService.reschedule() },
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) {
Text("알림 설정 적용", fontWeight = FontWeight.SemiBold, color = androidx.compose.ui.graphics.Color.White)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
private fun formatInterval(minutes: Int): String {
val h = minutes / 60
val m = minutes % 60
return when {
h > 0 && m > 0 -> "${h}시간 ${m}분"
h > 0 -> "${h}시간"
else -> "${m}분"
}
}