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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user