Initial commit - v1.1.9

This commit is contained in:
2026-02-22 12:03:04 +09:00
commit 27339dc7b7
180 changed files with 12908 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
package com.example.shiftalarm
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.enableEdgeToEdge
import com.example.shiftalarm.databinding.ActivityAlarmBinding
import androidx.core.content.ContextCompat
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.abs
class AlarmActivity : AppCompatActivity() {
private lateinit var binding: ActivityAlarmBinding
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var startX = 0f
// 5분 후 자동 스누즈
private val autoStopHandler = Handler(Looper.getMainLooper())
private val AUTO_STOP_DELAY = 5L * 60 * 1000
override fun onCreate(savedInstanceState: Bundle?) {
// 중요: 잠금 화면 위 표시 설정을 가장 먼저 적용
setupLockScreenFlags()
super.onCreate(savedInstanceState)
// ForegroundService가 실행 중이면 먼저 중지
stopService(Intent(this, AlarmForegroundService::class.java))
// Service 중지 후 약간의 지연을 두어 AudioFocus가 완전히 해제되도록 함
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
// 무시
}
enableEdgeToEdge()
binding = ActivityAlarmBinding.inflate(layoutInflater)
binding.root.background = ContextCompat.getDrawable(this, R.drawable.bg_alarm_gradient)
setContentView(binding.root)
// 추가 윈도우 플래그 설정
setupWindowFlags()
val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무"
binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람"
val now = java.util.Calendar.getInstance()
val amPm = if (now.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "오전" else "오후"
val hour = now.get(java.util.Calendar.HOUR)
val hourText = if (hour == 0) 12 else hour
val min = now.get(java.util.Calendar.MINUTE)
binding.tvCurrentTime.text = String.format("%s %d:%02d", amPm, hourText, min)
val today = LocalDate.now()
val dayOfWeek = today.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.KOREAN)
binding.tvDate.text = String.format("%d월 %d일 %s", today.monthValue, today.dayOfMonth, dayOfWeek)
// 알람 시작 (화면 상태와 무관하게 항상 실행)
startAlarm()
setupControls()
// 5분 후 자동 스누즈
autoStopHandler.postDelayed({
Toast.makeText(this, "알람이 자동으로 다시 울림 설정되었습니다.", Toast.LENGTH_LONG).show()
snoozeAlarm()
stopAlarm()
finish()
}, AUTO_STOP_DELAY)
// 키가드(잠금화면) 상태 변화 리스너 등록
registerKeyguardListener()
}
/**
* 잠금 화면 관련 플래그를 super.onCreate 이전에 설정
*/
private fun setupLockScreenFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
}
}
private fun setupWindowFlags() {
// ========================================
// 알람 화면이 패턴/지문보다 먼저 표시되도록 설정
// ========================================
// 중요: requestDismissKeyguard()를 호출하면 패턴/지문이 먼저 뜸
// 알람 화면을 먼저 띄우려면 FLAG_SHOW_WHEN_LOCKED만 사용해야 함
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
// 1. 가장 먼저: 화면 켜기 + 잠금화면 위에 표시
setShowWhenLocked(true)
setTurnScreenOn(true)
}
// 2. 화면 켜짐 유지
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// 3. 하위 호환성: Android 8.0 이하
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
// 4. Android 14+ 추가 플래그
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_OVERSCAN)
window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
// 5. Android 10+ 레이아웃
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
}
// 6. 전체화면 모드 (모든 기기 공통)
setFullscreenMode()
}
/**
* 전체화면 모드 설정
*/
private fun isSamsungDevice(): Boolean {
val manufacturer = Build.MANUFACTURER?.lowercase() ?: ""
val brand = Build.BRAND?.lowercase() ?: ""
return manufacturer.contains("samsung") || brand.contains("samsung")
}
/**
* 전체화면 모드 설정
*/
private fun setFullscreenMode() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+): WindowInsetsController 사용
window.setDecorFitsSystemWindows(false)
window.insetsController?.let { controller ->
controller.hide(android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars())
controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
// Android 10 이하: systemUiVisibility 사용
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
)
}
}
private fun setupControls() {
binding.btnSnooze.setOnClickListener {
handleSnooze()
}
// Swipe-to-dismiss for Stop Button
var startX = 0f
val dismissBtn = binding.btnDismiss
val maxSwipe = dpToPx(100f).toFloat()
dismissBtn.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = event.rawX
v.animate().cancel()
true
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - startX
val clampedDx = if (dx > 0) dx.coerceAtMost(maxSwipe) else dx.coerceAtLeast(-maxSwipe)
v.translationX = clampedDx
// Visual feedback: scale up when near trigger
val ratio = abs(clampedDx) / maxSwipe
v.scaleX = 1f + (ratio * 0.15f)
v.scaleY = 1f + (ratio * 0.15f)
v.alpha = 1f - (ratio * 0.3f)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val dx = event.rawX - startX
if (abs(dx) > maxSwipe * 0.8f) {
// Trigger Dismiss
(getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50)
Toast.makeText(this, "알람 해제 완료", Toast.LENGTH_SHORT).show()
stopAlarm(); finish()
} else {
// Reset
v.animate()
.translationX(0f)
.scaleX(1f)
.scaleY(1f)
.alpha(1f)
.setDuration(300)
.start()
}
true
}
else -> false
}
}
// Pulse logic with enhanced glow
fun startPulse() {
binding.pulseCircle.scaleX = 0.85f; binding.pulseCircle.scaleY = 0.85f; binding.pulseCircle.alpha = 0.5f
binding.pulseCircle.animate()
.scaleX(1.3f).scaleY(1.3f).alpha(1.0f)
.setDuration(1500)
.withEndAction {
binding.pulseCircle.animate()
.scaleX(0.85f).scaleY(0.85f).alpha(0.5f)
.setDuration(1500)
.withEndAction { if(!isFinishing) startPulse() }
.start()
}
.start()
}
startPulse()
}
private fun handleSnooze() {
(getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50)
val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3)
val text = if (snoozeRepeat == 99) "다시 울림 설정됨" else "다시 울림 (${snoozeRepeat}회 남음)"
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
snoozeAlarm(); stopAlarm(); finish()
}
private fun dpToPx(dp: Float): Int {
return (dp * resources.displayMetrics.density).toInt()
}
private fun startAlarm() {
if (mediaPlayer?.isPlaying == true) {
Log.d("AlarmActivity", "MediaPlayer가 이미 실행 중")
return
}
val soundUriStr = intent.getStringExtra("EXTRA_SOUND")
val alarmUri = if (!soundUriStr.isNullOrEmpty()) {
Uri.parse(soundUriStr)
} else {
val prefs = getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val globalUriStr = prefs.getString("alarm_uri", null)
if (globalUriStr != null) Uri.parse(globalUriStr)
else android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
}
// AudioAttributes 강화: 화면 켜진 상태에서도 알람음이 울리도록
val audioAttrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) // 볼륨 강제 적용
.build()
// AudioManager를 통해 알람 볼륨 설정
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_ALARM)
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM)
// 알람 볼륨을 최대로 설정 (사용자가 나중에 조정 가능)
try {
audioManager.setStreamVolume(AudioManager.STREAM_ALARM, maxVolume, 0)
} catch (e: Exception) {
Log.w("AlarmActivity", "알람 볼륨 설정 실패", e)
}
var mediaPlayerStarted = false
try {
mediaPlayer?.release()
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(audioAttrs)
setDataSource(this@AlarmActivity, alarmUri!!)
isLooping = true
setVolume(1.0f, 1.0f) // 최대 볼륨
prepare()
start()
}
mediaPlayerStarted = true
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (사용자 지정음)")
} catch (e: Exception) {
Log.e("AlarmActivity", "MediaPlayer 시작 실패 (사용자 지정음), fallback 시도", e)
// Fallback 1: 시스템 기본 알람음
try {
val fallback = android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(audioAttrs)
setDataSource(this@AlarmActivity, fallback)
isLooping = true
setVolume(1.0f, 1.0f)
prepare()
start()
}
mediaPlayerStarted = true
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 1: 시스템 기본)")
} catch (e2: Exception) {
Log.e("AlarmActivity", "Fallback 1 실패", e2)
// Fallback 2: RingtoneManager에서 기본 알람 가져오기
try {
val ringtoneUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM)
mediaPlayer = MediaPlayer().apply {
setAudioAttributes(audioAttrs)
setDataSource(this@AlarmActivity, ringtoneUri)
isLooping = true
setVolume(1.0f, 1.0f)
prepare()
start()
}
mediaPlayerStarted = true
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 2: RingtoneManager)")
} catch (e3: Exception) {
Log.e("AlarmActivity", "모든 MediaPlayer 시작 실패", e3)
}
}
}
// 진동 시작 (알람음과 독립적으로 - 알람음 실패필도 진동은 울림)
vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 1000, 500, 1000), 0)
vibrator?.vibrate(vibrationEffect)
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(longArrayOf(0, 1000, 500, 1000), 0)
}
Log.d("AlarmActivity", "진동 시작 성공")
} catch (e: Exception) {
Log.e("AlarmActivity", "진동 시작 실패", e)
}
// 알람음 시작 실패 시 토스트 메시지
if (!mediaPlayerStarted) {
Toast.makeText(this, "알람음 재생에 실패했습니다. 진동으로 알려드립니다.", Toast.LENGTH_LONG).show()
}
}
private fun snoozeAlarm() {
val snoozeMin = intent.getIntExtra("EXTRA_SNOOZE", 5)
val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3)
val soundUriStr = intent.getStringExtra("EXTRA_SOUND")
if (snoozeRepeat > 0) {
val nextRepeat = if (snoozeRepeat == 99) 99 else snoozeRepeat - 1
scheduleSnooze(this, snoozeMin, soundUriStr, nextRepeat)
} else {
Toast.makeText(this, "다시 울림 횟수를 모두 소모하여 알람을 종료합니다.", Toast.LENGTH_SHORT).show()
}
}
private fun stopAlarm() {
stopService(Intent(this, AlarmForegroundService::class.java))
try {
mediaPlayer?.let {
if (it.isPlaying) it.stop()
it.release()
}
} catch (e: Exception) {}
mediaPlayer = null
try {
vibrator?.cancel()
} catch (e: Exception) {}
vibrator = null
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
nm.cancel(1)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무"
binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람"
stopAlarm()
startAlarm()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// 화면 켜짐 및 잠금 화면 위 표시 재적용
setupWindowFlags()
}
override fun onDestroy() {
super.onDestroy()
autoStopHandler.removeCallbacksAndMessages(null)
stopAlarm()
unregisterKeyguardListener()
}
// ========================================
// 키가드(잠금화면) 상태 감지 및 알람 해제 처리
// ========================================
private var keyguardManager: KeyguardManager? = null
private var keyguardCallback: KeyguardManager.KeyguardDismissCallback? = null
/**
* 키가드(잠금화면) 상태 변화를 감지하여 알람을 적절히 처리
* 안드로이드 버전별로 다른 방식으로 처리
*/
private fun registerKeyguardListener() {
keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+: KeyguardDismissCallback 사용
keyguardCallback = object : KeyguardManager.KeyguardDismissCallback() {
override fun onDismissError() {
Log.e("AlarmActivity", "Keyguard dismiss error")
}
override fun onDismissSucceeded() {
Log.d("AlarmActivity", "Keyguard dismissed successfully - 사용자가 패턴/지문으로 해제함")
// 패턴/지문 해제 후 알람 계속 울리게 하려면 여기서 아무것도 하지 않음
// 알람을 자동으로 멈추려면: stopAlarm(); finish()
}
override fun onDismissCancelled() {
Log.d("AlarmActivity", "Keyguard dismiss cancelled")
}
}
}
}
private fun unregisterKeyguardListener() {
keyguardCallback = null
}
/**
* 현재 키가드(잠금화면)가 잠겨있는지 확인
*/
private fun isKeyguardLocked(): Boolean {
return keyguardManager?.isKeyguardLocked ?: false
}
/**
* 현재 키가드(잠금화면)가 보안 잠금(패턴/PIN/지문)을 사용하는지 확인
*/
private fun isKeyguardSecure(): Boolean {
return keyguardManager?.isKeyguardSecure ?: false
}
override fun onPause() {
super.onPause()
// 홈 버튼이나 다른 앱으로 전환 시 알람 계속 울리도록 함
// 사용자가 의도적으로 알람을 해제하지 않았으므로
Log.d("AlarmActivity", "onPause - 알람 계속 유지")
}
override fun onStop() {
super.onStop()
// 알람 화면이 백그라운드로 갔을 때
// 잠금화면이 다시 잠기면 알람을 멈추지 않고 계속 유지
Log.d("AlarmActivity", "onStop - 알람 계속 유지")
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
// 알람 화면이 다시 포커스를 받으면 전체화면 모드 재적용
setFullscreenMode()
Log.d("AlarmActivity", "Window focus regained")
}
}
}

