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 {
compose = true
buildConfig = true
}
}
+8
View File
@@ -20,6 +20,10 @@
<!-- Voice Recognition -->
<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) -->
<uses-permission android:name="android.permission.INTERNET" />
@@ -48,6 +52,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".services.ReminderReceiver"
android:exported="false" />
</application>
</manifest>
@@ -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--
@@ -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() }
)
}
}
}
@@ -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) }}" }
}
}
}
@@ -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)}, " +
@@ -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<VoidingRecord>) {
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<VoidingRecord> {
@@ -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,
@@ -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
)
@@ -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)
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()
@@ -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
@@ -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),
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))
}
@@ -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)) {
@@ -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<String?>(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<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.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<Sex?>(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
)
@@ -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}분"
}
}