From 0e13f2bbb697a2b9129ce4acc083a0cf3b765a27 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 7 Apr 2026 14:26:02 +0900 Subject: [PATCH] feat: consent screen, reminder system, medical metrics, sex field, design tokens, code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 8 + .../example/medilightv2android/AppState.kt | 14 +- .../medilightv2android/MainActivity.kt | 6 + .../medilightv2android/ble/BleManager.kt | 70 +++--- .../managers/NirsManager.kt | 3 +- .../measure/VoidingRecord.kt | 21 +- .../medilightv2android/models/NirsData.kt | 12 +- .../example/medilightv2android/models/User.kt | 3 + .../services/ReminderService.kt | 195 ++++++++++++++ .../services/UserStorage.kt | 20 ++ .../ui/components/DonutChartView.kt | 9 +- .../ui/theme/DesignTokens.kt | 58 +++++ .../ui/views/consent/ConsentView.kt | 192 ++++++++++++++ .../views/monitoring/PiezoMonitoringView.kt | 13 +- .../views/monitoring/VivamyoMonitoringView.kt | 6 +- .../ui/views/monitoring/VoidingDiaryView.kt | 137 ++++++++-- .../ui/views/registration/RegistrationView.kt | 27 +- .../ui/views/reminder/ReminderSettingsView.kt | 238 ++++++++++++++++++ 19 files changed, 965 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/services/ReminderService.kt create mode 100644 app/src/main/java/com/example/medilightv2android/ui/theme/DesignTokens.kt create mode 100644 app/src/main/java/com/example/medilightv2android/ui/views/consent/ConsentView.kt create mode 100644 app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt 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}분" + } +}