View File

@@ -0,0 +1,24 @@
import android.util.Log
class AlarmEventLogger {
companion object {
private const val TAG = "AlarmEventLogger"
}
fun logAlarmEvent(event: String) {
val currentTime = System.currentTimeMillis()
Log.d(TAG, "Alarm Event: $event at $currentTime")
}
fun logAlarmSet(alarmId: Int, time: String) {
Log.i(TAG, "Alarm set: ID = $alarmId, Time = $time")
}
fun logAlarmTriggered(alarmId: Int) {
Log.w(TAG, "Alarm triggered: ID = $alarmId")
}
fun logAlarmCanceled(alarmId: Int) {
Log.e(TAG, "Alarm canceled: ID = $alarmId")
}
}

View File

@@ -0,0 +1,83 @@
package com.example.shiftalarm
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class AlarmForegroundService : Service() {
private val CHANNEL_ID = "SHIFT_ALARM_CHANNEL_V5"
private val NOTIFICATION_ID = 1
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val shiftType = intent?.getStringExtra("EXTRA_SHIFT") ?: "근무"
val soundUri = intent?.getStringExtra("EXTRA_SOUND")
val snoozeMin = intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5
val snoozeRepeat = intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3
// 1. 알림 채널 생성
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"교대링 알람",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "알람이 울리는 동안 표시되는 알림입니다."
setSound(null, null) // 소리는 Activity에서 재생
enableVibration(false) // 진동은 Activity에서 재생
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
// 2. 전체화면 실행을 위한 PendingIntent
val fullScreenIntent = Intent(this, AlarmActivity::class.java).apply {
putExtra("EXTRA_SHIFT", shiftType)
putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val fullScreenPendingIntent = PendingIntent.getActivity(
this,
100,
fullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// 3. 일원화된 단일 알림 생성
val contentText = if (shiftType == "SNOOZE") "다시 울림 알람입니다." else "오늘의 근무는 [$shiftType] 입니다."
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("교대링 알람 작동 중")
.setContentText(contentText)
.setSmallIcon(R.drawable.ic_alarm_blue)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setContentIntent(fullScreenPendingIntent)
.setOngoing(true)
.setAutoCancel(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
// 4. Foreground 시작
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
}

View File

@@ -0,0 +1,228 @@
package com.example.shiftalarm
import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.app.AlertDialog
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
object AlarmPermissionUtil {
/**
* 전체 권한 상태를 확인하고 필요한 경우 통합 안내 다이얼로그를 표시합니다.
*/
fun checkAndRequestAllPermissions(activity: ComponentActivity) {
val missingPermissions = mutableListOf<String>()
// 1. 알림 권한 (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
missingPermissions.add("알림 표시 (알람 울림 확인)")
}
}
// 2. 정확한 알람 권한 (Android 12+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (!am.canScheduleExactAlarms()) {
missingPermissions.add("정확한 알람 (정시에 울림 보장)")
}
}
// 3. 배터리 최적화 제외 (Android 6+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(activity.packageName)) {
missingPermissions.add("배터리 최적화 제외 (절전 모드 무시)")
}
}
// 4. 전체화면 알림 권한 (Android 14+)
if (Build.VERSION.SDK_INT >= 34) {
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (!nm.canUseFullScreenIntent()) {
missingPermissions.add("전체화면 알림 (잠금 화면에서 즉시 표시)")
}
}
if (missingPermissions.isNotEmpty()) {
showIntegratedPermissionDialog(activity, missingPermissions)
}
}
private fun showIntegratedPermissionDialog(activity: ComponentActivity, missing: List<String>) {
val message = StringBuilder("안정적인 알람 작동을 위해 아래 권한들이 필요합니다:\n\n")
missing.forEach { message.append("- $it\n") }
message.append("\n[확인]을 누르면 설정 화면으로 순차적으로 안내합니다.")
AlertDialog.Builder(activity)
.setTitle("권한 설정 안내")
.setMessage(message.toString())
.setPositiveButton("확인") { _, _ ->
startPermissionFlow(activity)
}
.setNegativeButton("나중에", null)
.setCancelable(false)
.show()
}
private fun startPermissionFlow(activity: ComponentActivity) {
// 순차적으로 가장 중요한 것부터 요청
// 1. 알림 권한 (시스템 팝업)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
return // 알림 권한 결과 콜백 이후 다음으로 넘어가도록 유도 (혹은 그냥 연달아 띄움)
}
}
// 2. 배터리 최적화 (시스템 팝업)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(activity.packageName)) {
requestBatteryOptimization(activity)
return
}
}
// 3. 정확한 알람 (설정 화면)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (!am.canScheduleExactAlarms()) {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:${activity.packageName}")
}
activity.startActivity(intent)
return
}
}
// 4. 전체화면 알림 (설정 화면)
if (Build.VERSION.SDK_INT >= 34) {
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (!nm.canUseFullScreenIntent()) {
try {
val intent = Intent("android.settings.MANAGE_APP_USE_FULL_SCREEN_INTENT").apply {
data = Uri.parse("package:${activity.packageName}")
}
activity.startActivity(intent)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${activity.packageName}")
}
activity.startActivity(intent)
}
}
}
}
fun requestBatteryOptimization(context: Context) {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e2: Exception) {}
}
}
fun requestOverlayPermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
fun requestFullScreenIntentPermission(context: Context) {
if (Build.VERSION.SDK_INT >= 34) {
try {
val intent = Intent("android.settings.MANAGE_APP_USE_FULL_SCREEN_INTENT").apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}
fun isAllPermissionsGranted(context: Context): Boolean {
var allGranted = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
allGranted = allGranted && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
allGranted = allGranted && am.canScheduleExactAlarms()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
allGranted = allGranted && pm.isIgnoringBatteryOptimizations(context.packageName)
allGranted = allGranted && Settings.canDrawOverlays(context)
}
if (Build.VERSION.SDK_INT >= 34) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
allGranted = allGranted && nm.canUseFullScreenIntent()
}
return allGranted
}
fun getBatteryOptimizationStatus(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
return true
}
fun getExactAlarmStatus(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
return am.canScheduleExactAlarms()
}
return true
}
fun getOverlayStatus(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return Settings.canDrawOverlays(context)
}
return true
}
fun getFullScreenIntentStatus(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= 34) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return nm.canUseFullScreenIntent()
}
return true
}
}

View File

@@ -0,0 +1,103 @@
package com.example.shiftalarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import android.util.Log
import kotlinx.coroutines.*
class AlarmReceiver : BroadcastReceiver() {
private val TAG = "AlarmReceiver"
override fun onReceive(context: Context, intent: Intent?) {
Log.d(TAG, "===== 알람 수신 (Receiver) =====")
val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1
val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false
// 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결)
if (isCustom && alarmId != -1) {
val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1)
if (customAlarmId != -1) {
// 비동기로 DB 확인
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled }
if (!alarmExists) {
Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)")
scope.cancel()
return@launch
}
// 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작
startAlarm(context, intent)
scope.cancel()
}
return
}
}
// 일반 알람은 바로 직접 실행
startAlarm(context, intent)
}
private fun startAlarm(context: Context, intent: Intent?) {
// WakeLock 획득 (화면 켜기 및 Activity 실행 보장)
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"ShiftAlarm::AlarmWakeLock"
)
wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간
try {
// 1. Foreground Service 시작 (알림 표시 및 시스템에 알람 실행 중 알림)
val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
// 2. AlarmActivity 직접 실행 (알람 화면 표시)
val activityIntent = Intent(context, AlarmActivity::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
// 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 기존 인스턴스 재사용 및 최상위로 가져오기
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// 잠금 화면 위에 표시
addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT)
// 화면 켜기
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
}
context.startActivity(activityIntent)
Log.d(TAG, "AlarmActivity 실행 완료")
} catch (e: Exception) {
Log.e(TAG, "알람 실행 실패", e)
} finally {
// WakeLock은 Activity가 화면을 켜고 나서 해제
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (wakeLock.isHeld) wakeLock.release()
}, 5000)
}
}
}

View File

@@ -0,0 +1,299 @@
package com.example.shiftalarm
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
/**
* 알람 동기화 관리자
* DB와 AlarmManager 간의 실시간 동기화를 보장합니다.
*
* 동기화 전략:
* 1. DB 작업과 AlarmManager 작업을 원자적으로 처리
* 2. 실패 시 롤백 메커니즘 제공
* 3. 동기화 상태 추적 및 재시도
*/
object AlarmSyncManager {
private const val TAG = "AlarmSyncManager"
private const val PREFS_NAME = "AlarmSyncPrefs"
/**
* 알람 추가 동기화
* DB에 추가 후 AlarmManager에 즉시 예약
*/
suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try {
val repo = ShiftRepository(context)
// 1. DB에 알람 추가
val alarmId = repo.addCustomAlarm(alarm)
Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId")
// 2. AlarmManager에 예약
val today = LocalDate.now(SEOUL_ZONE)
val customAlarms = repo.getAllCustomAlarms()
val addedAlarm = customAlarms.find { it.id == alarmId.toInt() }
if (addedAlarm == null) {
Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId")
return@withContext Result.failure(Exception("알람을 찾을 수 없습니다"))
}
if (addedAlarm.isEnabled) {
// 향후 30일치 예약
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (addedAlarm.shiftType == "기타" || addedAlarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
addedAlarm.id,
addedAlarm.shiftType,
addedAlarm.time,
addedAlarm.soundUri,
addedAlarm.snoozeInterval,
addedAlarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 AlarmManager 예약 완료: ID=$alarmId")
}
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_add_alarm", System.currentTimeMillis())
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 추가 동기화 실패", e)
Result.failure(e)
}
}
/**
* 알람 수정 동기화
* DB 수정 후 기존 AlarmManager 예약 취소 후 재예약
*/
suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try {
val repo = ShiftRepository(context)
// 1. 기존 AlarmManager 예약 취소
cancelAllCustomAlarmSchedules(context, alarm.id)
Log.d(TAG, "기존 알람 예약 취소 완료: ID=${alarm.id}")
// 2. DB 업데이트
repo.updateCustomAlarm(alarm)
Log.d(TAG, "알람 DB 업데이트 완료: ID=${alarm.id}")
// 3. 활성화된 알람이면 재예약
if (alarm.isEnabled) {
val today = LocalDate.now(SEOUL_ZONE)
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}")
}
// 4. 동기화 상태 저장
saveSyncStatus(context, "last_update_alarm", System.currentTimeMillis())
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 수정 동기화 실패", e)
Result.failure(e)
}
}
/**
* 알람 삭제 동기화
* AlarmManager 예약 먼저 취소 후 DB에서 삭제
*/
suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try {
val repo = ShiftRepository(context)
// 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!)
cancelAllCustomAlarmSchedules(context, alarm.id)
Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}")
// 2. DB에서 삭제
repo.deleteCustomAlarm(alarm)
Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_delete_alarm", System.currentTimeMillis())
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 삭제 동기화 실패", e)
Result.failure(e)
}
}
/**
* 알람 토글 동기화 (활성화/비활성화)
*/
suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
try {
val repo = ShiftRepository(context)
val updatedAlarm = alarm.copy(isEnabled = enable)
if (enable) {
// 활성화: DB 업데이트 후 예약
repo.updateCustomAlarm(updatedAlarm)
val today = LocalDate.now(SEOUL_ZONE)
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}")
} else {
// 비활성화: 예약 취소 후 DB 업데이트
cancelAllCustomAlarmSchedules(context, alarm.id)
repo.updateCustomAlarm(updatedAlarm)
Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}")
}
saveSyncStatus(context, "last_toggle_alarm", System.currentTimeMillis())
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 토글 동기화 실패", e)
Result.failure(e)
}
}
/**
* 전체 알람 동기화 (앱 시작 시 호출)
*/
suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "전체 알람 동기화 시작")
// 1. 기존 모든 알람 취소
val repo = ShiftRepository(context)
val allAlarms = repo.getAllCustomAlarms()
for (alarm in allAlarms) {
cancelAllCustomAlarmSchedules(context, alarm.id)
}
Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}")
// 2. 활성화된 알람만 재예약
val enabledAlarms = allAlarms.filter { it.isEnabled }
val today = LocalDate.now(SEOUL_ZONE)
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
var scheduledCount = 0
for (alarm in enabledAlarms) {
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
scheduledCount++
}
}
}
Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람, ${scheduledCount}개 예약")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_full_sync", System.currentTimeMillis())
Result.success(SyncResult(
totalAlarms = allAlarms.size,
enabledAlarms = enabledAlarms.size,
scheduledAlarms = scheduledCount
))
} catch (e: Exception) {
Log.e(TAG, "전체 알람 동기화 실패", e)
Result.failure(e)
}
}
/**
* 동기화 상태 저장
*/
private fun saveSyncStatus(context: Context, key: String, timestamp: Long) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putLong(key, timestamp)
.apply()
}
/**
* 마지막 동기화 시간 확인
*/
fun getLastSyncTime(context: Context, key: String): Long {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getLong(key, 0)
}
/**
* 동기화 결과 데이터 클래스
*/
data class SyncResult(
val totalAlarms: Int,
val enabledAlarms: Int,
val scheduledAlarms: Int
)
}

