package com.example.shiftalarm import android.Manifest import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.GestureDetector import android.view.MotionEvent import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.activity.enableEdgeToEdge import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.example.shiftalarm.databinding.ActivityMainBinding import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import androidx.recyclerview.widget.GridLayoutManager import kotlin.math.abs class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private val PREFS_NAME = "ShiftAlarmPrefs" private val KEY_TEAM = "selected_team" private var currentViewMonth: YearMonth = YearMonth.now(ShiftCalculator.SEOUL_ZONE) private var currentViewTeam: String = "A" private lateinit var gestureDetector: GestureDetector override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { super.onConfigurationChanged(newConfig) // Smooth transition for theme change finish() startActivity(Intent(this, javaClass)) overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val density = resources.displayMetrics.density val p = (8 * density).toInt() v.setPadding(systemBars.left + p, systemBars.top + p, systemBars.right + p, systemBars.bottom + p) insets } setupCalendar() setupWorker() setupSwipeGesture() AppUpdateManager.checkUpdate(this, silent = true) checkRoot() val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A" // Default to Shift Calendar mode (checkbox unchecked) binding.cbShowHolidays.isChecked = false binding.btnSettings.setOnClickListener { startActivity(Intent(this, SettingsActivity::class.java)) } binding.prevMonth.setOnClickListener { animateMonthTransition(-1) } binding.monthTitle.setOnClickListener { showMonthYearPicker() } binding.nextMonth.setOnClickListener { animateMonthTransition(1) } binding.btnToday.setOnClickListener { val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A" currentViewMonth = YearMonth.now(ShiftCalculator.SEOUL_ZONE) updateCalendar() } binding.cbShowHolidays.setOnCheckedChangeListener { _, _ -> updateCalendar() } binding.alarmInfoBar.setOnClickListener { val intent = Intent(this, SettingsActivity::class.java) intent.putExtra("TARGET_TAB", 1) // 1 is Alarm tab startActivity(intent) } // Tide Location Cycle Logic val tideLocations = listOf("군산", "변산", "여수", "태안") binding.btnTideLocation.setOnClickListener { val currentLoc = prefs.getString("selected_tide_location", "군산") ?: "군산" val nextIndex = (tideLocations.indexOf(currentLoc) + 1) % tideLocations.size val nextLoc = tideLocations[nextIndex] prefs.edit().putString("selected_tide_location", nextLoc).apply() binding.btnTideLocation.text = nextLoc updateCalendar() } // setupWorker(), checkRoot() 등은 이미 호출됨 } private fun updateTideButtonVisibility() { val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val showTide = prefs.getBoolean("show_tide", false) val currentLoc = prefs.getString("selected_tide_location", "군산") ?: "군산" if (showTide) { binding.btnTideLocation.visibility = android.view.View.VISIBLE binding.btnTideLocation.text = currentLoc } else { binding.btnTideLocation.visibility = android.view.View.GONE } } private fun setupSwipeGesture() { gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { private val SWIPE_THRESHOLD = 100 private val SWIPE_VELOCITY_THRESHOLD = 100 override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { if (e1 == null) return false val diffX = e2.x - e1.x val diffY = e2.y - e1.y if (abs(diffX) > abs(diffY)) { if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (diffX > 0) { // Swipe Right -> Previous Month currentViewMonth = currentViewMonth.minusMonths(1) updateCalendar() } else { // Swipe Left -> Next Month currentViewMonth = currentViewMonth.plusMonths(1) updateCalendar() } return true } } return false } }) binding.calendarGrid.addOnItemTouchListener(object : androidx.recyclerview.widget.RecyclerView.OnItemTouchListener { override fun onInterceptTouchEvent(rv: androidx.recyclerview.widget.RecyclerView, e: MotionEvent): Boolean { gestureDetector.onTouchEvent(e) return false } override fun onTouchEvent(rv: androidx.recyclerview.widget.RecyclerView, e: MotionEvent) {} override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} }) binding.calendarContainer.setOnTouchListener { _, event -> gestureDetector.onTouchEvent(event) true } } override fun onResume() { super.onResume() val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A" updateTideButtonVisibility() updateCalendar() // 일원화된 통합 권한 체크 실행 (신뢰도 100% 보장) AlarmPermissionUtil.checkAndRequestAllPermissions(this) // 설정 변경 시 즉시 반영을 위한 강제 동기화 (30일 스케줄링) lifecycleScope.launch { syncAllAlarms(this@MainActivity) } // 연차 정보 업데이트 lifecycleScope.launch { val repo = ShiftRepository(this@MainActivity) val annualLeave = repo.getAnnualLeave() annualLeave?.let { binding.tvAnnualLeave.text = "연차: ${formatRemainingDays(it.remainingDays)}" } ?: run { binding.tvAnnualLeave.text = "연차: --" } } } private fun showMonthYearPicker() { val dialogView = layoutInflater.inflate(R.layout.dialog_month_year_picker, null) val yearPicker = dialogView.findViewById(R.id.yearPicker) val monthPicker = dialogView.findViewById(R.id.monthPicker) val currentYear = currentViewMonth.year val currentMonth = currentViewMonth.monthValue yearPicker.minValue = 2010 yearPicker.maxValue = 2050 yearPicker.value = currentYear yearPicker.wrapSelectorWheel = false monthPicker.minValue = 1 monthPicker.maxValue = 12 monthPicker.value = currentMonth monthPicker.displayedValues = arrayOf("1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월") val dialog = androidx.appcompat.app.AlertDialog.Builder(this, R.style.OneUI8_Dialog) .setView(dialogView) .setPositiveButton("이동") { _, _ -> currentViewMonth = YearMonth.of(yearPicker.value, monthPicker.value) updateCalendar() } .setNegativeButton("취소", null) .create() dialog.show() // 90% Screen Width for One UI 8.0 feel val width = (resources.displayMetrics.widthPixels * 0.9).toInt() dialog.window?.setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT) dialog.window?.setDimAmount(0.6f) // Darker dim to focus on popup // Style buttons to look like One UI 8.0 dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).apply { setTextColor(ContextCompat.getColor(this@MainActivity, R.color.primary)) textSize = 17f setPadding(dpToPx(32f), dpToPx(16f), dpToPx(32f), dpToPx(16f)) } dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE).apply { setTextColor(ContextCompat.getColor(this@MainActivity, R.color.text_secondary)) textSize = 17f setPadding(dpToPx(32f), dpToPx(16f), dpToPx(32f), dpToPx(16f)) } } private fun dpToPx(dp: Float): Int { return (dp * resources.displayMetrics.density).toInt() } private fun setupCalendar() { binding.calendarGrid.layoutManager = GridLayoutManager(this, 7) updateCalendar() } private fun updateCalendar() { val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val selectedTeam = prefs.getString(KEY_TEAM, "A") ?: "A" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" // Today's Shift val today = LocalDate.now(ShiftCalculator.SEOUL_ZONE) lifecycleScope.launch { val db = AppDatabase.getDatabase(this@MainActivity) val dao = db.shiftDao() val repo = ShiftRepository(this@MainActivity) // 전체 사용자 알람 로드 (일원화된 Room DB 사용) val allCustomAlarms = repo.getAllCustomAlarms() // 디스플레이 업데이트 if (currentViewTeam == selectedTeam) { val shiftForMyTeam = withContext(Dispatchers.IO) { repo.getShift(today, selectedTeam, factory) } updateAlarmTimeDisplay(today, shiftForMyTeam, factory, allCustomAlarms) binding.alarmTimeText.visibility = android.view.View.VISIBLE } else { binding.alarmTimeText.visibility = android.view.View.GONE } // Load overrides and memos for the month val monthStr = currentViewMonth.toString() val overrides = withContext(Dispatchers.IO) { dao.getOverridesForMonth(factory, currentViewTeam, monthStr).associateBy { overrideItem -> overrideItem.date } } val memos = withContext(Dispatchers.IO) { dao.getMemosForMonth(monthStr).associateBy { memoItem -> memoItem.date } } val days = generateDaysForMonthWithData(currentViewMonth, currentViewTeam, factory, overrides, memos) val adapter = CalendarAdapter(days, object : CalendarAdapter.OnDayClickListener { override fun onDayClick(date: LocalDate, currentShift: String) { showDaySettingsDialog(date, currentShift) } }, binding.cbShowHolidays.isChecked) binding.calendarGrid.adapter = adapter binding.monthTitle.text = currentViewMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")) // Update Header Status Text with Permission Warning if needed val shiftForViewingTeam = withContext(Dispatchers.IO) { repo.getShift(today, currentViewTeam, factory) } val teamSuffix = if (currentViewTeam == selectedTeam) " (내 반)" else " (${currentViewTeam}반)" if (currentViewTeam == selectedTeam && !AlarmPermissionUtil.getExactAlarmStatus(this@MainActivity)) { binding.todayStatusText.text = "⚠️ 정확한 알람 권한이 필요합니다 (설정 필요)" binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.warning_red)) } else { binding.todayStatusText.text = "오늘의 근무: $shiftForViewingTeam$teamSuffix" binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_secondary)) } // Update Annual Leave display val annualLeave = withContext(Dispatchers.IO) { repo.getAnnualLeave() } annualLeave?.let { binding.tvAnnualLeave.text = "연차: ${formatRemainingDays(it.remainingDays)}" } ?: run { binding.tvAnnualLeave.text = "연차: --" } } updateOtherTeamsLayout(today, factory, prefs) } private fun updateOtherTeamsLayout(today: LocalDate, factory: String, prefs: android.content.SharedPreferences) { val teamColors = mapOf( "A" to R.color.team_a_color, "B" to R.color.team_b_color, "C" to R.color.team_c_color, "D" to R.color.team_d_color ) val container = binding.otherTeamsContainer container.removeAllViews() val rowLayout = android.widget.LinearLayout(this).apply { orientation = android.widget.LinearLayout.HORIZONTAL layoutParams = android.widget.LinearLayout.LayoutParams( android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT ) } val allTeams = if (factory == "Nonsan") listOf("A", "B", "C") else listOf("A", "B", "C", "D") lifecycleScope.launch { for (t in allTeams) { val shift = ShiftCalculator.getShift(today, t, factory) val shortShift = when(shift) { "주간" -> "주" "석간" -> "석" "야간" -> "야" "주간 맞교대" -> "주맞" "야간 맞교대" -> "야맞" "휴무", "휴가" -> "휴" else -> shift.take(1) } val textView = android.widget.TextView(this@MainActivity).apply { text = "${t}반 ($shortShift)" setPadding(0, 24, 0, 24) textSize = 12f gravity = android.view.Gravity.CENTER setTypeface(null, android.graphics.Typeface.BOLD) if (currentViewTeam == t) { setTextColor(android.graphics.Color.WHITE) setBackgroundResource(R.drawable.bg_pill_rect_selected) } else { setTextColor(androidx.core.content.ContextCompat.getColor(context, teamColors[t]!!)) setBackgroundResource(R.drawable.bg_pill_rect_unselected) } layoutParams = android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(4, 0, 4, 0) } setOnClickListener { if (currentViewTeam != t) { currentViewTeam = t updateCalendar() showCustomToast(context, "${t}반 근무표를 표시합니다.") } } } rowLayout.addView(textView) } container.addView(rowLayout) } } private fun generateDaysForMonthWithData( month: YearMonth, team: String, factory: String, overrides: Map, memos: Map ): List { val daysInMonth = month.lengthOfMonth() val firstDayOfMonth = month.atDay(1).dayOfWeek.value % 7 val actualDayCount = firstDayOfMonth + daysInMonth val targetCells = if (actualDayCount <= 35) 35 else 42 val dayList = mutableListOf() for (i in 0 until firstDayOfMonth) { dayList.add(DayShift(null, null)) } for (day in 1..daysInMonth) { val date = month.atDay(day) val dateStr = date.toString() val shift = overrides[dateStr]?.shift ?: ShiftCalculator.getShift(date, team, factory) val memo = memos[dateStr] val hasMemo = memo != null val memoContent = memo?.content dayList.add(DayShift(date, shift, hasMemo, memoContent)) } while (dayList.size < targetCells) { dayList.add(DayShift(null, null)) } return dayList } private fun getAlarmsStrForDate(date: LocalDate, shift: String, allAlarms: List): List { val alarmTimes = mutableListOf() val isOff = shift == "휴무" || shift == "휴가" for (alarm in allAlarms) { if (alarm.isEnabled && (alarm.shiftType == "기타" || (!isOff && alarm.shiftType == shift))) { if (!alarmTimes.contains(alarm.time)) { alarmTimes.add(alarm.time) } } } alarmTimes.sort() return alarmTimes } private fun updateAlarmTimeDisplay(date: LocalDate, shift: String, factory: String, allAlarms: List) { val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) // 0. Check Master Switch if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { binding.alarmTimeText.text = "전체 알람 꺼짐" binding.alarmTimeText.visibility = android.view.View.VISIBLE binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this, R.color.shift_red)) binding.alarmTimeText.setTypeface(null, android.graphics.Typeface.BOLD) return } val todayStr = getAlarmsStrForDate(date, shift, allAlarms).firstOrNull() ?: "없음" val tomorrowDate = date.plusDays(1) val repo = ShiftRepository(this) lifecycleScope.launch(Dispatchers.IO) { val tomorrowShiftFull = repo.getShift(tomorrowDate, currentViewTeam, factory) val tomorrowAlarms = getAlarmsStrForDate(tomorrowDate, tomorrowShiftFull, allAlarms) val tomorrowStr = tomorrowAlarms.firstOrNull() ?: "없음" withContext(Dispatchers.Main) { if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { binding.alarmTimeText.text = "전체 알람 꺼짐" binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.shift_red)) return@withContext } val todayLabel = if (todayStr == "없음") "오늘: 없음" else "오늘: $todayStr" val tomorrowLabel = if (tomorrowStr == "없음") "내일: 없음" else "내일: $tomorrowStr" binding.alarmTimeText.text = "$todayLabel | $tomorrowLabel" binding.alarmTimeText.visibility = android.view.View.VISIBLE binding.alarmTimeText.setTypeface(null, android.graphics.Typeface.BOLD) binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_primary)) } } } private fun showDaySettingsDialog(date: LocalDate, currentShift: String) { val dialogView = layoutInflater.inflate(R.layout.dialog_day_settings, null) val dialog = androidx.appcompat.app.AlertDialog.Builder(this) .setView(dialogView) .create() dialog.window?.setBackgroundDrawableResource(android.R.color.transparent) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" val team = prefs.getString(KEY_TEAM, "A") ?: "A" // Set Title - One UI Style Header val titleText = dialogView.findViewById(R.id.dialogTitle) val subtitleText = dialogView.findViewById(R.id.dialogSubtitle) subtitleText.text = if (date == LocalDate.now()) "오늘의 근무" else "${date.monthValue}월 ${date.dayOfMonth}일 근무" titleText.text = "$currentShift" // Button Handlers val actionMap = mapOf( R.id.btnJu to "주간", R.id.btnJuMat to "주간 맞교대", R.id.btnSeok to "석간", R.id.btnYa to "야간", R.id.btnYaMat to "야간 맞교대", R.id.btnOff to "휴무", R.id.btnWolcha to "월차", R.id.btnYeoncha to "연차", R.id.btnBanwol to "반월", R.id.btnBannyeon to "반년", R.id.btnEdu to "교육", R.id.btnManual to "직접 입력", R.id.btnReset to "원래대로" ) fun applyStrokeStyle(viewId: Int, shiftType: String) { val view = dialogView.findViewById(viewId) val colorRes = when(shiftType) { "주간" -> R.color.shift_ju "석간" -> R.color.shift_seok "야간" -> R.color.shift_ya "주간 맞교대" -> R.color.shift_jumat "야간 맞교대" -> R.color.shift_yamat "휴무", "휴가" -> R.color.shift_off "월차", "연차" -> R.color.secondary "반월", "반년" -> R.color.shift_red "교육" -> R.color.primary "원래대로" -> R.color.text_secondary else -> R.color.shift_gray } val color = androidx.core.content.ContextCompat.getColor(this, colorRes) view.setTextColor(color) // Create Stroke Drawable Programmatically val drawable = android.graphics.drawable.GradientDrawable() drawable.shape = android.graphics.drawable.GradientDrawable.OVAL drawable.setColor(android.graphics.Color.TRANSPARENT) val density = resources.displayMetrics.density drawable.setStroke((1.5 * density).toInt(), color) view.background = drawable } actionMap.forEach { (id, type) -> applyStrokeStyle(id, type) } // Memo Handling val etMemo = dialogView.findViewById(R.id.etMemo) val repo = ShiftRepository(this) lifecycleScope.launch { val existingMemo = repo.getMemo(date) etMemo.setText(existingMemo ?: "") } for ((id, action) in actionMap) { dialogView.findViewById(id).setOnClickListener { lifecycleScope.launch { val content = etMemo.text.toString().trim() repo.setMemo(date, content) // Save memo even when shift button clicked handleDaySettingAction(date, action, team, factory) dialog.dismiss() } } } dialogView.findViewById(R.id.btnClearMemo).setOnClickListener { etMemo.setText("") lifecycleScope.launch { repo.setMemo(date, "") updateCalendar() Toast.makeText(this@MainActivity, "메모가 삭제되었습니다.", Toast.LENGTH_SHORT).show() } } dialogView.findViewById(R.id.btnClose).setOnClickListener { lifecycleScope.launch { val content = etMemo.text.toString().trim() repo.setMemo(date, content) updateCalendar() dialog.dismiss() } } dialog.show() } private suspend fun handleDaySettingAction(date: LocalDate, selected: String, team: String, factory: String) { val repo = ShiftRepository(this) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) when (selected) { "원래대로" -> { repo.clearOverride(date, team, factory) repo.setMemo(date, "") // Clear memo too on reset android.widget.Toast.makeText(this, "원래 근무로 복구되었습니다.", android.widget.Toast.LENGTH_SHORT).show() syncAllAlarms(this) updateCalendar() repo.updateRemainingAnnualLeave() } "직접 입력" -> { showCustomInputDialog(date, repo, team, factory) } "주간", "석간", "야간", "주간 맞교대", "야간 맞교대" -> { // Standard Shifts -> Override Shift WITHOUT manual time repo.setOverride(date, selected, team, factory) updateCalendar() // Alarms are handled by syncAllAlarms/CustomAlarms during updateCalendar syncAllAlarms(this) android.widget.Toast.makeText(this, "${date} [$selected]로 설정되었습니다.", android.widget.Toast.LENGTH_SHORT).show() } "휴무" -> { // Standard Off repo.setOverride(date, selected, team, factory) updateCalendar() syncAllAlarms(this) android.widget.Toast.makeText(this, "${selected}로 설정되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show() } else -> { // New Types: 월차, 연차, 반월, 반년, 교육 -> Saved as Override with no time repo.setOverride(date, selected, team, factory) // 연차 계산을 먼저 수행하고 달력 업데이트 repo.updateRemainingAnnualLeave() updateCalendar() syncAllAlarms(this) android.widget.Toast.makeText(this, "${selected}(으)로 기록되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show() } } } private fun showCustomInputDialog(date: LocalDate, repo: ShiftRepository, team: String, factory: String) { val layout = android.widget.LinearLayout(this).apply { orientation = android.widget.LinearLayout.VERTICAL setPadding(50, 40, 50, 10) } val etMemoInput = android.widget.EditText(this).apply { hint = "메모 내용 (예: 회식, 교육, 출장)" maxLines = 1 filters = arrayOf(android.text.InputFilter.LengthFilter(20)) } layout.addView(etMemoInput) androidx.appcompat.app.AlertDialog.Builder(this) .setTitle("${date.monthValue}월 ${date.dayOfMonth}일 메모 입력") .setView(layout) .setPositiveButton("확인") { _, _ -> val content = etMemoInput.text.toString().trim() if (content.isNotEmpty()) { lifecycleScope.launch { // Direct Input -> Set as Memo. // We do NOT change the shift (keep default or existing override). // If user wants to change shift, they should use the specific buttons. repo.setMemo(date, content) updateCalendar() android.widget.Toast.makeText(this@MainActivity, "메모가 저장되었습니다.", android.widget.Toast.LENGTH_SHORT).show() } } else { android.widget.Toast.makeText(this, "입력이 취소되었습니다.", android.widget.Toast.LENGTH_SHORT).show() } } .setNegativeButton("취소", null) .show() } private fun setupWorker() { val workRequest = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) .setInitialDelay(calculateDelayToMidnight(), TimeUnit.MILLISECONDS) .build() WorkManager.getInstance(this).enqueueUniquePeriodicWork( "DailyShiftCheck", ExistingPeriodicWorkPolicy.KEEP, workRequest ) } private fun calculateDelayToMidnight(): Long { val seoulZone = java.time.ZoneId.of("Asia/Seoul") val now = java.time.LocalDateTime.now(seoulZone) val midnight = now.plusDays(1).withHour(0).withMinute(0).withSecond(1) return java.time.Duration.between(now, midnight).toMillis() } private fun checkRoot() { if (RootUtil.isDeviceRooted()) { Toast.makeText(this, "⚠️ 루팅된 기기에서 시각적 오류나 알람 불안정이 발생할 수 있습니다.", Toast.LENGTH_LONG).show() } } private fun animateMonthTransition(direction: Int) { val card = binding.calendarCard val width = card.width.toFloat() if (width == 0f) { currentViewMonth = if (direction > 0) { currentViewMonth.plusMonths(1) } else { currentViewMonth.minusMonths(1) } updateCalendar() return } card.animate() .translationX(if (direction > 0) -width else width) .alpha(0.5f) .setDuration(200) .withEndAction { currentViewMonth = if (direction > 0) { currentViewMonth.plusMonths(1) } else { currentViewMonth.minusMonths(1) } updateCalendar() card.translationX = if (direction > 0) width else -width card.animate() .translationX(0f) .alpha(1f) .setDuration(200) .start() } .start() } private fun formatRemainingDays(days: Float): String { return if (days == days.toInt().toFloat()) { // 정수인 경우 days.toInt().toString() } else { // 소숫점이 있는 경우 (0.5 등) String.format("%.1f", days) } } }