diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5eb8438..2a1e34d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -38,6 +38,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d33e876..2d6f163 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,10 @@
+
+
+
+
@@ -48,6 +52,10 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt
index 0891154..1ce3f95 100644
--- a/app/src/main/java/com/example/medilightv2android/AppState.kt
+++ b/app/src/main/java/com/example/medilightv2android/AppState.kt
@@ -11,11 +11,10 @@ import com.example.medilightv2android.models.*
import com.example.medilightv2android.services.*
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,
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
- VOIDING_DIARY,
- URINE_CAMERA
+ VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS
}
class AppState(private val context: Context) {
@@ -32,6 +31,7 @@ class AppState(private val context: Context) {
private val historyStore = MeasurementHistoryStore.getInstance(context)
private val notificationService = NotificationService.getInstance(context)
private val bleManager = BleManager.getInstance(context)
+ private val reminderService = ReminderService.getInstance(context)
init {
loadState()
@@ -49,6 +49,7 @@ class AppState(private val context: Context) {
currentScreen = when {
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
+ !storage.hasConsented -> AppScreen.CONSENT
!storage.isRegistered -> AppScreen.REGISTRATION
!storage.hasPin -> AppScreen.PIN_SETUP
else -> AppScreen.PIN_ENTRY
@@ -57,6 +58,12 @@ class AppState(private val context: Context) {
fun completeOnboarding() {
storage.hasCompletedOnboarding = true
+ currentScreen = AppScreen.CONSENT
+ }
+
+ fun completeConsent() {
+ storage.hasConsented = true
+ storage.consentTimestamp = System.currentTimeMillis()
currentScreen = AppScreen.REGISTRATION
}
@@ -164,6 +171,7 @@ class AppState(private val context: Context) {
bladderLevel = newLevel
storage.bladderLevel = 0
notificationService.cancelAll()
+ reminderService.reschedule()
if (catheterCount.intValue > 0) {
catheterCount.intValue--
diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt
index 3121541..7bec70f 100644
--- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt
+++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt
@@ -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.PinView
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
class MainActivity : ComponentActivity() {
@@ -65,6 +67,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
) { screen ->
when (screen) {
AppScreen.ONBOARDING -> OnboardingView(appState)
+ AppScreen.CONSENT -> ConsentView(appState)
AppScreen.REGISTRATION -> RegistrationView(appState)
AppScreen.HOME -> HomeView(appState)
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
@@ -84,6 +87,9 @@ fun MedilightApp(modifier: Modifier = Modifier) {
},
onCancel = { appState.currentScreen = AppScreen.PIEZO_MONITORING }
)
+ AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
+ onBack = { appState.backToMonitoring() }
+ )
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
index dcb5837..5f5b821 100644
--- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
+++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
+import com.example.medilightv2android.BuildConfig
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import java.util.UUID
@@ -27,6 +28,9 @@ class BleManager private constructor(private val context: Context) {
companion object {
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
private var instance: BleManager? = null
@@ -87,7 +91,7 @@ class BleManager private constructor(private val context: Context) {
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
if (bluetoothLeScanner == null) {
- Log.e(TAG, "BluetoothLeScanner is null")
+ loge { "BluetoothLeScanner is null" }
isScanning.value = false
return
}
@@ -98,9 +102,9 @@ class BleManager private constructor(private val context: Context) {
try {
bluetoothLeScanner?.startScan(null, settings, scanCallback)
- Log.d(TAG, "BLE scan started")
+ logd { "BLE scan started" }
} catch (e: Exception) {
- Log.e(TAG, "Failed to start scan: ${e.message}")
+ loge { "Failed to start scan: ${e.message}" }
isScanning.value = false
return
}
@@ -115,7 +119,7 @@ class BleManager private constructor(private val context: Context) {
try {
bluetoothLeScanner?.stopScan(scanCallback)
} catch (e: Exception) {
- Log.w(TAG, "Failed to stop scan: ${e.message}")
+ logw { "Failed to stop scan: ${e.message}" }
}
isScanning.value = false
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)
} else {
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) {
- Log.e(TAG, "Scan failed with error code: $errorCode")
+ loge { "Scan failed with error code: $errorCode" }
handler.post {
isScanning.value = false
connectionError.value = "Scan failed (error $errorCode)"
@@ -151,16 +155,16 @@ class BleManager private constructor(private val context: Context) {
// Connection
fun connect(bleDevice: BleDevice) {
stopScan()
- Log.d(TAG, "Connecting to ${bleDevice.name} (${bleDevice.address})")
+ logd { "Connecting to ${bleDevice.name} (${bleDevice.address})" }
// Clean up any existing GATT connection
bluetoothGatt?.let { gatt ->
- Log.d(TAG, "Closing existing GATT connection")
+ logd { "Closing existing GATT connection" }
try {
gatt.disconnect()
gatt.close()
} catch (e: Exception) {
- Log.w(TAG, "Error closing old GATT: ${e.message}")
+ logw { "Error closing old GATT: ${e.message}" }
}
bluetoothGatt = null
txCharacteristic = null
@@ -174,9 +178,9 @@ class BleManager private constructor(private val context: Context) {
try {
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) {
- Log.e(TAG, "connectGatt failed: ${e.message}")
+ loge { "connectGatt failed: ${e.message}" }
handler.post {
connectionError.value = "Connection failed: ${e.message}"
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) }
val timeout = Runnable {
if (!isConnected.value) {
- Log.w(TAG, "Connection timeout")
+ logw { "Connection timeout" }
bluetoothGatt?.let { gatt ->
try {
gatt.disconnect()
@@ -247,25 +251,25 @@ class BleManager private constructor(private val context: Context) {
val characteristic = txCharacteristic
val gatt = bluetoothGatt
if (characteristic == null) {
- Log.e(TAG, "sendRaw: txCharacteristic is null!")
+ loge { "sendRaw: txCharacteristic is null!" }
return
}
if (gatt == null) {
- Log.e(TAG, "sendRaw: bluetoothGatt is null!")
+ loge { "sendRaw: bluetoothGatt is null!" }
return
}
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) {
val result = gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
- Log.d(TAG, "sendRaw result: $result")
+ logd { "sendRaw result: $result" }
} else {
@Suppress("DEPRECATION")
characteristic.value = data
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@Suppress("DEPRECATION")
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() {
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 {
// Cancel connection timeout
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) {
// Connection failed with error
- Log.e(TAG, "GATT connection failed with status: $status")
+ loge { "GATT connection failed with status: $status" }
bluetoothGatt = null
txCharacteristic = null
rxCharacteristic = null
@@ -354,7 +358,7 @@ class BleManager private constructor(private val context: Context) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
- Log.d(TAG, "Connected to ${gatt.device.address}")
+ logd { "Connected to ${gatt.device.address}" }
isReconnecting.value = false
isUserDisconnect = false
connectionError.value = null
@@ -373,7 +377,7 @@ class BleManager private constructor(private val context: Context) {
onConnectionStateChanged?.invoke(true)
}
BluetoothProfile.STATE_DISCONNECTED -> {
- Log.d(TAG, "Disconnected from ${gatt.device.address}")
+ logd { "Disconnected from ${gatt.device.address}" }
stopBatteryPolling()
bluetoothGatt = null
txCharacteristic = null
@@ -399,21 +403,21 @@ class BleManager private constructor(private val context: Context) {
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
- Log.d(TAG, "onServicesDiscovered: status=$status")
+ logd { "onServicesDiscovered: status=$status" }
if (status != BluetoothGatt.GATT_SUCCESS) {
handler.post { connectionError.value = "Service discovery failed (status $status)" }
return
}
val service = gatt.getService(SERVICE_UUID)
if (service == null) {
- Log.e(TAG, "NUS service not found!")
+ loge { "NUS service not found!" }
handler.post { connectionError.value = "Device service not found" }
return
}
txCharacteristic = service.getCharacteristic(TX_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 ->
gatt.setCharacteristicNotification(rx, true)
@@ -428,12 +432,12 @@ class BleManager private constructor(private val context: Context) {
gatt.writeDescriptor(descriptor)
}
} 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
handler.post {
if (txCharacteristic != null && rxCharacteristic != null) {
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 {
if (txCharacteristic != null) {
isServiceReady.value = true
- Log.d(TAG, "Service ready (no RX)")
+ logd { "Service ready (no RX)" }
}
}
}
}
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) {
handler.post {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (txCharacteristic != null && rxCharacteristic != null) {
isServiceReady.value = true
startBatteryPolling()
- Log.d(TAG, "Service ready! (CCCD written)")
+ logd { "Service ready! (CCCD written)" }
}
} else {
- Log.e(TAG, "CCCD write failed with status: $status")
+ loge { "CCCD write failed with 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) {
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 {
prefix.startsWith("rcj") -> "onNirsMcjReceived=${onNirsMcjReceived != 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}"
else -> "n/a"
}
- }")
+ }" }
when (prefix) {
"rta:", "rta!" -> onNirsPowerOnReceived?.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)
}
}
- 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) }}" }
}
}
}
diff --git a/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt b/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt
index fbf6283..220963f 100644
--- a/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt
+++ b/app/src/main/java/com/example/medilightv2android/managers/NirsManager.kt
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import com.example.medilightv2android.models.NirsMagResponse
import com.example.medilightv2android.models.NirsMcjPacket
import com.example.medilightv2android.models.NirsConstants
+import com.example.medilightv2android.BuildConfig
import com.example.medilightv2android.models.NirsOxyResult
class NirsManager private constructor() {
@@ -42,7 +43,7 @@ class NirsManager private constructor() {
currentOxy.value = result.oxyFiltered
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}: " +
"hasGain=${calcEngine.hasGain}, " +
"led[0..5]=${packet.ledValues.take(6)}, " +
diff --git a/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt b/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt
index 899784c..8c7c0d1 100644
--- a/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt
+++ b/app/src/main/java/com/example/medilightv2android/measure/VoidingRecord.kt
@@ -4,6 +4,9 @@ import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
@@ -17,11 +20,16 @@ data class VoidingRecord(
val method: String = "manual", // "manual", "voice", "camera"
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
- get() = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
+ get() = timeFormat.format(Date(timestamp))
val dateString: String
- get() = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(timestamp))
+ get() = dateFormat.format(Date(timestamp))
val displayText: String
get() = when {
@@ -35,6 +43,7 @@ data class VoidingRecord(
/**
* 로컬 배뇨 기록 저장소 (SharedPreferences + JSON)
*/
+@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
object VoidingRecordStore {
private const val PREFS_NAME = "voiding_records"
private const val KEY_RECORDS = "records"
@@ -61,11 +70,15 @@ object VoidingRecordStore {
}
fun clear(context: Context) {
- prefs(context).edit().remove(KEY_RECORDS).apply()
+ GlobalScope.launch(Dispatchers.IO) {
+ prefs(context).edit().remove(KEY_RECORDS).apply()
+ }
}
private fun save(context: Context, records: List) {
- prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
+ GlobalScope.launch(Dispatchers.IO) {
+ prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
+ }
}
fun getToday(context: Context): List {
diff --git a/app/src/main/java/com/example/medilightv2android/models/NirsData.kt b/app/src/main/java/com/example/medilightv2android/models/NirsData.kt
index 7ef0c4c..1465ca4 100644
--- a/app/src/main/java/com/example/medilightv2android/models/NirsData.kt
+++ b/app/src/main/java/com/example/medilightv2android/models/NirsData.kt
@@ -16,7 +16,9 @@ data class NirsMcjPacket(
fun parse(data: ByteArray): NirsMcjPacket? {
if (data.size < 72) {
- android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
+ if (com.example.medilightv2android.BuildConfig.DEBUG) {
+ android.util.Log.e(TAG, "parse: data too small: ${data.size} < 72")
+ }
return null
}
@@ -40,13 +42,17 @@ data class NirsMcjPacket(
for (i in 0 until 24) {
val offset = base + 20 + (i * 2)
if (offset + 1 >= data.size) {
- android.util.Log.e(TAG, "parse: LED[$i] out of bounds: offset=${offset+1} >= size=${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}")
+ }
return null
}
ledValues[i] = u16(offset)
}
- android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
+ if (com.example.medilightv2android.BuildConfig.DEBUG) {
+ android.util.Log.d(TAG, "parse OK: battery=$battery led[0..5]=${ledValues.take(6)}")
+ }
return NirsMcjPacket(
battery, temperature,
diff --git a/app/src/main/java/com/example/medilightv2android/models/User.kt b/app/src/main/java/com/example/medilightv2android/models/User.kt
index c664167..b335200 100644
--- a/app/src/main/java/com/example/medilightv2android/models/User.kt
+++ b/app/src/main/java/com/example/medilightv2android/models/User.kt
@@ -1,9 +1,12 @@
package com.example.medilightv2android.models
+enum class Sex { MALE, FEMALE }
+
data class User(
var firstName: String = "",
var lastName: String = "",
var age: Int = 0,
+ var sex: Sex? = null,
var heightCm: Double = 0.0,
var weightKg: Double = 0.0
)
diff --git a/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt b/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt
new file mode 100644
index 0000000..34c3908
--- /dev/null
+++ b/app/src/main/java/com/example/medilightv2android/services/ReminderService.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt
index 7f0d741..47e65df 100644
--- a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt
+++ b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt
@@ -81,6 +81,26 @@ class UserStorage private constructor(context: Context) {
get() = prefs.getBoolean("has_completed_onboarding", false)
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
get() {
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
diff --git a/app/src/main/java/com/example/medilightv2android/ui/components/DonutChartView.kt b/app/src/main/java/com/example/medilightv2android/ui/components/DonutChartView.kt
index 6b948ed..27307ca 100644
--- a/app/src/main/java/com/example/medilightv2android/ui/components/DonutChartView.kt
+++ b/app/src/main/java/com/example/medilightv2android/ui/components/DonutChartView.kt
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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 com.example.medilightv2android.models.UrgencyLevel
@@ -28,8 +30,13 @@ fun DonutChartView(
)
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()
// Background track
diff --git a/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt b/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt
new file mode 100644
index 0000000..f93ca9a
--- /dev/null
+++ b/app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt
@@ -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
+}
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/consent/ConsentView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/consent/ConsentView.kt
new file mode 100644
index 0000000..3e0c61f
--- /dev/null
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/consent/ConsentView.kt
@@ -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)
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt
index 118007f..82e4043 100644
--- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt
@@ -271,11 +271,14 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
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))
}
- IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(if (isCompact) 44.dp else 48.dp)) {
- Icon(Icons.Default.Description, "배뇨일지", tint = MlPrimary, modifier = Modifier.size(if (isCompact) 20.dp else 24.dp))
+ IconButton(onClick = { appState.currentScreen = AppScreen.VOIDING_DIARY }, modifier = Modifier.size(48.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) {
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))
- 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))
}
- 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))
}
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt
index 115b846..220d153 100644
--- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt
@@ -86,7 +86,9 @@ fun VivamyoMonitoringView(appState: AppState) {
if (packet != null) {
nirsManager.processMcjPacket(packet)
} else {
- android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
+ if (com.example.medilightv2android.BuildConfig.DEBUG) {
+ android.util.Log.e("VivamyoMonitor", "NirsMcjPacket.parse returned null! data.size=${data.size}")
+ }
}
handler.postDelayed({
if (nirsManager.isStreaming.value) {
@@ -239,7 +241,7 @@ fun VivamyoMonitoringView(appState: AppState) {
IconButton(onClick = {
nirsManager.reset()
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))
}
Column(modifier = Modifier.padding(start = if (isCompact) 4.dp else 8.dp)) {
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt
index 6514598..0f710e5 100644
--- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VoidingDiaryView.kt
@@ -6,11 +6,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -19,11 +21,14 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.measure.VoidingRecord
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.util.*
/**
- * 배뇨일지 목록 화면 — 로컬 기록 조회
+ * 배뇨일지 목록 화면 — 로컬 기록 조회 + 의료 지표
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -32,17 +37,23 @@ fun VoidingDiaryView(onBack: () -> Unit) {
var records by remember { mutableStateOf(VoidingRecordStore.getAll(context).sortedByDescending { it.timestamp }) }
var showDeleteConfirm by remember { mutableStateOf(null) }
+ val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) }
+ val displayDateFormat = remember { SimpleDateFormat("M월 d일 (E)", Locale.KOREA) }
+
val grouped = remember(records) {
- records.groupBy { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date(it.timestamp)) }
- .toSortedMap(compareByDescending { it }) // 최신 날짜가 위
+ records.groupBy { dateFormat.format(Date(it.timestamp)) }
+ .toSortedMap(compareByDescending { it })
}
+ // 의료 지표 계산 (최근 24시간 기준)
+ val metrics = remember(records) { calculateMedicalMetrics(records) }
+
Scaffold(
topBar = {
TopAppBar(
title = { Text("배뇨일지", fontWeight = FontWeight.SemiBold) },
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()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
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))
Text("기록이 없습니다", color = Color.Gray)
}
@@ -61,11 +72,14 @@ fun VoidingDiaryView(onBack: () -> Unit) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
+ // 의료 지표 카드
+ item { MedicalMetricsCard(metrics) }
+
grouped.forEach { (date, dayRecords) ->
item {
val displayDate = try {
- val d = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(date)
- SimpleDateFormat("M월 d일 (E)", Locale.KOREA).format(d!!)
+ val d = dateFormat.parse(date)
+ displayDateFormat.format(d!!)
} catch (_: Exception) { date }
Text(displayDate, fontWeight = FontWeight.Bold, fontSize = 15.sp,
@@ -85,17 +99,17 @@ fun VoidingDiaryView(onBack: () -> Unit) {
when {
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))
Text("${record.volume} ml", fontWeight = FontWeight.SemiBold)
}
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))
Text("기상")
}
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))
Text("취침")
}
@@ -103,7 +117,6 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Spacer(Modifier.weight(1f))
- // 입력 방식 뱃지
val methodLabel = when (record.method) {
"voice" -> "음성"
"camera" -> "카메라"
@@ -115,9 +128,11 @@ fun VoidingDiaryView(onBack: () -> Unit) {
Spacer(Modifier.width(8.dp))
- // 삭제
- IconButton(onClick = { showDeleteConfirm = record.id }) {
- Icon(Icons.Default.Delete, "삭제", Modifier.size(20.dp), Color.Gray)
+ IconButton(
+ onClick = { showDeleteConfirm = record.id },
+ 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): 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)
+ }
+}
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt
index 859f6a9..5de2a4f 100644
--- a/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/registration/RegistrationView.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.medilightv2android.AppState
+import com.example.medilightv2android.models.Sex
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
import com.example.medilightv2android.ui.theme.*
@@ -29,6 +30,7 @@ fun RegistrationView(appState: AppState) {
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var ageText by remember { mutableStateOf("") }
+ var selectedSex by remember { mutableStateOf(null) }
var heightText by remember { mutableStateOf("") }
var weightText by remember { mutableStateOf("") }
val totalSteps = 3
@@ -37,7 +39,7 @@ fun RegistrationView(appState: AppState) {
1 -> firstName.trim().isNotEmpty() && lastName.trim().isNotEmpty()
2 -> {
val age = ageText.toIntOrNull() ?: 0
- age in 1..120
+ age in 1..120 && selectedSex != null
}
3 -> {
val h = heightText.toDoubleOrNull() ?: 0.0
@@ -87,8 +89,28 @@ fun RegistrationView(appState: AppState) {
RegistrationTextField("Last Name", lastName, { lastName = it })
}
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)
+ 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 -> {
Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold)
@@ -116,6 +138,7 @@ fun RegistrationView(appState: AppState) {
firstName = firstName,
lastName = lastName,
age = ageText.toIntOrNull() ?: 0,
+ sex = selectedSex,
heightCm = heightText.toDoubleOrNull() ?: 0.0,
weightKg = weightText.toDoubleOrNull() ?: 0.0
)
diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt
new file mode 100644
index 0000000..f631f09
--- /dev/null
+++ b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt
@@ -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}분"
+ }
+}