View File

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

View File

@@ -0,0 +1,28 @@
package com.example.shiftalarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
class AlarmWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
syncAllAlarms(applicationContext)
Result.success()
} catch (e: Exception) {
e.printStackTrace()
Result.retry()
}
}
}

View File

@@ -0,0 +1,28 @@
package com.example.shiftalarm
import android.content.Context
import androidx.room.*
@Database(entities = [ShiftOverride::class, DailyMemo::class, CustomAlarm::class], version = 3, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun shiftDao(): ShiftDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"shift_database"
)
.fallbackToDestructiveMigration() // Simple for now
.build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,187 @@
package com.example.shiftalarm
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.FileProvider
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
object AppUpdateManager {
private const val VERSION_URL = "https://git.webpluss.net/sanjeok77/ShiftRing/raw/branch/main/version.json"
fun checkUpdate(activity: Activity, silent: Boolean = false) {
val ctx = activity.applicationContext
val versionCheckUrl = "$VERSION_URL?t=${System.currentTimeMillis()}"
Thread {
try {
val url = URL(versionCheckUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.requestMethod = "GET"
connection.useCaches = false
if (connection.responseCode == 200) {
val reader = connection.inputStream.bufferedReader()
val result = reader.readText()
reader.close()
val json = JSONObject(result)
val serverVersionName = json.getString("versionName")
val apkUrl = json.getString("apkUrl")
val changelog = json.optString("changelog", "버그 수정 및 성능 향상")
val pInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
val currentVersionName = pInfo.versionName ?: "0.0.0"
if (isNewerVersion(serverVersionName, currentVersionName)) {
activity.runOnUiThread {
showUpdateDialog(activity, serverVersionName, changelog, apkUrl)
}
} else if (!silent) {
activity.runOnUiThread {
Toast.makeText(ctx, "현재 최신 버전을 사용 중입니다. ($currentVersionName)", Toast.LENGTH_SHORT).show()
}
}
} else if (!silent) {
activity.runOnUiThread {
Toast.makeText(ctx, "서버 연결 실패", Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
e.printStackTrace()
if (!silent) {
activity.runOnUiThread {
Toast.makeText(ctx, "업데이트 확인 중 오류 발생", Toast.LENGTH_SHORT).show()
}
}
}
}.start()
}
private fun isNewerVersion(server: String, current: String): Boolean {
try {
// Clean version strings (remove non-numeric suffixes if any)
val sClean = server.split("-")[0].split(" ")[0]
val cClean = current.split("-")[0].split(" ")[0]
val sParts = sClean.split(".").map { it.filter { char -> char.isDigit() }.let { p -> if (p.isEmpty()) 0 else p.toInt() } }
val cParts = cClean.split(".").map { it.filter { char -> char.isDigit() }.let { p -> if (p.isEmpty()) 0 else p.toInt() } }
val length = Math.max(sParts.size, cParts.size)
for (i in 0 until length) {
val s = if (i < sParts.size) sParts[i] else 0
val c = if (i < cParts.size) cParts[i] else 0
if (s > c) return true
if (s < c) return false
}
} catch (e: Exception) {
android.util.Log.e("AppUpdateManager", "Version comparison failed: ${e.message}")
return false
}
return false
}
private fun showUpdateDialog(activity: Activity, version: String, changelog: String, apkUrl: String) {
com.google.android.material.dialog.MaterialAlertDialogBuilder(activity)
.setTitle("새로운 업데이트 발견 (v$version)")
.setMessage("업데이트 내용:\n$changelog\n\n지금 다운로드하시겠습니까?")
.setPositiveButton("다운로드") { _, _ ->
downloadAndInstallApk(activity, apkUrl, version)
}
.setNegativeButton("나중에", null)
.show()
}
private fun downloadAndInstallApk(activity: Activity, apkUrl: String, version: String) {
val progressDialog = ProgressDialog(activity).apply {
setTitle("업데이트 다운로드 중")
setMessage("v$version 다운로드 중...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
setCancelable(false)
max = 100
show()
}
Thread {
try {
val url = URL(apkUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 15000
connection.readTimeout = 15000
connection.requestMethod = "GET"
connection.connect()
val fileLength = connection.contentLength
val inputStream = BufferedInputStream(connection.inputStream)
val apkFile = File(activity.cacheDir, "update.apk")
val outputStream = FileOutputStream(apkFile)
val buffer = ByteArray(8192)
var total: Long = 0
var count: Int
while (inputStream.read(buffer).also { count = it } != -1) {
total += count
outputStream.write(buffer, 0, count)
if (fileLength > 0) {
val progress = (total * 100 / fileLength).toInt()
activity.runOnUiThread {
progressDialog.progress = progress
}
}
}
outputStream.flush()
outputStream.close()
inputStream.close()
connection.disconnect()
activity.runOnUiThread {
progressDialog.dismiss()
installApk(activity, apkFile)
}
} catch (e: Exception) {
e.printStackTrace()
activity.runOnUiThread {
progressDialog.dismiss()
Toast.makeText(activity, "다운로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}.start()
}
private fun installApk(activity: Activity, apkFile: File) {
try {
val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
FileProvider.getUriForFile(activity, "${activity.packageName}.provider", apkFile)
} else {
Uri.fromFile(apkFile)
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
activity.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(activity, "설치 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}

View File

@@ -0,0 +1,178 @@
package com.example.shiftalarm
import android.content.Context
import android.net.Uri
import org.json.JSONArray
import org.json.JSONObject
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Handles data backup and restoration (Database + SharedPreferences).
* Format: JSON
*/
object BackupManager {
suspend fun backupData(context: Context, uri: Uri, dao: ShiftDao) = withContext(Dispatchers.IO) {
val overrides = dao.getAllOverrides()
val memos = dao.getAllMemos()
val json = JSONObject()
// 1. Backup Overrides
val overrideArray = JSONArray()
overrides.forEach {
overrideArray.put(JSONObject().apply {
put("date", it.date)
put("shift", it.shift)
put("team", it.team)
put("factory", it.factory)
})
}
json.put("overrides", overrideArray)
// 1.5 Backup Custom Alarms
val customAlarms = dao.getAllCustomAlarms()
val customAlarmArray = JSONArray()
customAlarms.forEach {
customAlarmArray.put(JSONObject().apply {
put("time", it.time)
put("shiftType", it.shiftType)
put("isEnabled", it.isEnabled)
put("soundUri", it.soundUri)
put("snoozeInterval", it.snoozeInterval)
put("snoozeRepeat", it.snoozeRepeat)
})
}
json.put("custom_alarms_v2", customAlarmArray)
// 2. Backup Memos
val memoArray = JSONArray()
memos.forEach {
memoArray.put(JSONObject().apply {
put("date", it.date)
put("content", it.content)
})
}
json.put("memos", memoArray)
// 3. Backup Settings (SharedPreferences)
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val settings = JSONObject()
prefs.all.forEach { (key, value) ->
if (value is String) settings.put(key, value)
else if (value is Boolean) settings.put(key, value)
else if (value is Int) settings.put(key, value)
else if (value is Float) settings.put(key, value.toDouble())
else if (value is Long) settings.put(key, value)
else if (value is Double) settings.put(key, value)
}
json.put("settings", settings)
json.put("magic", "SHIFTRING_BACKUP_V3")
json.put("timestamp", System.currentTimeMillis())
val finalString = json.toString()
val encodedBytes = android.util.Base64.encode(finalString.toByteArray(), android.util.Base64.DEFAULT)
context.contentResolver.openOutputStream(uri)?.use { os ->
os.write(encodedBytes)
}
}
suspend fun restoreData(context: Context, uri: Uri, dao: ShiftDao) = withContext(Dispatchers.IO) {
val bytes = context.contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: throw Exception("Failed to read file")
var content = ""
try {
// Try Base64 first (V3)
val decodedBytes = android.util.Base64.decode(bytes, android.util.Base64.DEFAULT)
content = String(decodedBytes)
} catch (e: Exception) {
// Fallback to plain text (V1/V2)
content = String(bytes)
}
val json = JSONObject(content)
val magic = json.optString("magic", "")
if (magic != "SHIFTRING_BACKUP_V1" && magic != "SHIFTRING_BACKUP_V2" && magic != "SHIFTRING_BACKUP_V3") {
throw Exception("올바르지 않은 백업 파일 형식입니다.")
}
// 1. Restore Settings FIRST
if (json.has("settings")) {
val settings = json.getJSONObject("settings")
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE).edit()
prefs.clear()
val keys = settings.keys()
while(keys.hasNext()) {
val key = keys.next()
if (settings.isNull(key)) continue
val value = settings.get(key)
when(value) {
is Boolean -> prefs.putBoolean(key, value)
is Int -> prefs.putInt(key, value)
is String -> prefs.putString(key, value)
is Double -> prefs.putFloat(key, value.toFloat())
is Long -> prefs.putLong(key, value)
}
}
prefs.apply()
}
val restoredPrefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val fallbackFactory = restoredPrefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val fallbackTeam = restoredPrefs.getString("selected_team", "A") ?: "A"
// 2. Restore Overrides
if (json.has("overrides")) {
dao.clearOverrides()
val arr = json.getJSONArray("overrides")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
dao.insertOverride(ShiftOverride(
factory = obj.optString("factory", fallbackFactory),
team = obj.optString("team", fallbackTeam),
date = obj.getString("date"),
shift = obj.getString("shift")
))
}
}
// 2.5 Restore Custom Alarms
if (json.has("custom_alarms_v2")) {
dao.clearCustomAlarms()
val arr = json.getJSONArray("custom_alarms_v2")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
dao.insertCustomAlarm(CustomAlarm(
time = obj.getString("time"),
shiftType = obj.getString("shiftType"),
isEnabled = obj.optBoolean("isEnabled", true),
soundUri = obj.optString("soundUri", null),
snoozeInterval = obj.optInt("snoozeInterval", 5),
snoozeRepeat = obj.optInt("snoozeRepeat", 3)
))
}
}
// 3. Restore Memos
if (json.has("memos")) {
dao.clearMemos()
val arr = json.getJSONArray("memos")
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
dao.insertMemo(DailyMemo(
obj.getString("date"),
obj.getString("content")
))
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.example.shiftalarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작")
// 1) 즉시 1회 실행 → 당일 알람을 바로 복구
val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build()
WorkManager.getInstance(context).enqueueUniqueWork(
"BootAlarmRestore",
androidx.work.ExistingWorkPolicy.REPLACE,
immediateWork
)
// 2) 24시간 주기 반복 워커 등록
val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"DailyShiftCheck",
androidx.work.ExistingPeriodicWorkPolicy.KEEP,
periodicWork
)
android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료")
}
}
}

View File

@@ -0,0 +1,238 @@
package com.example.shiftalarm
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import java.time.LocalDate
data class DayShift(
val date: LocalDate?,
val shift: String?,
val hasMemo: Boolean = false,
val memoContent: String? = null
)
class CalendarAdapter(
var days: List<DayShift>,
private val listener: OnDayClickListener,
var showHolidays: Boolean = true
) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() {
interface OnDayClickListener {
fun onDayClick(date: LocalDate, currentShift: String)
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.dayRoot)
val dayNumber: TextView = view.findViewById(R.id.dayNumber)
val shiftChar: TextView = view.findViewById(R.id.shiftChar)
val holidayNameSmall: TextView = view.findViewById(R.id.holidayNameSmall)
val memoIndicator: ImageView = view.findViewById(R.id.memoIndicator)
val tvTide: TextView = view.findViewById(R.id.tvTide)
val memoContent: TextView = view.findViewById(R.id.memoContent)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_day, parent, false)
return ViewHolder(view)
}
private fun dpToPx(context: Context, dp: Float): Int {
return (dp * context.resources.displayMetrics.density).toInt()
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = days[position]
val context = holder.itemView.context
if (item.date == null) {
holder.itemView.visibility = View.INVISIBLE
return
}
holder.itemView.visibility = View.VISIBLE
// Day Number
holder.dayNumber.text = item.date.dayOfMonth.toString()
// Holiday / Weekend logic
val isSunday = item.date.dayOfWeek == java.time.DayOfWeek.SUNDAY
val isSaturday = item.date.dayOfWeek == java.time.DayOfWeek.SATURDAY
val fullHolidayName = HolidayManager.getHolidayName(item.date)
val isToday = item.date == LocalDate.now()
// Day Number Color
if (fullHolidayName != null || isSunday) {
holder.dayNumber.setTextColor(Color.parseColor("#FF5252"))
} else if (isSaturday) {
holder.dayNumber.setTextColor(Color.parseColor("#448AFF"))
} else {
holder.dayNumber.setTextColor(ContextCompat.getColor(context, R.color.text_primary))
}
// Tide Display
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val showTide = prefs.getBoolean("show_tide", false)
val tideLocation = prefs.getString("selected_tide_location", "군산") ?: "군산"
if (showTide) {
val tide = HolidayManager.getTide(item.date, tideLocation)
if (tide.isNotEmpty()) {
holder.tvTide.visibility = View.VISIBLE
holder.tvTide.text = tide
} else {
holder.tvTide.visibility = View.GONE
}
} else {
holder.tvTide.visibility = View.GONE
}
// --- Shift & Holiday Display Logic ---
holder.shiftChar.background = null
holder.shiftChar.text = ""
holder.holidayNameSmall.visibility = View.GONE
holder.shiftChar.textSize = 13f
// "반월", "반년" (Half-Monthly, Half-Yearly) Special Logic
// These are overrides or specific shifts that user sets.
// User requested: "월", "년" text. Half-filled background (Red + Transparent).
// Check exact string or "startswith" if logic changed?
// Logic in adapter `getShift` might return "반월", "반년".
if (showHolidays && fullHolidayName != null) {
// Holiday Mode (Priority): Show full holiday name, no circle
holder.shiftChar.text = fullHolidayName
holder.shiftChar.setTextColor(Color.parseColor("#FF5252"))
holder.shiftChar.textSize = 10f
holder.shiftChar.background = null
} else if (item.shift != null && item.shift != "비번") {
// Shift Mode
// Handle specific "Half" cases first
if (item.shift == "반월" || item.shift == "반년") {
holder.shiftChar.text = if (item.shift == "반월") "" else ""
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.black)) // Black for contrast on Half Red/Transparent
holder.shiftChar.textSize = 13f
holder.shiftChar.background = ContextCompat.getDrawable(context, R.drawable.bg_shift_half_red)
} else {
// Standard Logic
val shiftAbbreviation = when (item.shift) {
"주간" -> ""
"석간" -> ""
"야간" -> ""
"주간 맞교대" -> "주맞"
"야간 맞교대" -> "야맞"
"휴무", "휴가" -> ""
"월차" -> ""
"연차" -> ""
"교육" -> ""
else -> item.shift.take(1)
}
holder.shiftChar.text = shiftAbbreviation
holder.shiftChar.textSize = 15f
holder.shiftChar.setTypeface(null, android.graphics.Typeface.BOLD)
val shiftColorRes = when (item.shift) {
"주간" -> R.color.shift_lemon
"석간" -> R.color.shift_seok
"야간" -> R.color.shift_ya
"주간 맞교대" -> R.color.shift_jumat
"야간 맞교대" -> R.color.shift_yamat
"휴무", "휴가", "월차", "연차" -> R.color.shift_red
"교육" -> R.color.primary
else -> R.color.text_secondary
}
val shiftColor = ContextCompat.getColor(context, shiftColorRes)
if (isToday) {
// Today: Solid Circle
val background = ContextCompat.getDrawable(context, R.drawable.bg_shift_solid_v4) as? android.graphics.drawable.GradientDrawable
background?.setColor(shiftColor)
holder.shiftChar.background = background
holder.shiftChar.backgroundTintList = null
if (item.shift == "주간" || item.shift == "석간") {
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.black))
} else {
holder.shiftChar.setTextColor(Color.WHITE)
}
} else {
// Not Today: Stroke Circle
val background = ContextCompat.getDrawable(context, R.drawable.bg_shift_stroke_v4) as? android.graphics.drawable.GradientDrawable
background?.setStroke(dpToPx(context, 1.5f), shiftColor)
background?.setColor(Color.TRANSPARENT)
holder.shiftChar.background = background
holder.shiftChar.backgroundTintList = null
holder.shiftChar.setTextColor(shiftColor)
}
}
}
// Lunar date small display if requested or just default
if (!showHolidays && fullHolidayName != null) {
holder.holidayNameSmall.visibility = View.VISIBLE
holder.holidayNameSmall.text = fullHolidayName
} else {
// Ensure visibility GONE if not needed (e.g. standard day)
holder.holidayNameSmall.visibility = View.GONE
}
// Double check: if showHolidays=true (Holiday mode), we handled it at top block.
// But if showHolidays=true and NO holiday, we show lunar date?
// User asked: "Overlap date and holiday text".
// My item_day.xml has holidayNameSmall at bottom now.
// If showHolidays=true, CalendarAdapter usually HIDES shiftChar and shows Holiday Name?
// Wait, standard logic (lines 84-91 above):
// If showHolidays && fullHolidayName != null -> shiftChar shows Name.
// If showHolidays && fullHolidayName == null -> shiftChar shows LUNAR DATE? (Old logic had this).
if (showHolidays && fullHolidayName == null) {
// Show Lunar Date in shiftChar instead of empty?
// Or shiftChar is empty, show small text?
// Previous code:
// holder.shiftChar.text = ""
// holder.holidayNameSmall.visibility = View.VISIBLE
// holder.holidayNameSmall.text = HolidayManager.getLunarDateString(item.date)
holder.shiftChar.text = HolidayManager.getLunarDateString(item.date)
holder.shiftChar.textSize = 10f
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
holder.shiftChar.background = null
}
// Memo Indicator
holder.memoIndicator.visibility = View.GONE // Hide indicator, showing text instead
if (item.hasMemo && !item.memoContent.isNullOrEmpty()) {
holder.memoContent.visibility = View.VISIBLE
holder.memoContent.text = item.memoContent
} else {
holder.memoContent.visibility = View.GONE
}
// Today Border or Highlight
if (isToday) {
holder.root.setBackgroundResource(R.drawable.bg_grid_cell_today_v4)
} else {
holder.root.setBackgroundResource(R.drawable.bg_grid_cell_v4)
}
holder.itemView.setOnClickListener {
if (item.date != null && item.shift != null) {
listener.onDayClick(item.date, item.shift)
}
}
}
override fun getItemCount(): Int = days.size
}

View File

@@ -0,0 +1,30 @@
package com.example.shiftalarm
import androidx.room.*
@Entity(tableName = "shift_overrides", primaryKeys = ["factory", "team", "date"])
data class ShiftOverride(
val factory: String,
val team: String,
val date: String, // YYYY-MM-DD
val shift: String
)
@Entity(tableName = "daily_memos")
data class DailyMemo(
@PrimaryKey
val date: String, // YYYY-MM-DD
val content: String
)
@Entity(tableName = "custom_alarms")
data class CustomAlarm(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val time: String, // HH:MM
val shiftType: String, // 주간, 석간, 야간 ... 기타
val isEnabled: Boolean = true,
val soundUri: String? = null,
val snoozeInterval: Int = 5,
val snoozeRepeat: Int = 3
)

View File

@@ -0,0 +1,218 @@
package com.example.shiftalarm
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.example.shiftalarm.databinding.FragmentSettingsAdditionalBinding
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class FragmentSettingsAdditional : Fragment() {
private var _binding: FragmentSettingsAdditionalBinding? = null
private val binding get() = _binding!!
private val PREFS_NAME = "ShiftAlarmPrefs"
private var isUserInteraction = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAdditionalBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadSettings()
setupListeners()
}
private val backupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
uri?.let {
lifecycleScope.launch {
try {
val db = AppDatabase.getDatabase(requireContext())
BackupManager.backupData(requireContext(), it, db.shiftDao())
Toast.makeText(requireContext(), "백업이 완료되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "백업 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
private val restoreLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let {
lifecycleScope.launch {
try {
val db = AppDatabase.getDatabase(requireContext())
BackupManager.restoreData(requireContext(), it, db.shiftDao())
androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle("복구 완료")
.setMessage("데이터 복구가 완료되었습니다. 변경사항을 적용하기 위해 앱을 재시작해야 합니다.")
.setPositiveButton("앱 재시작") { _, _ ->
val intent = requireContext().packageManager.getLaunchIntentForPackage(requireContext().packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
requireActivity().finish()
}
.setCancelable(false)
.show()
loadSettings()
} catch (e: Exception) {
Toast.makeText(requireContext(), "복구 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
private fun loadSettings() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Theme Spinner
val themeOptions = resources.getStringArray(R.array.theme_array)
val themeAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, themeOptions)
themeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.themeSpinner.adapter = themeAdapter
val themeMode = prefs.getInt("theme_mode", androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
val themeIndex = when(themeMode) {
androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO -> 1
androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES -> 2
else -> 0
}
binding.themeSpinner.setSelection(themeIndex)
// Tide Switch
binding.switchTide.isChecked = prefs.getBoolean("show_tide", false)
}
private fun setupListeners() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
binding.themeSpinner.setOnTouchListener { _, _ ->
isUserInteraction = true
false
}
binding.themeSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
if (!isUserInteraction) return
val themeMode = when(position) {
1 -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
2 -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
else -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
// Save and Apply
val currentMode = prefs.getInt("theme_mode", -1)
if (currentMode != themeMode) {
prefs.edit().putInt("theme_mode", themeMode).apply()
// Critical Guard: Only apply if it actually changes the global state
if (androidx.appcompat.app.AppCompatDelegate.getDefaultNightMode() != themeMode) {
androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode(themeMode)
}
}
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
}
// Tide Switch Listener (Fixed: properly saving now)
binding.switchTide.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("show_tide", isChecked).apply()
}
// Backup/Restore buttons
binding.btnBackup.setOnClickListener {
val dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmm"))
backupLauncher.launch("shiftring_backup_$dateStr.json")
}
binding.btnRestore.setOnClickListener {
restoreLauncher.launch(arrayOf("application/json"))
}
binding.btnManual.setOnClickListener {
startActivity(Intent(requireContext(), ManualActivity::class.java))
}
binding.btnNotice.setOnClickListener {
startActivity(Intent(requireContext(), NoticeActivity::class.java))
}
binding.btnShareApp.setOnClickListener {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
val context = requireContext()
val pm = context.packageManager
val appInfo = pm.getApplicationInfo(context.packageName, 0)
val apkFile = java.io.File(appInfo.sourceDir)
val cachePath = java.io.File(context.cacheDir, "apks")
cachePath.mkdirs()
val newFile = java.io.File(cachePath, "ShiftRing_Installer.apk")
apkFile.copyTo(newFile, overwrite = true)
val contentUri = androidx.core.content.FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
newFile
)
val shareIntent = Intent(Intent.ACTION_SEND)
shareIntent.type = "application/vnd.android.package-archive"
shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri)
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
startActivity(Intent.createChooser(shareIntent, "앱 설치 파일 공유하기"))
}
} catch (e: Exception) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
Toast.makeText(requireContext(), "공유 실패: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
binding.btnResetOverrides.setOnClickListener {
androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle("데이터 초기화")
.setMessage("달력에서 개별적으로 바꾼 모든 근무와 알람 설정이 삭제됩니다. 계속하시겠습니까?")
.setPositiveButton("초기화") { _, _ ->
lifecycleScope.launch {
try {
val db = AppDatabase.getDatabase(requireContext())
val dao = db.shiftDao()
dao.clearOverrides()
// Immediately re-sync all alarms
syncAllAlarms(requireContext())
Toast.makeText(requireContext(), "모든 개별 설정이 삭제되고 알람이 재설정되었습니다.", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(requireContext(), "초기화 실패: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
.setNegativeButton("취소", null)
.show()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,574 @@
package com.example.shiftalarm
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.TimePicker
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.google.android.material.materialswitch.MaterialSwitch
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.example.shiftalarm.databinding.FragmentSettingsAlarmBinding
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDate
class FragmentSettingsAlarm : Fragment(), SharedPreferences.OnSharedPreferenceChangeListener {
private var _binding: FragmentSettingsAlarmBinding? = null
private val binding get() = _binding!!
private val PREFS_NAME = "ShiftAlarmPrefs"
private lateinit var repository: ShiftRepository
private var customAlarms: MutableList<CustomAlarm> = mutableListOf()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsAlarmBinding.inflate(inflater, container, false)
repository = ShiftRepository(requireContext())
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.registerOnSharedPreferenceChangeListener(this)
setupListeners()
loadSettings()
}
override fun onResume() {
super.onResume()
refreshAlarmList()
}
override fun onDestroyView() {
super.onDestroyView()
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.unregisterOnSharedPreferenceChangeListener(this)
_binding = null
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == "master_alarm_enabled") {
sharedPreferences?.let {
updateMasterToggleUI(ShiftAlarmDefaults.isMasterAlarmEnabled(it))
}
}
}
private fun loadSettings() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Master Toggle Button State
updateMasterToggleUI(ShiftAlarmDefaults.isMasterAlarmEnabled(prefs))
// Migrate and Refresh
lifecycleScope.launch {
migrateFromPrefsIfNecessary()
refreshAlarmList()
}
}
private suspend fun migrateFromPrefsIfNecessary() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val legacyJson = prefs.getString("custom_alarms", null)
if (legacyJson != null) {
try {
val arr = JSONArray(legacyJson)
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
val alarm = CustomAlarm(
time = obj.getString("time"),
shiftType = obj.getString("shiftType"),
isEnabled = obj.optBoolean("enabled", true),
soundUri = obj.optString("soundUri", null),
snoozeInterval = obj.optInt("snoozeInterval", 5),
snoozeRepeat = obj.optInt("snoozeRepeat", 3)
)
repository.addCustomAlarm(alarm)
}
// Clear legacy data
prefs.edit().remove("custom_alarms").apply()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun refreshAlarmList() {
lifecycleScope.launch {
customAlarms = repository.getAllCustomAlarms().toMutableList()
refreshUI()
}
}
private val soundTitleCache = mutableMapOf<String?, String>()
private fun updateMasterToggleUI(isEnabled: Boolean) {
if (isEnabled) {
binding.tvMasterStatus.text = "전체 알람 켜짐"
binding.tvMasterStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.primary))
binding.tvMasterStatus.backgroundTintList = android.content.res.ColorStateList.valueOf(Color.parseColor("#E3F2FD"))
} else {
binding.tvMasterStatus.text = "전체 알람 꺼짐"
binding.tvMasterStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.shift_red))
binding.tvMasterStatus.backgroundTintList = android.content.res.ColorStateList.valueOf(Color.parseColor("#FFEBEE"))
}
}
private fun setupListeners() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
binding.tvMasterStatus.setOnClickListener {
val isEnabled = !ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)
prefs.edit().putBoolean("master_alarm_enabled", isEnabled).apply()
updateMasterToggleUI(isEnabled)
val message = if (isEnabled) "전체 알람이 켜졌습니다." else "전체 알람이 꺼졌습니다."
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
// Resync immediately
lifecycleScope.launch { syncAllAlarms(requireContext()) }
}
binding.btnAddCustomAlarm.setOnClickListener {
showEditDialog(
title = "새 알람 추가",
currentTime = "07:00",
shiftType = "주간",
existingAlarm = null,
isNew = true
)
}
binding.btnTestAlarm.setOnClickListener {
scheduleTestAlarm(requireContext())
}
}
private fun refreshUI() {
val container = binding.alarmListContainer
container.removeAllViews()
for (alarm in customAlarms) {
val item = createAlarmRow(alarm.shiftType, alarm.time, alarm.isEnabled, isCustom = true, snoozeMin = alarm.snoozeInterval, snoozeRepeat = alarm.snoozeRepeat, soundUri = alarm.soundUri) { isToggle, isLongOrShort ->
if (isToggle) {
// AlarmSyncManager를 사용하여 토글 동기화
lifecycleScope.launch {
val enable = !alarm.isEnabled
val result = AlarmSyncManager.toggleAlarm(requireContext(), alarm, enable)
if (result.isSuccess) {
Log.d("ShiftAlarm", "알람 토글 동기화 성공: ID=${alarm.id}, enabled=$enable")
} else {
Log.e("ShiftAlarm", "알람 토글 동기화 실패", result.exceptionOrNull())
Toast.makeText(requireContext(), "알람 상태 변경 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
}
refreshAlarmList()
}
} else {
showEditDialog("사용자 알람", alarm.time, alarm.shiftType, existingAlarm = alarm, isNew = false)
}
}
container.addView(item)
}
}
private fun createAlarmRow(
shiftName: String,
time: String,
isEnabled: Boolean,
isCustom: Boolean,
snoozeMin: Int,
snoozeRepeat: Int,
soundUri: String?,
onAction: (isToggle: Boolean, isLongClick: Boolean) -> Unit
): View {
val view = layoutInflater.inflate(R.layout.item_alarm_unified, binding.alarmListContainer, false)
view.isFocusable = true
val shiftIndicator = view.findViewById<TextView>(R.id.shiftIndicator)
val tvTime = view.findViewById<TextView>(R.id.tvTime)
val tvAmPm = view.findViewById<TextView>(R.id.tvAmPm)
val tvSummary = view.findViewById<TextView>(R.id.tvSummary)
val alarmSwitch = view.findViewById<MaterialSwitch>(R.id.alarmSwitch)
val layoutAlarmSwitch = view.findViewById<View>(R.id.layoutAlarmSwitch)
val shortName = when(shiftName) {
"주간" -> ""
"석간" -> ""
"야간" -> ""
"주간 맞교대" -> "주맞"
"야간 맞교대" -> "야맞"
"기타" -> "기타"
else -> shiftName.take(1)
}
shiftIndicator.text = shortName
val colorRes = when(shiftName) {
"주간" -> R.color.shift_lemon
"석간" -> R.color.shift_seok
"야간" -> R.color.shift_ya
"주간 맞교대" -> R.color.shift_jumat
"야간 맞교대" -> R.color.shift_yamat
else -> R.color.shift_gray
}
val context = requireContext()
val color = ContextCompat.getColor(context, colorRes)
val drawable = ContextCompat.getDrawable(context, R.drawable.bg_shift_stroke_v4) as android.graphics.drawable.GradientDrawable
drawable.mutate()
drawable.setStroke(dpToPx(2.5f), color)
shiftIndicator.background = drawable
shiftIndicator.setTextColor(color)
try {
val parts = time.split(":")
val h24 = parts[0].toInt()
val m = parts[1].toInt()
val h12 = if (h24 % 12 == 0) 12 else h24 % 12
tvTime.text = String.format("%02d:%02d", h12, m)
tvAmPm.text = if (h24 < 12) "오전" else "오후"
if (!isEnabled) {
tvTime.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
tvAmPm.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
tvSummary.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
shiftIndicator.alpha = 0.4f
} else {
tvTime.setTextColor(ContextCompat.getColor(context, R.color.text_primary))
tvAmPm.setTextColor(ContextCompat.getColor(context, R.color.text_secondary))
tvSummary.setTextColor(ContextCompat.getColor(context, R.color.primary))
shiftIndicator.alpha = 1.0f
}
} catch (e: Exception) { tvTime.text = time }
val tvSoundNameView = view.findViewById<TextView>(R.id.tvSoundName)
val soundName = getSoundTitle(context, soundUri)
tvSummary.text = "${snoozeMin}분 간격, ${if(snoozeRepeat == 99) "계속" else snoozeRepeat.toString() + "회"}"
tvSoundNameView.text = soundName
val rowContents = view.findViewById<View>(R.id.rowContents)
rowContents.setOnClickListener { onAction(false, false) }
rowContents.setOnLongClickListener { onAction(false, true); true }
alarmSwitch.isChecked = isEnabled
layoutAlarmSwitch.setOnClickListener {
// onAction will handle the data update and re-sync
onAction(true, false)
}
return view
}
private var currentDialogSoundUri: String? = null
private var tvSoundNameReference: android.widget.TextView? = null
/**
* 새 알람 추가 시 기본음으로 시스템 알람음 설정
* 무음 문제 해결을 위해 반드시 시스템 기본 알람음을 반환
*/
private fun getDefaultAlarmUri(context: Context): String {
// 1. 시스템 기본 알람음 (가장 우선)
val defaultUri = android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
if (defaultUri != null) {
Log.d("ShiftAlarm", "시스템 기본 알람음 URI: $defaultUri")
return defaultUri.toString()
}
// 2. RingtoneManager에서 알람 타입 기본값 가져오기
val fallbackUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM)
if (fallbackUri != null) {
Log.d("ShiftAlarm", "Fallback 알람음 URI: $fallbackUri")
return fallbackUri.toString()
}
// 3. 마지막 fallback: 알림음이라도 사용
val notificationUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION)
if (notificationUri != null) {
Log.w("ShiftAlarm", "알람음 없음, 알림음 사용: $notificationUri")
return notificationUri.toString()
}
// 4. 최후의 수단: 벨소리
val ringtoneUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_RINGTONE)
if (ringtoneUri != null) {
Log.w("ShiftAlarm", "알림음 없음, 벨소리 사용: $ringtoneUri")
return ringtoneUri.toString()
}
// 이 경우는 거의 없지만, 안전장치
Log.e("ShiftAlarm", "어떤 기본 소리도 찾을 수 없음")
return ""
}
private fun showEditDialog(
title: String, currentTime: String, shiftType: String, existingAlarm: CustomAlarm?, isNew: Boolean
) {
val dialogView = layoutInflater.inflate(R.layout.dialog_alarm_edit_spinner, null)
val dialog = AlertDialog.Builder(requireContext(), android.R.style.Theme_DeviceDefault_Light_NoActionBar_Fullscreen).setView(dialogView).create()
dialog.window?.setBackgroundDrawableResource(android.R.color.transparent)
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
val tvTitle = dialogView.findViewById<TextView>(R.id.dialogTitle)
val timePicker = dialogView.findViewById<TimePicker>(R.id.timePicker)
val tvSoundName = dialogView.findViewById<TextView>(R.id.tvSoundName)
tvSoundNameReference = tvSoundName
val btnSelectSound = dialogView.findViewById<View>(R.id.btnSelectSound)
val btnDelete = dialogView.findViewById<Button>(R.id.btnDelete)
val btnCancel = dialogView.findViewById<View>(R.id.btnCancel)
val btnSave = dialogView.findViewById<View>(R.id.btnSave)
// Initialize Values
var selectedSnooze = existingAlarm?.snoozeInterval ?: 5
var selectedRepeat = existingAlarm?.snoozeRepeat ?: 3
// 새 알람 생성 시 기본적으로 시스템 알람음 설정 (무음 문제 해결)
// 기존 알람 수정 시에도 soundUri가 비어있거나 null이면 기본값으로 설정
val existingUri = existingAlarm?.soundUri
val isExistingUriEmpty = existingUri.isNullOrEmpty() || existingUri == "null"
currentDialogSoundUri = if (isNew || isExistingUriEmpty) {
// 새 알람 또는 기존 알람의 소리가 설정되지 않은 경우: 반드시 기본 알람음으로 설정
val defaultUri = getDefaultAlarmUri(requireContext())
Log.d("ShiftAlarm", "기본 알람음 설정: $defaultUri (isNew=$isNew, isExistingUriEmpty=$isExistingUriEmpty)")
defaultUri
} else {
// 기존 알람 수정: 기존 값 유지
existingUri
}
// soundUri가 비어있는 경우 최종 안전장치
if (currentDialogSoundUri.isNullOrEmpty()) {
currentDialogSoundUri = getDefaultAlarmUri(requireContext())
Log.w("ShiftAlarm", "soundUri가 비어있어 기본값으로 재설정: $currentDialogSoundUri")
}
Log.d("ShiftAlarm", "알람 ${if (isNew) "생성" else "수정"} - 최종 soundUri: $currentDialogSoundUri")
fun updateSoundName(uriStr: String?) {
if (uriStr.isNullOrEmpty() || uriStr == "null") {
tvSoundName.text = "기본 알람음"
} else {
try {
val uri = android.net.Uri.parse(uriStr)
val ringtone = android.media.RingtoneManager.getRingtone(requireContext(), uri)
val title = ringtone?.getTitle(requireContext()) ?: "알람음"
tvSoundName.text = title
} catch (e: Exception) {
tvSoundName.text = "알람음"
}
}
}
updateSoundName(currentDialogSoundUri)
// Snooze Interval Buttons
val snoozeButtons = listOf(
dialogView.findViewById<TextView>(R.id.snooze5),
dialogView.findViewById<TextView>(R.id.snooze10),
dialogView.findViewById<TextView>(R.id.snooze15),
dialogView.findViewById<TextView>(R.id.snooze30)
)
val snoozeValues = listOf(5, 10, 15, 30)
fun updateSnoozeUI() {
snoozeButtons.forEachIndexed { i, btn ->
val isSelected = snoozeValues[i] == selectedSnooze
btn.setBackgroundResource(if (isSelected) R.drawable.bg_pill_rect_selected else R.drawable.bg_pill_rect_unselected)
btn.setTextColor(if (isSelected) ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.text_secondary))
}
}
updateSnoozeUI()
snoozeButtons.forEachIndexed { i, btn -> btn.setOnClickListener { selectedSnooze = snoozeValues[i]; updateSnoozeUI() } }
// Repeat Count Buttons
val repeatButtons = listOf(
dialogView.findViewById<TextView>(R.id.repeat3),
dialogView.findViewById<TextView>(R.id.repeat5),
dialogView.findViewById<TextView>(R.id.repeatForever)
)
val repeatValues = listOf(3, 5, 99)
fun updateRepeatUI() {
repeatButtons.forEachIndexed { i, btn ->
val isSelected = repeatValues[i] == selectedRepeat
btn.setBackgroundResource(if (isSelected) R.drawable.bg_pill_rect_selected else R.drawable.bg_pill_rect_unselected)
btn.setTextColor(if (isSelected) ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.text_secondary))
}
}
updateRepeatUI()
repeatButtons.forEachIndexed { i, btn -> btn.setOnClickListener { selectedRepeat = repeatValues[i]; updateRepeatUI() } }
val cardShift = dialogView.findViewById<View>(R.id.cardShiftSelector)
var currentShift = shiftType
cardShift.visibility = View.VISIBLE
val shiftBtns = listOf(
dialogView.findViewById<TextView>(R.id.btnShiftJu),
dialogView.findViewById<TextView>(R.id.btnShiftSeok),
dialogView.findViewById<TextView>(R.id.btnShiftYa),
dialogView.findViewById<TextView>(R.id.btnShiftYaMat),
dialogView.findViewById<TextView>(R.id.btnShiftEtc)
)
val shiftTypes = listOf("주간", "석간", "야간", "야간 맞교대", "기타")
fun updateShiftUI() {
shiftBtns.forEachIndexed { i, btn ->
val isSelected = shiftTypes[i] == currentShift
val colorRes = when(shiftTypes[i]) {
"주간" -> R.color.shift_lemon; "석간" -> R.color.shift_seok; "야간" -> R.color.shift_ya
"야간 맞교대" -> R.color.shift_yamat; else -> R.color.shift_gray
}
val color = ContextCompat.getColor(requireContext(), colorRes)
if (isSelected) {
val d = ContextCompat.getDrawable(requireContext(), R.drawable.bg_shift_circle_v4) as android.graphics.drawable.GradientDrawable
d.mutate(); d.setColor(color); btn.background = d
btn.setTextColor(if (shiftTypes[i] == "야간") ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.black))
} else {
val d = ContextCompat.getDrawable(requireContext(), R.drawable.bg_shift_stroke_v4) as android.graphics.drawable.GradientDrawable
d.mutate(); d.setStroke(dpToPx(1.5f), color); btn.background = d; btn.setTextColor(color)
}
}
}
updateShiftUI()
shiftBtns.forEachIndexed { i, btn -> btn.setOnClickListener { currentShift = shiftTypes[i]; updateShiftUI() } }
tvTitle.text = if (isNew) "새 알람 추가" else "$shiftType 알람 수정"
timePicker.setIs24HourView(false)
val parts = currentTime.split(":")
if (android.os.Build.VERSION.SDK_INT >= 23) {
timePicker.hour = parts[0].toInt(); timePicker.minute = parts[1].toInt()
} else {
timePicker.currentHour = parts[0].toInt(); timePicker.currentMinute = parts[1].toInt()
}
btnSelectSound.setOnClickListener {
val intent = Intent(android.media.RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_TYPE, android.media.RingtoneManager.TYPE_ALARM)
putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, if (currentDialogSoundUri != null) android.net.Uri.parse(currentDialogSoundUri) else null as android.net.Uri?)
}
startActivityForResult(intent, 100)
}
if (!isNew) {
btnDelete.visibility = View.VISIBLE
btnDelete.setOnClickListener {
lifecycleScope.launch {
existingAlarm?.let {
// AlarmSyncManager를 사용하여 동기화된 삭제 수행
// DB 삭제 전 AlarmManager 취소가 보장됨
val result = AlarmSyncManager.deleteAlarm(requireContext(), it)
if (result.isSuccess) {
Log.d("ShiftAlarm", "알람 삭제 동기화 성공: ID=${it.id}")
} else {
Log.e("ShiftAlarm", "알람 삭제 동기화 실패", result.exceptionOrNull())
Toast.makeText(requireContext(), "알람 삭제 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
}
}
refreshAlarmList()
dialog.dismiss()
}
}
}
btnCancel.setOnClickListener { dialog.dismiss() }
btnSave.setOnClickListener {
val h = if (android.os.Build.VERSION.SDK_INT >= 23) timePicker.hour else timePicker.currentHour
val m = if (android.os.Build.VERSION.SDK_INT >= 23) timePicker.minute else timePicker.currentMinute
val time = String.format("%02d:%02d", h, m)
lifecycleScope.launch {
if (isNew) {
val newAlarm = CustomAlarm(
time = time,
shiftType = currentShift,
isEnabled = true,
soundUri = currentDialogSoundUri,
snoozeInterval = selectedSnooze,
snoozeRepeat = selectedRepeat
)
// AlarmSyncManager를 사용하여 동기화된 추가 수행
val result = AlarmSyncManager.addAlarm(requireContext(), newAlarm)
if (result.isSuccess) {
Log.d("ShiftAlarm", "새 알람 추가 동기화 성공")
Toast.makeText(requireContext(), "알람이 추가되었습니다.", Toast.LENGTH_SHORT).show()
} else {
Log.e("ShiftAlarm", "새 알람 추가 동기화 실패", result.exceptionOrNull())
Toast.makeText(requireContext(), "알람 추가 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
}
} else {
val updated = existingAlarm!!.copy(
time = time,
shiftType = currentShift,
soundUri = currentDialogSoundUri,
snoozeInterval = selectedSnooze,
snoozeRepeat = selectedRepeat
)
// AlarmSyncManager를 사용하여 동기화된 수정 수행
val result = AlarmSyncManager.updateAlarm(requireContext(), updated)
if (result.isSuccess) {
Log.d("ShiftAlarm", "알람 수정 동기화 성공: ID=${updated.id}")
Toast.makeText(requireContext(), "알람이 수정되었습니다.", Toast.LENGTH_SHORT).show()
} else {
Log.e("ShiftAlarm", "알람 수정 동기화 실패", result.exceptionOrNull())
Toast.makeText(requireContext(), "알람 수정 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
}
}
refreshAlarmList()
dialog.dismiss()
}
}
dialog.setOnDismissListener { tvSoundNameReference = null }
dialog.show()
}
private fun dpToPx(dp: Float): Int {
return (dp * resources.displayMetrics.density).toInt()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == androidx.appcompat.app.AppCompatActivity.RESULT_OK) {
val uri = data?.getParcelableExtra<android.net.Uri>(android.media.RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
if (uri != null) {
currentDialogSoundUri = uri.toString()
try {
val ringtone = android.media.RingtoneManager.getRingtone(requireContext(), uri)
tvSoundNameReference?.text = ringtone.getTitle(requireContext())
} catch(e: Exception) {
tvSoundNameReference?.text = "사용자 지정음"
}
}
}
}
private fun getSoundTitle(context: Context, uriStr: String?): String {
if (soundTitleCache.containsKey(uriStr)) return soundTitleCache[uriStr]!!
// uriStr이 null이거나 비어있거나 "null" 문자열인 경우 기본음으로 처리
val title = if (uriStr.isNullOrEmpty() || uriStr == "null") {
"기본 알람음"
} else {
try {
val uri = android.net.Uri.parse(uriStr)
val ringtone = android.media.RingtoneManager.getRingtone(context, uri)
ringtone?.getTitle(context) ?: "알람음"
} catch (e: Exception) {
"알람음"
}
}
soundTitleCache[uriStr] = title
return title
}
}

View File

@@ -0,0 +1,268 @@
package com.example.shiftalarm
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.example.shiftalarm.databinding.FragmentSettingsBasicBinding
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import android.app.ProgressDialog
import android.net.Uri
import android.os.Build
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import android.provider.Settings
import androidx.core.content.ContextCompat
class FragmentSettingsBasic : Fragment() {
private var _binding: FragmentSettingsBasicBinding? = null
private val binding get() = _binding!!
private val PREFS_NAME = "ShiftAlarmPrefs"
private var isUserInteraction = false
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsBasicBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadSettings()
setupListeners()
}
private fun loadSettings() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Factory Spinner
setupFactorySpinner(prefs)
// Team Spinner
setupTeamSpinner(prefs)
// Version Info
try {
val pInfo = requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
binding.versionInfo.text = "Ver. ${pInfo.versionName} | 제작자: 산적이얌"
} catch (e: Exception) {
binding.versionInfo.text = "Ver. Unknown | 제작자: 산적이얌"
}
// Show/Hide Exact Alarm based on version
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
binding.btnExactAlarm.visibility = View.VISIBLE
binding.dividerExact.visibility = View.VISIBLE
} else {
binding.btnExactAlarm.visibility = View.GONE
binding.dividerExact.visibility = View.GONE
}
}
private fun setupFactorySpinner(prefs: android.content.SharedPreferences) {
val savedFactory = prefs.getString("selected_factory", "Jeonju")
val factoryIndex = if (savedFactory == "Nonsan") 1 else 0
binding.factorySpinner.setSelection(factoryIndex)
binding.factorySpinner.setOnTouchListener { _, _ ->
isUserInteraction = true
false
}
binding.factorySpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
if (!isUserInteraction) return
val isNonsan = position == 1
val factory = if (isNonsan) "Nonsan" else "Jeonju"
val currentFactory = prefs.getString("selected_factory", "Jeonju")
if (factory == currentFactory) {
// Just update team spinner without resetting times if same factory
updateTeamSpinner(isNonsan)
return
}
// Save immediately
val editor = prefs.edit()
editor.putString("selected_factory", factory)
editor.apply()
// CRUCIAL: Re-sync all alarms for the new factory
lifecycleScope.launch {
syncAllAlarms(requireContext())
Toast.makeText(requireContext(), "공장 설정이 변경되었습니다.", Toast.LENGTH_SHORT).show()
}
// Update Team Spinner logic
updateTeamSpinner(isNonsan)
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
}
}
private fun updateTeamSpinner(isNonsan: Boolean) {
val currentSelection = binding.teamSpinner.selectedItemPosition
val teamOptions = if (isNonsan) {
arrayOf("A반", "B반", "C반")
} else {
arrayOf("A반", "B반", "C반", "D반")
}
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, teamOptions)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.teamSpinner.adapter = adapter
if (currentSelection < teamOptions.size) {
binding.teamSpinner.setSelection(currentSelection)
} else {
binding.teamSpinner.setSelection(0)
if (isUserInteraction) {
Toast.makeText(requireContext(), "논산 회사는 D반이 없습니다. A반으로 설정됩니다.", Toast.LENGTH_SHORT).show()
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString("selected_team", "A").apply()
}
}
}
private fun setupTeamSpinner(prefs: android.content.SharedPreferences) {
val savedFactory = prefs.getString("selected_factory", "Jeonju")
val isNonsan = savedFactory == "Nonsan"
updateTeamSpinner(isNonsan)
val savedTeam = prefs.getString("selected_team", "A")
val teamIndex = when (savedTeam) {
"A" -> 0
"B" -> 1
"C" -> 2
"D" -> if (isNonsan) 0 else 3
else -> 0
}
binding.teamSpinner.setSelection(teamIndex)
binding.teamSpinner.setOnTouchListener { _, _ ->
isUserInteraction = true
false
}
binding.teamSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
if (!isUserInteraction) return
val selectedTeam = when(position) {
0 -> "A"
1 -> "B"
2 -> "C"
3 -> "D"
else -> "A"
}
prefs.edit().putString("selected_team", selectedTeam).apply()
// CRUCIAL: Re-sync all alarms for the new team
lifecycleScope.launch {
syncAllAlarms(requireContext())
Toast.makeText(requireContext(), "${selectedTeam}반으로 알람이 재설정되었습니다.", Toast.LENGTH_SHORT).show()
}
}
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
}
}
override fun onResume() {
super.onResume()
updatePermissionStatuses()
}
private fun updatePermissionStatuses() {
val context = requireContext()
// 1. 배터리 (Battery)
val isBatteryIgnored = AlarmPermissionUtil.getBatteryOptimizationStatus(context)
binding.tvBatteryStatus.text = if (isBatteryIgnored) "[설정 완료: 절전 예외]" else "클릭하여 '제한 없음'으로 설정하세요"
binding.tvBatteryStatus.setTextColor(ContextCompat.getColor(context, if (isBatteryIgnored) R.color.primary else R.color.shift_red))
// 2. 정확한 알람 (Exact Alarm) - Android 12+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val isExactGranted = AlarmPermissionUtil.getExactAlarmStatus(context)
binding.tvExactStatus.text = if (isExactGranted) "[설정 완료: 정밀 알람]" else "필수: 클릭하여 권한을 허용하세요"
binding.tvExactStatus.setTextColor(ContextCompat.getColor(context, if (isExactGranted) R.color.primary else R.color.shift_red))
} else {
binding.btnExactAlarm.visibility = View.GONE
binding.dividerExact.visibility = View.GONE
}
// 3. 다른 앱 위에 표시 (Overlay)
val isOverlayGranted = AlarmPermissionUtil.getOverlayStatus(context)
binding.tvOverlayStatus.text = if (isOverlayGranted) "[설정 완료: 화면 우위]" else "필수: 알람창 노출을 위해 허용하세요"
binding.tvOverlayStatus.setTextColor(ContextCompat.getColor(context, if (isOverlayGranted) R.color.primary else R.color.shift_red))
// 4. 전체화면 알림 (Full Screen Intent) - Android 14+
if (Build.VERSION.SDK_INT >= 34) {
val isFullScreenGranted = AlarmPermissionUtil.getFullScreenIntentStatus(context)
binding.tvFullScreenStatus.text = if (isFullScreenGranted) "[설정 완료: 전체화면]" else "필수: 안드로이드 14 이상 필수 설정"
binding.tvFullScreenStatus.setTextColor(ContextCompat.getColor(context, if (isFullScreenGranted) R.color.primary else R.color.shift_red))
binding.btnFullScreenIntent.visibility = View.VISIBLE
} else {
binding.btnFullScreenIntent.visibility = View.GONE
}
}
private fun setupListeners() {
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
binding.btnBatteryOptimize.setOnClickListener {
AlarmPermissionUtil.requestBatteryOptimization(requireContext())
}
binding.btnExactAlarm.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:${requireContext().packageName}")
}
startActivity(intent)
}
}
binding.btnOverlayPermission.setOnClickListener {
AlarmPermissionUtil.requestOverlayPermission(requireContext())
}
binding.btnFullScreenIntent.setOnClickListener {
AlarmPermissionUtil.requestFullScreenIntentPermission(requireContext())
}
binding.btnPermissionSettings.setOnClickListener {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${requireContext().packageName}")
}
startActivity(intent)
}
binding.btnCheckUpdate.setOnClickListener {
checkUpdate()
}
}
private fun checkUpdate() {
AppUpdateManager.checkUpdate(requireActivity(), silent = false)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,27 @@
package com.example.shiftalarm
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.shiftalarm.databinding.FragmentSettingsLabBinding
class FragmentSettingsLab : Fragment() {
private var _binding: FragmentSettingsLabBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsLabBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,194 @@
package com.example.shiftalarm
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import android.icu.util.ChineseCalendar
import android.os.Build
/**
* 대한민국 공휴일 관리자 + 물때(Tide) 계산기.
* - 양력 고정 공휴일
* - 음력 공휴일 (ICU ChineseCalendar)
* - 대체공휴일
* - 물때 (7물때식: 서해안/남해서부 기준)
*/
object HolidayManager {
private val cache = mutableMapOf<Int, Map<LocalDate, String>>()
fun getHolidayName(date: LocalDate): String? {
return getHolidaysForYear(date.year)[date]
}
fun isHoliday(date: LocalDate): Boolean {
return getHolidaysForYear(date.year).containsKey(date)
}
fun getHolidaysForYear(year: Int): Map<LocalDate, String> {
return cache.getOrPut(year) { generateHolidays(year) }
}
private fun generateHolidays(year: Int): Map<LocalDate, String> {
val holidays = mutableMapOf<LocalDate, String>()
addFixedHolidays(year, holidays)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
addLunarHolidays(year, holidays)
}
addSubstituteHolidays(holidays)
return holidays
}
// ── 양력 고정 공휴일 ──
private fun addFixedHolidays(year: Int, h: MutableMap<LocalDate, String>) {
h[LocalDate.of(year, 1, 1)] = "신정"
h[LocalDate.of(year, 3, 1)] = "삼일절"
h[LocalDate.of(year, 5, 5)] = "어린이날"
h[LocalDate.of(year, 6, 6)] = "현충일"
h[LocalDate.of(year, 8, 15)] = "광복절"
h[LocalDate.of(year, 10, 3)] = "개천절"
h[LocalDate.of(year, 10, 9)] = "한글날"
h[LocalDate.of(year, 12, 25)] = "성탄절"
}
// ── 음력 공휴일 ──
private fun addLunarHolidays(year: Int, h: MutableMap<LocalDate, String>) {
lunarToSolar(year, 1, 1)?.let { seolnal ->
h[seolnal.minusDays(1)] = "설날 연휴"
h[seolnal] = "설날"
h[seolnal.plusDays(1)] = "설날 연휴"
}
lunarToSolar(year, 4, 8)?.let { buddha ->
h[buddha] = "부처님오신날"
}
lunarToSolar(year, 8, 15)?.let { chuseok ->
h[chuseok.minusDays(1)] = "추석 연휴"
h[chuseok] = "추석"
h[chuseok.plusDays(1)] = "추석 연휴"
}
}
// ── 대체공휴일 (2025~ 기준) ──
private val SUBSTITUTE_ELIGIBLE = setOf(
"삼일절", "어린이날", "부처님오신날", "현충일", "광복절",
"개천절", "한글날", "성탄절",
"설날", "설날 연휴", "추석", "추석 연휴"
)
private fun addSubstituteHolidays(holidays: MutableMap<LocalDate, String>) {
val occupied = holidays.keys.toMutableSet()
val substitutes = mutableListOf<Pair<LocalDate, String>>()
for ((date, name) in holidays.entries.sortedBy { it.key }) {
if (name !in SUBSTITUTE_ELIGIBLE) continue
val dow = date.dayOfWeek
if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
var sub = date.plusDays(1)
while (sub.dayOfWeek == DayOfWeek.SATURDAY ||
sub.dayOfWeek == DayOfWeek.SUNDAY ||
sub in occupied
) {
sub = sub.plusDays(1)
}
substitutes.add(sub to "대체공휴일($name)")
occupied.add(sub)
}
}
for ((d, n) in substitutes) holidays[d] = n
}
// ── 음력 → 양력 변환 (ICU ChineseCalendar) ──
private fun lunarToSolar(gregorianYear: Int, lunarMonth: Int, lunarDay: Int): LocalDate? {
try {
val cc = ChineseCalendar()
val cal = java.util.GregorianCalendar(gregorianYear, 6, 1)
cc.timeInMillis = cal.timeInMillis
val chineseYear = cc.get(ChineseCalendar.EXTENDED_YEAR)
cc.set(ChineseCalendar.EXTENDED_YEAR, chineseYear)
cc.set(ChineseCalendar.MONTH, lunarMonth - 1)
cc.set(ChineseCalendar.DAY_OF_MONTH, lunarDay)
cc.set(ChineseCalendar.IS_LEAP_MONTH, 0)
val result = java.time.Instant.ofEpochMilli(cc.timeInMillis)
.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()
if (result.year == gregorianYear) return result
cc.set(ChineseCalendar.EXTENDED_YEAR, chineseYear + 1)
cc.set(ChineseCalendar.MONTH, lunarMonth - 1)
cc.set(ChineseCalendar.DAY_OF_MONTH, lunarDay)
cc.set(ChineseCalendar.IS_LEAP_MONTH, 0)
val result2 = java.time.Instant.ofEpochMilli(cc.timeInMillis)
.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()
return if (result2.year == gregorianYear) result2 else result
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
// ── 음력 날짜 문자열 (달력 표시용) ──
fun getLunarDateString(date: LocalDate): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
val cc = ChineseCalendar()
cc.timeInMillis = date.atStartOfDay(ZoneId.of("Asia/Seoul"))
.toInstant().toEpochMilli()
val m = cc.get(ChineseCalendar.MONTH) + 1
val d = cc.get(ChineseCalendar.DAY_OF_MONTH)
return "$m.$d"
} catch (_: Exception) {}
}
return ""
}
// ── 물때 계산 (7물때 및 8물때 고도화) ──
fun getTide(date: LocalDate, location: String = "군산"): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
try {
val cc = ChineseCalendar()
cc.timeInMillis = date.atStartOfDay(ZoneId.of("Asia/Seoul"))
.toInstant().toEpochMilli()
val d = cc.get(ChineseCalendar.DAY_OF_MONTH)
val is8Tide = location == "여수" // 여수 등 남해 일부는 8물때식 선호 경향
return if (is8Tide) {
// 8물때식 (남해/동해 기준)
when(d) {
in 1..7 -> "${d + 7}"
8 -> "조금"
9 -> "무시"
in 10..22 -> "${d - 9}"
23 -> "사리"
24 -> "조금"
25 -> "무시"
in 26..30 -> "${d - 25}"
else -> ""
}
} else {
// 7물때식 (서해/남해서부 기준: 군산, 변산, 태안 등)
when(d) {
in 1..6 -> "${d + 6}"
7 -> "13물"
8 -> "사리"
9 -> "조금"
10 -> "무시"
in 11..21 -> "${d - 10}"
22 -> "12물"
23 -> "13물"
24 -> "사리"
25 -> "조금"
26 -> "무시"
in 27..30 -> "${d - 26}"
else -> ""
}
}
} catch (_: Exception) {}
}
return ""
}
}

