feat: consent screen, reminder system, medical metrics, sex field, design tokens, code quality

A. Reminder: ReminderService (AlarmManager chain), ReminderSettingsView,
   bell icon in Piezo top bar, reschedule on catheterization
B. Consent: ConsentView (3 mandatory items), onboarding→consent→registration flow,
   persistent consent storage, sex field in User + Registration Step 2
C. Diary: 24hUV, NUV, NPI, max capacity, day/night frequency metrics card
D. Design: DesignTokens.kt, 48dp touch targets, DonutChart semantics, pressScale modifier
E. Quality: BuildConfig.DEBUG log wrapping, DateFormatter caching, background I/O for diary store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 14:26:02 +09:00
parent 1d5c6f15f8
commit 0e13f2bbb6
19 changed files with 965 additions and 68 deletions
@@ -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()