Files
ShiftRing/app/src/main/java/com/example/shiftalarm/MainActivity.kt
T

900 lines
37 KiB
Kotlin

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<android.widget.NumberPicker>(R.id.yearPicker)
val monthPicker = dialogView.findViewById<android.widget.NumberPicker>(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<android.widget.Button>(R.id.btnCancel).setOnClickListener {
dialog.dismiss()
}
dialogView.findViewById<android.widget.Button>(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<String, ShiftOverride>,
memos: Map<String, DailyMemo>
): List<DayShift> {
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<DayShift>()
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<CustomAlarm>): List<String> {
val alarmTimes = mutableListOf<String>()
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<CustomAlarm>) {
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<android.widget.TextView>(R.id.dialogTitle)
val subtitleText = dialogView.findViewById<android.widget.TextView>(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<android.widget.TextView>(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<android.widget.EditText>(R.id.etMemo)
val repo = ShiftRepository(this)
lifecycleScope.launch {
val existingMemo = repo.getMemo(date)
etMemo.setText(existingMemo ?: "")
}
for ((id, action) in actionMap) {
dialogView.findViewById<android.view.View>(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<android.view.View>(R.id.btnQuickSaveMemo).setOnClickListener {
saveAndCloseMemo()
Toast.makeText(this@MainActivity, "메모가 저장되었습니다.", Toast.LENGTH_SHORT).show()
}
dialogView.findViewById<android.view.View>(R.id.btnClearMemo).setOnClickListener {
etMemo.setText("")
lifecycleScope.launch {
repo.setMemo(date, "")
updateCalendar()
Toast.makeText(this@MainActivity, "메모가 삭제되었습니다.", Toast.LENGTH_SHORT).show()
}
}
dialogView.findViewById<android.view.View>(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<AlarmWorker>(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)
}
}
}