View File

@@ -0,0 +1,691 @@
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 {
currentViewMonth = currentViewMonth.minusMonths(1)
updateCalendar()
}
binding.monthTitle.setOnClickListener {
showMonthYearPicker()
}
binding.nextMonth.setOnClickListener {
currentViewMonth = currentViewMonth.plusMonths(1)
updateCalendar()
}
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)
}
}
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))
}
}
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()
Toast.makeText(context, "${t}반 근무표를 표시합니다.", Toast.LENGTH_SHORT).show()
}
}
}
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()
}
"직접 입력" -> {
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)
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()
}
}
}

View File

@@ -0,0 +1,60 @@
package com.example.shiftalarm
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.enableEdgeToEdge
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.shiftalarm.databinding.ActivityManualBinding
class ManualActivity : AppCompatActivity() {
private lateinit var binding: ActivityManualBinding
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivityManualBinding.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
}
setupManual()
binding.btnCloseManual.setOnClickListener {
finish()
}
}
private fun setupManual() {
try {
val versionName = try {
packageManager.getPackageInfo(packageName, 0).versionName
} catch (e: Exception) { "0.7.1" }
binding.manualVersionText.text = "교대링(Shiftring) v$versionName"
val rawContent = assets.open("MANUAL.md").bufferedReader().use { it.readText() }
// Premium Styling logic
val styledContent = rawContent
.replace(Regex("^# (.*)", RegexOption.MULTILINE), "<br><big><big><b>$1</b></big></big><br>")
.replace(Regex("^## (.*)", RegexOption.MULTILINE), "<br><br><font color='#00897B'><b>$1</b></font><br>")
.replace(Regex("^### (.*)", RegexOption.MULTILINE), "<br><br><b>$1</b><br>")
.replace(Regex("^- (.*)", RegexOption.MULTILINE), "$1")
.replace("\n", "<br>")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
binding.manualContent.text = android.text.Html.fromHtml(styledContent, android.text.Html.FROM_HTML_MODE_LEGACY)
} else {
@Suppress("DEPRECATION")
binding.manualContent.text = android.text.Html.fromHtml(styledContent)
}
} catch (e: Exception) {
binding.manualContent.text = "설명서를 불러오지 못했습니다."
}
}
}

