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