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.view.animation.AccelerateDecelerateInterpolator 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 android.graphics.Bitmap import android.graphics.Canvas import android.util.Log 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 private var calendarAdapter: CalendarAdapter? = null private var isAnimatingMonth = false 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()) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } setupCalendar() setupWorker() setupSwipeGesture() AppUpdateManager.checkUpdate(this, silent = true) 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 onDown(e: MotionEvent): Boolean { return true } 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 animateMonthTransition(-1) } else { // Swipe Left -> Next Month animateMonthTransition(1) } 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() // 통합 권한 체크: 권한 플로우가 진행 중이 아닐 때만 실행 (모달 루프 방지) if (!AlarmPermissionUtil.isPermissionFlowActive) { 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) .create() // Internal button click handlers dialogView.findViewById(R.id.btnCancel).setOnClickListener { dialog.dismiss() } dialogView.findViewById(R.id.btnConfirm).setOnClickListener { currentViewMonth = YearMonth.of(yearPicker.value, monthPicker.value) updateCalendar() dialog.dismiss() } 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) } private fun dpToPx(dp: Float): Int { return (dp * resources.displayMetrics.density).toInt() } private fun setupCalendar() { binding.calendarGrid.layoutManager = GridLayoutManager(this, 7) binding.calendarGrid.itemAnimator = null updateCalendar() } private suspend fun loadCalendarDataForMonth(targetMonth: YearMonth) { val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val selectedTeam = prefs.getString(KEY_TEAM, "A") ?: "A" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" val today = LocalDate.now(ShiftCalculator.SEOUL_ZONE) 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 = targetMonth.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(targetMonth, currentViewTeam, factory, overrides, memos) val availableHeight = binding.calendarGrid.height val calcRowHeight = if (availableHeight > 0) availableHeight / 5 else 0 if (calendarAdapter == null || binding.calendarGrid.adapter == null) { val newAdapter = CalendarAdapter(days, object : CalendarAdapter.OnDayClickListener { override fun onDayClick(date: LocalDate, currentShift: String) { showDaySettingsDialog(date, currentShift) } }, binding.cbShowHolidays.isChecked) if (calcRowHeight > 0) { newAdapter.rowHeight = calcRowHeight } calendarAdapter = newAdapter binding.calendarGrid.adapter = newAdapter } else { calendarAdapter?.let { ad -> ad.days = days ad.showHolidays = binding.cbShowHolidays.isChecked if (calcRowHeight > 0) { ad.rowHeight = calcRowHeight } ad.notifyDataSetChanged() } } binding.monthTitle.text = targetMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")) if (calcRowHeight == 0) { binding.calendarGrid.post { val h = binding.calendarGrid.height if (h > 0) { val rH = h / 5 calendarAdapter?.let { if (it.rowHeight != rH) { it.rowHeight = rH it.notifyDataSetChanged() } } } } } // 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 { val prefix = "오늘의 근무: " val fullText = "$prefix$shiftForViewingTeam$teamSuffix" val spannable = android.text.SpannableStringBuilder(fullText) val shiftColorRes = when (shiftForViewingTeam) { "주간", "주" -> 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 else -> R.color.text_primary } val shiftColor = androidx.core.content.ContextCompat.getColor(this@MainActivity, shiftColorRes) val startIndex = prefix.length val endIndex = startIndex + shiftForViewingTeam.length spannable.setSpan( android.text.style.ForegroundColorSpan(shiftColor), startIndex, endIndex, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) spannable.setSpan( android.text.style.StyleSpan(android.graphics.Typeface.BOLD), startIndex, endIndex, android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) binding.todayStatusText.text = spannable 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 = "연차: --" } // Update Shift Summary Chips (v2.2.0) val juCount = days.count { it.date != null && (it.shift == "주간" || it.shift == "주") } val seokCount = days.count { it.date != null && (it.shift == "석간" || it.shift == "석") } val yaCount = days.count { it.date != null && (it.shift == "야간" || it.shift == "야") } val monthlyOvertime = withContext(Dispatchers.IO) { repo.getMonthlyOvertimeHours(targetMonth) } binding.chipJu.text = "주간 ${juCount}일" binding.chipSeok.text = "석간 ${seokCount}일" binding.chipYa.text = "야간 ${yaCount}일" binding.chipOvertime.text = "연장 ${monthlyOvertime}h" updateOtherTeamsLayout(today, factory, prefs) } private fun updateCalendar() { lifecycleScope.launch { loadCalendarDataForMonth(currentViewMonth) } } 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 == "휴가" // 교육으로 변경된 경우 원래 근무의 알람을 표시 val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val matchShift = if (shift == "교육") { val team = prefs.getString(KEY_TEAM, "A") ?: "A" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" ShiftCalculator.getShift(date, team, factory) } else { shift } for (alarm in allAlarms) { if (alarm.isEnabled && (alarm.shiftType == "기타" || (!isOff && alarm.shiftType == matchShift))) { 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 = "오늘: $todayStr" val tomorrowLabel = "내일: $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() } } } val saveAndCloseMemo = { val content = etMemo.text.toString().trim() lifecycleScope.launch { repo.setMemo(date, content) updateCalendar() } dialog.dismiss() } dialogView.findViewById(R.id.btnQuickSaveMemo).setOnClickListener { saveAndCloseMemo() Toast.makeText(this@MainActivity, "메모가 저장되었습니다.", Toast.LENGTH_SHORT).show() } 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 { saveAndCloseMemo() } // Auto-save when dialog is dismissed (e.g., Back key or touching outside) dialog.setOnDismissListener { val content = etMemo.text.toString().trim() lifecycleScope.launch { repo.setMemo(date, content) updateCalendar() } } dialog.show() // Keyboard adjust resize dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } 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() } "교육" -> { // 교육은 원래 근무 알람을 유지 repo.setOverride(date, selected, team, factory) updateCalendar() syncAllAlarms(this) android.widget.Toast.makeText(this, "교육으로 기록되었습니다. 원래 근무 알람이 유지됩니다.", 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 animateMonthTransition(direction: Int) { if (isAnimatingMonth) return val card = binding.calendarCard val snapshot = binding.calendarOverlaySnapshot val width = card.width.toFloat() val nextMonth = if (direction > 0) { currentViewMonth.plusMonths(1) } else { currentViewMonth.minusMonths(1) } if (width == 0f) { currentViewMonth = nextMonth updateCalendar() return } isAnimatingMonth = true // 1. Capture bitmap snapshot of current calendar card try { val bitmap = Bitmap.createBitmap(card.width, card.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) card.draw(canvas) snapshot.setImageBitmap(bitmap) snapshot.visibility = android.view.View.VISIBLE snapshot.alpha = 1.0f snapshot.scaleX = 1.0f snapshot.scaleY = 1.0f snapshot.translationX = 0f } catch (e: Exception) { Log.w("MainActivity", "Failed to capture snapshot for month transition", e) } // 2. Position top card offscreen before loading card.translationX = if (direction > 0) width else -width currentViewMonth = nextMonth // 3. Load next month's data BEFORE starting slide animation to eliminate any flicker lifecycleScope.launch { loadCalendarDataForMonth(currentViewMonth) val fluidInterpolator = androidx.core.view.animation.PathInterpolatorCompat.create(0.22f, 1.0f, 0.36f, 1.0f) val duration = 280L card.animate() .translationX(0f) .setDuration(duration) .setInterpolator(fluidInterpolator) .withEndAction { snapshot.visibility = android.view.View.GONE snapshot.setImageDrawable(null) isAnimatingMonth = false } .start() snapshot.animate() .scaleX(0.94f) .scaleY(0.94f) .alpha(0.35f) .translationX(if (direction > 0) -width * 0.22f else width * 0.22f) .setDuration(duration) .setInterpolator(fluidInterpolator) .start() } } private fun formatRemainingDays(days: Float): String { return if (days == days.toInt().toFloat()) { // 정수인 경우 days.toInt().toString() } else { // 소숫점이 있는 경우 (0.5 등) String.format("%.1f", days) } } }