View File

@@ -0,0 +1,137 @@
package com.example.shiftalarm
import android.os.Bundle
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.activity.enableEdgeToEdge
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.shiftalarm.databinding.ActivityNoticeBinding
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
class NoticeActivity : AppCompatActivity() {
private lateinit var binding: ActivityNoticeBinding
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivityNoticeBinding.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
}
supportActionBar?.hide()
binding.btnCloseNotice.setOnClickListener {
finish()
}
binding.noticeRecyclerView.layoutManager = LinearLayoutManager(this)
fetchChangelog()
}
private fun fetchChangelog() {
// GitHub Raw URL with cache busting
val baseUrl = "https://raw.githubusercontent.com/sanjeok77-tech/dakjaba-releases/main/CHANGELOG.md"
val urlString = "$baseUrl?t=${System.currentTimeMillis()}"
Thread {
try {
val url = URL(urlString)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.requestMethod = "GET"
connection.useCaches = false
if (connection.responseCode == 200) {
val reader = BufferedReader(InputStreamReader(connection.inputStream))
val content = reader.use { it.readText() }
runOnUiThread {
val notices = parseChangelog(content)
binding.noticeRecyclerView.adapter = NoticeAdapter(notices)
}
} else {
throw Exception("Server returned ${connection.responseCode}")
}
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
// Fallback to local asset
loadLocalChangelog()
}
}
}.start()
}
private fun loadLocalChangelog() {
try {
val content = assets.open("CHANGELOG.md").bufferedReader().use { it.readText() }
val notices = parseChangelog(content)
binding.noticeRecyclerView.adapter = NoticeAdapter(notices)
} catch (e: Exception) {
val empty = listOf(NoticeItem("데이터 로드 실패", "", "변경사항을 불러올 수 없습니다."))
binding.noticeRecyclerView.adapter = NoticeAdapter(empty)
}
}
private fun parseChangelog(content: String): List<NoticeItem> {
val notices = mutableListOf<NoticeItem>()
val lines = content.lines()
var currentVersion = ""
var currentDate = ""
var currentBody = StringBuilder()
for (line in lines) {
val trimmed = line.trim()
// Skip empty lines or horizontal rules (any amount of dashes)
if (trimmed.isEmpty() || trimmed.matches(Regex("-{2,}"))) continue
// Handle version headers like "## v0.7.3" or "## [0.7.3]"
if (trimmed.startsWith("## v") || trimmed.startsWith("## [")) {
// Save previous version if exists
if (currentVersion.isNotEmpty() && currentBody.isNotBlank()) {
notices.add(NoticeItem("v$currentVersion 업데이트 정보", currentDate, currentBody.toString().trim()))
}
// Parse new version (matches v0.7.3 or [0.7.3])
val versionMatch = Regex("v?([\\d.]+)").find(trimmed)
currentVersion = versionMatch?.groupValues?.getOrNull(1) ?: ""
val dateMatch = Regex("(\\d{4}-\\d{2}-\\d{2})").find(trimmed)
currentDate = dateMatch?.groupValues?.getOrNull(1) ?: ""
currentBody = StringBuilder()
} else if (trimmed.startsWith("- **") || trimmed.startsWith("* **")) {
// Content line with bold key
val cleaned = trimmed
.replace(Regex("^[-*]\\s*\\*\\*(.+?)\\*\\*:?\\s*"), "$1: ")
.replace("**", "")
currentBody.appendLine(cleaned)
} else if (trimmed.startsWith("-") || trimmed.startsWith("*")) {
// Regular bullet point
val cleaned = trimmed.replace(Regex("^[-*]\\s*"), "")
if (cleaned.length > 2) currentBody.appendLine(cleaned)
}
}
// Add last version
if (currentVersion.isNotEmpty() && currentBody.isNotBlank()) {
notices.add(NoticeItem("v$currentVersion 업데이트 정보", currentDate, currentBody.toString().trim()))
}
return notices.take(7)
}
}

