Files
ShiftRing/app/src/main/java/com/example/shiftalarm/MainActivity.kt
sanjeok77 cce9c48345 fix: 애니메이션 개선 및 APK 업데이트
- ViewPropertyAnimator 사용으로 더 부드러운 전환
- alpha 애니메이션 추가로 시각적 효과 개선
- APK URL 업데이트

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:50:38 +09:00

754 lines
31 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.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<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)
.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<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 == "휴가"
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<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 = 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<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()
}
}
}
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 {
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<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 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)
}
}
}