- 배경색을 반투명 회색(#CC333333)으로 변경하여 라이트/다크 모두 가독성 확보 - 텍스트 색상을 흰색(@android:color/white)으로 변경 - showCustomToast() 함수 개선 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
396 lines
15 KiB
Kotlin
396 lines
15 KiB
Kotlin
package com.example.shiftalarm
|
|
|
|
import android.app.AlarmManager
|
|
import android.app.PendingIntent
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.util.Log
|
|
import android.widget.Toast
|
|
import java.time.LocalDate
|
|
import java.time.LocalDateTime
|
|
import java.time.LocalTime
|
|
import java.time.ZoneId
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
|
const val TAG = "ShiftAlarm"
|
|
|
|
/**
|
|
* 다크모드 지원 커스텀 토스트 표시
|
|
*/
|
|
fun showCustomToast(context: Context, message: String, duration: Int = android.widget.Toast.LENGTH_SHORT) {
|
|
try {
|
|
val inflater = android.view.LayoutInflater.from(context)
|
|
val layout = inflater.inflate(R.layout.custom_toast, null)
|
|
val textView = layout.findViewById<android.widget.TextView>(R.id.toastText)
|
|
textView.text = message
|
|
|
|
val toast = android.widget.Toast(context)
|
|
toast.duration = duration
|
|
toast.view = layout
|
|
toast.setGravity(android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL, 0, 150)
|
|
toast.show()
|
|
} catch (e: Exception) {
|
|
// Fallback to default toast if custom toast fails
|
|
android.widget.Toast.makeText(context, message, duration).show()
|
|
}
|
|
}
|
|
* 다크모드 지원 커스텀 토스트 표시
|
|
*/
|
|
fun showCustomToast(context: Context, message: String, duration: Int = android.widget.Toast.LENGTH_SHORT) {
|
|
try {
|
|
// Use application context with theme for proper dark mode support
|
|
val themedContext = android.view.ContextThemeWrapper(context.applicationContext, R.style.Theme_ShiftAlarm)
|
|
val inflater = android.view.LayoutInflater.from(themedContext)
|
|
val layout = inflater.inflate(R.layout.custom_toast, null)
|
|
val textView = layout.findViewById<android.widget.TextView>(R.id.toastText)
|
|
textView.text = message
|
|
|
|
val toast = android.widget.Toast(context.applicationContext)
|
|
toast.duration = duration
|
|
toast.view = layout
|
|
toast.setGravity(android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL, 0, 100)
|
|
toast.show()
|
|
} catch (e: Exception) {
|
|
// Fallback to default toast if custom toast fails
|
|
android.widget.Toast.makeText(context, message, duration).show()
|
|
}
|
|
}
|
|
// ============================================
|
|
// 알람 ID 생성
|
|
// ============================================
|
|
fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int {
|
|
// Combine date and a unique ID from DB to avoid collisions
|
|
// Using (uniqueId % 1000) to keep it within a reasonable range
|
|
return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100)
|
|
}
|
|
|
|
// ============================================
|
|
// 사용자 알람 예약
|
|
// ============================================
|
|
fun scheduleCustomAlarm(
|
|
context: Context,
|
|
date: LocalDate,
|
|
uniqueId: Int,
|
|
shiftType: String,
|
|
time: String,
|
|
soundUri: String? = null,
|
|
snoozeMin: Int = 5,
|
|
snoozeRepeat: Int = 3
|
|
) {
|
|
val alarmId = getCustomAlarmId(date, uniqueId)
|
|
val label = "사용자:$shiftType"
|
|
|
|
val parts = time.split(":")
|
|
if (parts.size != 2) return
|
|
val hour = parts[0].toIntOrNull() ?: return
|
|
val min = parts[1].toIntOrNull() ?: return
|
|
|
|
cancelAlarmInternal(context, alarmId)
|
|
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
|
putExtra("EXTRA_SHIFT", label)
|
|
putExtra("EXTRA_DATE", date.toString())
|
|
putExtra("EXTRA_TIME", time)
|
|
putExtra("EXTRA_ALARM_ID", alarmId)
|
|
putExtra("EXTRA_IS_CUSTOM", true)
|
|
putExtra("EXTRA_UNIQUE_ID", uniqueId) // DB 검증용
|
|
putExtra("EXTRA_SOUND", soundUri)
|
|
putExtra("EXTRA_SNOOZE", snoozeMin)
|
|
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
|
|
}
|
|
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, alarmId, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min))
|
|
.withSecond(0).withNano(0)
|
|
|
|
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
|
|
|
|
if (alarmTime > System.currentTimeMillis()) {
|
|
setExactAlarm(context, alarmTime, pendingIntent)
|
|
Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)")
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 알람 취소 (전체 범위)
|
|
// ============================================
|
|
fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) {
|
|
val alarmId = getCustomAlarmId(date, uniqueId)
|
|
cancelAlarmInternal(context, alarmId)
|
|
}
|
|
|
|
/**
|
|
* 특정 알람의 모든 예약을 완전히 취소합니다.
|
|
* DB에서 삭제하기 전에 반드시 호출해야 합니다.
|
|
* 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소
|
|
*/
|
|
fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) {
|
|
val today = LocalDate.now(SEOUL_ZONE)
|
|
|
|
// 1. 과거 30일치 취소 (혹시 모를 과거 예약)
|
|
for (i in -30 until 0) {
|
|
val targetDate = today.plusDays(i.toLong())
|
|
cancelCustomAlarm(context, targetDate, uniqueId)
|
|
}
|
|
|
|
// 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버)
|
|
for (i in 0 until 365) {
|
|
val targetDate = today.plusDays(i.toLong())
|
|
cancelCustomAlarm(context, targetDate, uniqueId)
|
|
}
|
|
|
|
// 3. 스누즈 알람도 취소 (스누즈는 999999 ID 사용)
|
|
cancelSnoozeAlarm(context)
|
|
|
|
// 4. 테스트 알람도 취소 (테스트는 888888 ID 사용)
|
|
cancelTestAlarm(context)
|
|
|
|
// 5. 해당 uniqueId와 관련된 모든 가능한 PendingIntent 취소 (추가 안전장치)
|
|
cancelAllPendingIntentsForUniqueId(context, uniqueId)
|
|
|
|
Log.d(TAG, "알람 예약 완전 취소 완료 (ID: $uniqueId, 범위: -30일 ~ +365일)")
|
|
}
|
|
|
|
/**
|
|
* 특정 uniqueId에 대한 모든 가능한 PendingIntent를 취소합니다.
|
|
* 알람 ID 생성 공식의 역연산을 통해 모든 가능성을 커버합니다.
|
|
*/
|
|
private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
|
|
// uniqueId % 100의 모든 가능한 값에 대해 취소 시도
|
|
val baseId = uniqueId % 100
|
|
|
|
// 현재 연도 기준으로 여러 해에 걸친 가능한 ID들
|
|
val currentYear = LocalDate.now(SEOUL_ZONE).year % 100
|
|
val years = listOf(currentYear - 1, currentYear, currentYear + 1)
|
|
|
|
for (year in years) {
|
|
if (year < 0) continue
|
|
for (month in 1..12) {
|
|
for (day in 1..31) {
|
|
try {
|
|
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, alarmId, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
pendingIntent.cancel()
|
|
} catch (e: Exception) {
|
|
// 무시 - 유효하지 않은 날짜 조합
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Log.d(TAG, "uniqueId $uniqueId 관련 모든 PendingIntent 취소 완료")
|
|
}
|
|
|
|
/**
|
|
* 스누즈 알람 취소
|
|
*/
|
|
/**
|
|
* 스누즈 알람 취소 - 모든 가능한 스누즈 ID 취소
|
|
*/
|
|
fun cancelSnoozeAlarm(context: Context) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
|
|
// 주요 스누즈 ID들 취소
|
|
val snoozeIds = listOf(999999, 999998, 999997, 999996, 999995)
|
|
|
|
for (snoozeId in snoozeIds) {
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.SNOOZE"
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, snoozeId, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
pendingIntent.cancel()
|
|
}
|
|
|
|
Log.d(TAG, "스누즈 알람 취소 완료")
|
|
}
|
|
|
|
/**
|
|
* 테스트 알람 취소
|
|
*/
|
|
private fun cancelTestAlarm(context: Context) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, 888888, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
pendingIntent.cancel()
|
|
Log.d(TAG, "테스트 알람 취소 완료")
|
|
}
|
|
|
|
private fun cancelAlarmInternal(context: Context, alarmId: Int) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, alarmId, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
pendingIntent.cancel()
|
|
}
|
|
|
|
// ============================================
|
|
// 정밀 알람 설정 (setAlarmClock 우선)
|
|
// ============================================
|
|
private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
if (!alarmManager.canScheduleExactAlarms()) {
|
|
Log.e(TAG, "정확한 알람 권한 없음!")
|
|
return
|
|
}
|
|
}
|
|
|
|
// setAlarmClock은 Doze 모드에서도 정확하게 작동하며 상단바 알람 아이콘을 활성화함 (신뢰도 최고)
|
|
try {
|
|
val viewIntent = Intent(context, MainActivity::class.java).apply {
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
}
|
|
val viewPendingIntent = PendingIntent.getActivity(
|
|
context, 0, viewIntent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent)
|
|
alarmManager.setAlarmClock(clockInfo, pendingIntent)
|
|
Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}")
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "setAlarmClock 실패, fallback 사용", e)
|
|
try {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
|
|
Log.d(TAG, "setExactAndAllowWhileIdle 예약 성공")
|
|
} else {
|
|
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
|
|
Log.d(TAG, "setExact 예약 성공")
|
|
}
|
|
} catch (e2: Exception) {
|
|
Log.e(TAG, "모든 알람 예약 방법 실패", e2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 스누즈
|
|
// ============================================
|
|
fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) {
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.SNOOZE"
|
|
putExtra("EXTRA_SHIFT", "SNOOZE")
|
|
putExtra("EXTRA_SOUND", soundUri)
|
|
putExtra("EXTRA_SNOOZE", snoozeMin)
|
|
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, 999999, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000)
|
|
setExactAlarm(context, triggerTime, pendingIntent)
|
|
}
|
|
|
|
// ============================================
|
|
// 테스트 알람 (5초 후)
|
|
// ============================================
|
|
fun scheduleTestAlarm(context: Context) {
|
|
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
|
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
|
putExtra("EXTRA_SHIFT", "테스트")
|
|
}
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context, 888888, intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
val triggerTime = System.currentTimeMillis() + 5000
|
|
setExactAlarm(context, triggerTime, pendingIntent)
|
|
}
|
|
|
|
// ============================================
|
|
// 전체 동기화 (30일치 예약)
|
|
// ============================================
|
|
suspend fun syncAllAlarms(context: Context) {
|
|
Log.d(TAG, "===== 전체 알람 동기화 시작 (30일) =====")
|
|
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
|
val repo = ShiftRepository(context)
|
|
|
|
val today = LocalDate.now(SEOUL_ZONE)
|
|
val team = prefs.getString("selected_team", "A") ?: "A"
|
|
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
|
|
|
// 1. 기존 알람 모두 취소 (안전장치)
|
|
// Custom 알람의 경우 ID가 uniqueId 기반이므로 모든 가능성 있는 ID를 취소하기는 어려움.
|
|
// 대신 AlarmManager에서 해당 PendingIntent를 정확히 취소해야 함.
|
|
// 하지만 uniqueId를 알 수 없으므로, 모든 날짜 루프에서 취소 시도.
|
|
|
|
val customAlarms = repo.getAllCustomAlarms()
|
|
|
|
for (i in 0 until 30) {
|
|
val targetDate = today.plusDays(i.toLong())
|
|
// 기본 알람 ID 취소 (이제 안 쓰지만 하위 호환/청소용)
|
|
val legacyId = 100000000 + (targetDate.year % 100) * 1000000 + targetDate.monthValue * 10000 + targetDate.dayOfMonth * 100
|
|
cancelAlarmInternal(context, legacyId)
|
|
|
|
// 커스텀 알람 취소
|
|
customAlarms.forEach { alarm ->
|
|
cancelCustomAlarm(context, targetDate, alarm.id)
|
|
}
|
|
}
|
|
|
|
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
|
|
Log.d(TAG, "마스터 알람이 꺼져 있어 예약을 중단합니다.")
|
|
return
|
|
}
|
|
|
|
// 2. 새로운 스케줄 생성
|
|
for (i in 0 until 30) {
|
|
val targetDate = today.plusDays(i.toLong())
|
|
val shift = repo.getShift(targetDate, team, factory)
|
|
|
|
for (alarm in customAlarms) {
|
|
if (!alarm.isEnabled) continue
|
|
|
|
// 근무 연동 조건 확인
|
|
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
|
|
scheduleCustomAlarm(
|
|
context,
|
|
targetDate,
|
|
alarm.id,
|
|
alarm.shiftType,
|
|
alarm.time,
|
|
alarm.soundUri,
|
|
alarm.snoozeInterval,
|
|
alarm.snoozeRepeat
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Log.d(TAG, "===== 전체 알람 동기화 완료 =====")
|
|
}
|