View File

@@ -0,0 +1,30 @@
package com.example.shiftalarm
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class NoticeAdapter(private val notices: List<NoticeItem>) : RecyclerView.Adapter<NoticeAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.noticeTitle)
val date: TextView = view.findViewById(R.id.noticeDate)
val content: TextView = view.findViewById(R.id.noticeContent)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_notice, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = notices[position]
holder.title.text = item.title
holder.date.text = item.date
holder.content.text = item.content
}
override fun getItemCount(): Int = notices.size
}

View File

@@ -0,0 +1,7 @@
package com.example.shiftalarm
data class NoticeItem(
val title: String,
val date: String,
val content: String
)

View File

@@ -0,0 +1,46 @@
package com.example.shiftalarm
import java.io.File
object RootUtil {
fun isDeviceRooted(): Boolean {
return checkRootMethod1() || checkRootMethod2() || checkRootMethod3()
}
private fun checkRootMethod1(): Boolean {
val buildTags = android.os.Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
private fun checkRootMethod2(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su",
"/su/bin/su"
)
for (path in paths) {
if (File(path).exists()) return true
}
return false
}
private fun checkRootMethod3(): Boolean {
var process: Process? = null
return try {
process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
val reader = process.inputStream.bufferedReader()
reader.readLine() != null
} catch (t: Throwable) {
false
} finally {
process?.destroy()
}
}
}

