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:
@@ -38,6 +38,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +70,16 @@ object VoidingRecordStore {
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
prefs(context).edit().remove(KEY_RECORDS).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(context: Context, records: List<VoidingRecord>) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
prefs(context).edit().putString(KEY_RECORDS, gson.toJson(records)).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getToday(context: Context): List<VoidingRecord> {
|
||||
val todayStart = Calendar.getInstance().apply {
|
||||
|
||||
@@ -16,7 +16,9 @@ data class NirsMcjPacket(
|
||||
|
||||
fun parse(data: ByteArray): NirsMcjPacket? {
|
||||
if (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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -86,8 +86,10 @@ fun VivamyoMonitoringView(appState: AppState) {
|
||||
if (packet != null) {
|
||||
nirsManager.processMcjPacket(packet)
|
||||
} else {
|
||||
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) {
|
||||
bleManager.sendMcjQuery()
|
||||
@@ -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)) {
|
||||
|
||||
+123
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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
|
||||
)
|
||||
|
||||
+238
@@ -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}분"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user