View File

@@ -0,0 +1,57 @@
package com.example.shiftalarm
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.shiftalarm.databinding.ActivitySettingsBinding
import com.google.android.material.tabs.TabLayoutMediator
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
// Refresh UI smoothly
finish()
startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.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
}
val adapter = SettingsPagerAdapter(this)
binding.viewPager.adapter = adapter
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = when (position) {
0 -> getString(R.string.tab_basic)
1 -> getString(R.string.tab_alarm)
2 -> getString(R.string.tab_additional)
3 -> getString(R.string.tab_lab)
else -> "설정"
}
}.attach()
// Jump to specific tab if requested
val targetTab = intent.getIntExtra("TARGET_TAB", 0)
binding.viewPager.setCurrentItem(targetTab, false)
binding.btnSave.text = "닫기"
binding.btnSave.setOnClickListener {
finish()
}
}
}

View File

@@ -0,0 +1,20 @@
package com.example.shiftalarm
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class SettingsPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int = 4
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> FragmentSettingsBasic()
1 -> FragmentSettingsAlarm()
2 -> FragmentSettingsAdditional()
3 -> FragmentSettingsLab()
else -> FragmentSettingsBasic()
}
}
}

View File

@@ -0,0 +1,17 @@
package com.example.shiftalarm
import android.content.SharedPreferences
/**
* 알람 관련 전역 설정 및 유틸리티.
*/
object ShiftAlarmDefaults {
/**
* 마스터 알람 스위치 상태 확인.
*/
fun isMasterAlarmEnabled(prefs: SharedPreferences): Boolean {
// 기본값 TRUE
return prefs.getBoolean("master_alarm_enabled", true)
}
}

View File

@@ -0,0 +1,89 @@
package com.example.shiftalarm
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
object ShiftCalculator {
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
val BASE_DATE: LocalDate = LocalDate.of(2026, 2, 1)
// Provided list has 20 items.
// "석간 석간 석간 휴 휴" (5)
// "주간 주간 주간 주간 주간 휴 휴" (7)
// "야간 야간 야간 야간 야간 휴" (6)
// "석간 석간" (2)
// Total 20.
val cycle = listOf(
"석간", "석간", "석간", "휴무", "휴무",
"주간", "주간", "주간", "주간", "주간", "휴무", "휴무",
"야간", "야간", "야간", "야간", "야간", "휴무",
"석간", "석간"
)
val CYCLE_LENGTH = cycle.size
val TEAM_OFFSETS = mapOf(
"A" to 0,
"B" to 15,
"C" to 10,
"D" to 5
)
fun getShift(date: LocalDate, team: String, factory: String = "Jeonju"): String {
return when (factory) {
"Nonsan" -> calculateNonsanShift(date, team)
else -> calculateJeonjuShift(date, team)
}
}
private fun calculateJeonjuShift(date: LocalDate, team: String): String {
val teamOffset = TEAM_OFFSETS[team] ?: 0
val days = ChronoUnit.DAYS.between(BASE_DATE, date).toInt()
val index = Math.floorMod(days + teamOffset, CYCLE_LENGTH)
return cycle[index]
}
private fun calculateNonsanShift(date: LocalDate, team: String): String {
// Nonsan Factory Logic
// Mon-Fri: Work, Sat-Sun: Rest (Off) -> "휴무"
// Base Date: 2026-02-09 (Monday)
// Groups: A, B, C
// User Requirement (Step 145):
// Feb 9 week: Day (주간)
// Feb 16 week: Night (야간)
// Feb 23 week: Evening (석간)
// Cycle: Day -> Night -> Evening
val dayOfWeek = date.dayOfWeek.value // 1=Mon, ..., 7=Sun
if (dayOfWeek >= 6) return "휴무" // Sat, Sun is OFF
// Base Date: 2026-02-09 (Monday)
val baseDateNonsan = LocalDate.of(2026, 2, 9)
// Calculate days between Monday of the target date and base date
// To be safe for "Any date before", we align target date to its Monday
val targetMonday = date.minusDays((dayOfWeek - 1).toLong()) // Align to Monday
val daysDiff = ChronoUnit.DAYS.between(baseDateNonsan, targetMonday).toInt()
val weeksPassed = daysDiff / 7
// Rotation Pattern: 주간 -> 야간 -> 석간
// Index: 0=주간, 1=야간, 2=석간
val rotation = listOf("주간", "야간", "석간")
// Start indices for 2026-02-09 (Week 0)
// A: 주간 (0) -> Matches User Specification
// B, C: Distributed to other shifts.
// Assuming A=0(Day), B=1(Night), C=2(Evening)
val startOffset = when (team) {
"A" -> 0 // 주간
"B" -> 1 // 야간
"C" -> 2 // 석간
else -> 0
}
val currentIndex = Math.floorMod(startOffset + weeksPassed, 3)
return rotation[currentIndex]
}
}

View File

@@ -0,0 +1,60 @@
package com.example.shiftalarm
import androidx.room.*
@Dao
interface ShiftDao {
// Override Queries
@Query("SELECT * FROM shift_overrides WHERE factory = :factory AND team = :team AND date = :date")
suspend fun getOverride(factory: String, team: String, date: String): ShiftOverride?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOverride(override: ShiftOverride)
@Query("DELETE FROM shift_overrides WHERE factory = :factory AND team = :team AND date = :date")
suspend fun deleteOverride(factory: String, team: String, date: String)
@Query("SELECT * FROM shift_overrides WHERE factory = :factory AND team = :team AND date LIKE :month || '%'")
suspend fun getOverridesForMonth(factory: String, team: String, month: String): List<ShiftOverride>
@Query("SELECT * FROM shift_overrides")
suspend fun getAllOverrides(): List<ShiftOverride>
@Query("DELETE FROM shift_overrides")
suspend fun clearOverrides()
// Memo Queries
@Query("SELECT * FROM daily_memos WHERE date = :date")
suspend fun getMemo(date: String): DailyMemo?
@Query("SELECT * FROM daily_memos WHERE date LIKE :month || '%'")
suspend fun getMemosForMonth(month: String): List<DailyMemo>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMemo(memo: DailyMemo)
@Query("DELETE FROM daily_memos WHERE date = :date")
suspend fun deleteMemo(date: String)
@Query("SELECT * FROM daily_memos")
suspend fun getAllMemos(): List<DailyMemo>
@Query("DELETE FROM daily_memos")
suspend fun clearMemos()
// Custom Alarm Queries
@Query("SELECT * FROM custom_alarms ORDER BY time ASC")
suspend fun getAllCustomAlarms(): List<CustomAlarm>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCustomAlarm(alarm: CustomAlarm): Long
@Update
suspend fun updateCustomAlarm(alarm: CustomAlarm)
@Delete
suspend fun deleteCustomAlarm(alarm: CustomAlarm)
@Query("DELETE FROM custom_alarms")
suspend fun clearCustomAlarms()
}

View File

@@ -0,0 +1,60 @@
package com.example.shiftalarm
import android.content.Context
import java.time.LocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ShiftRepository(private val context: Context) {
private val db = AppDatabase.getDatabase(context)
private val dao = db.shiftDao()
suspend fun getShift(date: LocalDate, team: String, factory: String): String = withContext(Dispatchers.IO) {
val override = dao.getOverride(factory, team, date.toString())
if (override != null) {
return@withContext override.shift
}
ShiftCalculator.getShift(date, team, factory)
}
suspend fun setOverride(date: LocalDate, shift: String, team: String, factory: String) {
dao.insertOverride(ShiftOverride(factory, team, date.toString(), shift))
}
suspend fun clearOverride(date: LocalDate, team: String, factory: String) {
dao.deleteOverride(factory, team, date.toString())
}
suspend fun getMemo(date: LocalDate): String? {
return dao.getMemo(date.toString())?.content
}
suspend fun setMemo(date: LocalDate, content: String) {
if (content.isEmpty()) {
dao.deleteMemo(date.toString())
} else {
dao.insertMemo(DailyMemo(date.toString(), content))
}
}
// Custom Alarms
suspend fun getAllCustomAlarms(): List<CustomAlarm> = withContext(Dispatchers.IO) {
dao.getAllCustomAlarms()
}
suspend fun addCustomAlarm(alarm: CustomAlarm): Long = withContext(Dispatchers.IO) {
dao.insertCustomAlarm(alarm)
}
suspend fun updateCustomAlarm(alarm: CustomAlarm) = withContext(Dispatchers.IO) {
dao.updateCustomAlarm(alarm)
}
suspend fun deleteCustomAlarm(alarm: CustomAlarm) = withContext(Dispatchers.IO) {
dao.deleteCustomAlarm(alarm)
}
suspend fun clearAllCustomAlarms() = withContext(Dispatchers.IO) {
dao.clearCustomAlarms()
}
}