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

241
app/CROSS_VERSION_REPORT.md Normal file
View File

@@ -0,0 +1,241 @@
# 📱 Cross-Version Alarm Accuracy Report (Android 8.0 ~ 14)
**Period**: 2026-02-01 ~ 2026-03-31
**Scenario**: Jeonju Factory - Team C (Standard)
| Date | Shift | Alarm Time | Android Version | Simulation Result (API Behavior) |
|---|---|---|---|---|
| 2026-02-01 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-02 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-03 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-03 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-03 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-03 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-03 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-04 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-04 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-04 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-04 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-04 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-05 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-05 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-05 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-05 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-05 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-06 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-06 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-06 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-06 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-06 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-07 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-07 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-07 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-07 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-07 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-08 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-09 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-09 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-09 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-09 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-09 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-10 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-10 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-10 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-10 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-10 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-11 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-11 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-11 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-11 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-11 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-12 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-12 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-12 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-12 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-12 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-13 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-13 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-13 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-13 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-13 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-14 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-15 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-16 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-16 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-16 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-16 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-16 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-17 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-17 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-17 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-17 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-17 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-18 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-18 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-18 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-18 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-18 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-19 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-19 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-19 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-19 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-19 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-20 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-20 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-20 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-20 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-20 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-21 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-22 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-02-23 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-23 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-23 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-23 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-23 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-24 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-24 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-24 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-24 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-24 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-25 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-25 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-25 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-25 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-25 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-26 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-26 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-26 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-26 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-26 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-27 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-27 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-27 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-27 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-27 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-02-28 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-01 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-01 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-01 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-01 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-01 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-02 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-02 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-02 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-02 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-02 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-03 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-03 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-03 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-03 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-03 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-04 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-04 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-04 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-04 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-04 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-05 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-05 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-05 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-05 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-05 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-06 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-07 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-08 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-08 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-08 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-08 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-08 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-09 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-09 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-09 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-09 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-09 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-10 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-10 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-10 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-10 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-10 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-11 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-11 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-11 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-11 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-11 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-12 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-12 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-12 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-12 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-12 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-13 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-14 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-15 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-15 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-15 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-15 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-15 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-16 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-16 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-16 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-16 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-16 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-17 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-17 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-17 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-17 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-17 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-18 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-18 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-18 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-18 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-18 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-19 | 야간 | **22:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-19 | 야간 | **22:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-19 | 야간 | **22:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-19 | 야간 | **22:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-19 | 야간 | **22:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-20 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-21 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-21 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-21 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-21 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-21 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-22 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-22 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-22 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-22 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-22 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-23 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-23 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-23 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-23 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-23 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-24 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-24 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-24 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-24 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-24 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-25 | 석간 | **14:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-25 | 석간 | **14:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-25 | 석간 | **14:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-25 | 석간 | **14:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-25 | 석간 | **14:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-26 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-27 | 휴무 | - | All Versions | No Alarm Scheduled (OFF) |
| 2026-03-28 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-28 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-28 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-28 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-28 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-29 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-29 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-29 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-29 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-29 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-30 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-30 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-30 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-30 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-30 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-31 | 주간 | **06:00** | Android 8.0 (Oreo) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-31 | 주간 | **06:00** | Android 10 (Q) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: None | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-31 | 주간 | **06:00** | Android 12 (S) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-31 | 주간 | **06:00** | Android 13 (T) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |
| 2026-03-31 | 주간 | **06:00** | Android 14 (U) | Using AlarmManager.setAlarmClock (Reliable, shows icon) | Flags: FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE | Perms: SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS | Doze: Bypasses Doze Mode (Highest Priority) |

88
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,88 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = file("${project.rootDir}/keystore.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "com.example.shiftalarm"
compileSdk = 35
defaultConfig {
applicationId = "com.example.shiftalarm"
minSdk = 26
targetSdk = 35
versionCode = 1119
versionName = "1.1.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile", "../release.jks"))
storePassword = keystoreProperties.getProperty("storePassword", "dummy")
keyAlias = keystoreProperties.getProperty("keyAlias", "dummy")
keyPassword = keystoreProperties.getProperty("keyPassword", "dummy")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// WorkManager (Crucial for reliable background tasks)
val work_version = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$work_version")
// Activity & Lifecycle
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// Room Database
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
// Kotlin Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}

42
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,42 @@
# ProGuard rules for ShiftAlarm App
# 1. Android Entry Points (Names only)
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.app.backup.BackupAgent
-keep public class * extends android.preference.Preference
# 2. Room Database & Entities
-keep @androidx.room.Entity class * { *; }
-keep interface * extends androidx.room.RoomDatabase { *; }
-keep class * extends androidx.room.RoomDatabase { *; }
-keep @androidx.room.Dao interface * { *; }
-keep class * implements com.example.shiftalarm.ShiftDao { *; }
# 3. WorkManager
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# 4. ViewBinding / UI
-keep class * implements androidx.viewbinding.ViewBinding { *; }
# 5. Metadata for Stacktraces and Debugging
-keepattributes Signature, *Annotation*, EnclosingMethod, InnerClasses, SourceFile, LineNumberTable
# 6. JSON / Serialization (if any)
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# 7. Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-dontwarn kotlinx.coroutines.**
# 8. Suppress general warnings
-dontwarn javax.lang.model.element.Modifier
-dontwarn androidx.room.paging.**

191
app/simulation_result.md Normal file
View File

@@ -0,0 +1,191 @@
# ShiftRing Alarm Logic Simulation V2 (Robust)
Period: 2026-02-01 ~ 2026-03-31
### Simulation Report: User 1 (Jeonju-C Standard) (Jeonju - Team C)
| Date | Day | Shift | Alarm Time | Status | Logic Check |
|---|---|---|---|---|---|
| 2026-02-01 | Sun | 휴무 | - | OFF | OK |
| 2026-02-02 | Mon | 휴무 | - | OFF | OK |
| 2026-02-03 | Tue | 야간 | 22:00 | ON | OK |
| 2026-02-04 | Wed | 야간 | 22:00 | ON | OK |
| 2026-02-05 | Thu | 야간 | 22:00 | ON | OK |
| 2026-02-06 | Fri | 야간 | 22:00 | ON | OK |
| 2026-02-07 | Sat | 야간 | 22:00 | ON | OK |
| 2026-02-08 | Sun | 휴무 | - | OFF | OK |
| 2026-02-09 | Mon | 석간 | 14:00 | ON | OK |
| 2026-02-10 | Tue | 석간 | 14:00 | ON | OK |
| 2026-02-11 | Wed | 석간 | 14:00 | ON | OK HOLIDAY |
| 2026-02-12 | Thu | 석간 | 14:00 | ON | OK |
| 2026-02-13 | Fri | 석간 | 14:00 | ON | OK |
| 2026-02-14 | Sat | 휴무 | - | OFF | OK |
| 2026-02-15 | Sun | 휴무 | - | OFF | OK |
| 2026-02-16 | Mon | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-17 | Tue | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-18 | Wed | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-19 | Thu | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-20 | Fri | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-21 | Sat | 휴무 | - | OFF | OK |
| 2026-02-22 | Sun | 휴무 | - | OFF | OK |
| 2026-02-23 | Mon | 야간 | 22:00 | ON | OK |
| 2026-02-24 | Tue | 야간 | 22:00 | ON | OK |
| 2026-02-25 | Wed | 야간 | 22:00 | ON | OK |
| 2026-02-26 | Thu | 야간 | 22:00 | ON | OK |
| 2026-02-27 | Fri | 야간 | 22:00 | ON | OK |
| 2026-02-28 | Sat | 휴무 | - | OFF | OK |
| 2026-03-01 | Sun | 석간 | 14:00 | ON | OK HOLIDAY |
| 2026-03-02 | Mon | 석간 | 14:00 | ON | OK |
| 2026-03-03 | Tue | 석간 | 14:00 | ON | OK |
| 2026-03-04 | Wed | 석간 | 14:00 | ON | OK |
| 2026-03-05 | Thu | 석간 | 14:00 | ON | OK |
| 2026-03-06 | Fri | 휴무 | - | OFF | OK |
| 2026-03-07 | Sat | 휴무 | - | OFF | OK |
| 2026-03-08 | Sun | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-09 | Mon | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-10 | Tue | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-11 | Wed | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-12 | Thu | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-13 | Fri | 휴무 | - | OFF | OK |
| 2026-03-14 | Sat | 휴무 | - | OFF | OK |
| 2026-03-15 | Sun | 야간 | 22:00 | ON | OK |
| 2026-03-16 | Mon | 야간 | 22:00 | ON | OK |
| 2026-03-17 | Tue | 야간 | 22:00 | ON | OK |
| 2026-03-18 | Wed | 야간 | 22:00 | ON | OK |
| 2026-03-19 | Thu | 야간 | 22:00 | ON | OK |
| 2026-03-20 | Fri | 휴무 | - | OFF | OK |
| 2026-03-21 | Sat | 석간 | 14:00 | ON | OK |
| 2026-03-22 | Sun | 석간 | 14:00 | ON | OK |
| 2026-03-23 | Mon | 석간 | 14:00 | ON | OK |
| 2026-03-24 | Tue | 석간 | 14:00 | ON | OK |
| 2026-03-25 | Wed | 석간 | 14:00 | ON | OK |
| 2026-03-26 | Thu | 휴무 | - | OFF | OK |
| 2026-03-27 | Fri | 휴무 | - | OFF | OK |
| 2026-03-28 | Sat | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-29 | Sun | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-30 | Mon | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-31 | Tue | 주간 | 06:00 | ON | Valid (Default) |
### Simulation Report: User 2 (Nonsan-A Standard) (Nonsan - Team A)
| Date | Day | Shift | Alarm Time | Status | Logic Check |
|---|---|---|---|---|---|
| 2026-02-01 | Sun | 휴무 | - | OFF | OK |
| 2026-02-02 | Mon | 석간 | 15:00 | ON | OK |
| 2026-02-03 | Tue | 석간 | 15:00 | ON | OK |
| 2026-02-04 | Wed | 석간 | 15:00 | ON | OK |
| 2026-02-05 | Thu | 석간 | 15:00 | ON | OK |
| 2026-02-06 | Fri | 석간 | 15:00 | ON | OK |
| 2026-02-07 | Sat | 휴무 | - | OFF | OK |
| 2026-02-08 | Sun | 휴무 | - | OFF | OK |
| 2026-02-09 | Mon | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-02-10 | Tue | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-02-11 | Wed | 주간 | 07:00 | ON | Valid (Nonsan) HOLIDAY |
| 2026-02-12 | Thu | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-02-13 | Fri | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-02-14 | Sat | 휴무 | - | OFF | OK |
| 2026-02-15 | Sun | 휴무 | - | OFF | OK |
| 2026-02-16 | Mon | 야간 | 23:00 | ON | OK |
| 2026-02-17 | Tue | 야간 | 23:00 | ON | OK |
| 2026-02-18 | Wed | 야간 | 23:00 | ON | OK |
| 2026-02-19 | Thu | 야간 | 23:00 | ON | OK |
| 2026-02-20 | Fri | 야간 | 23:00 | ON | OK |
| 2026-02-21 | Sat | 휴무 | - | OFF | OK |
| 2026-02-22 | Sun | 휴무 | - | OFF | OK |
| 2026-02-23 | Mon | 석간 | 15:00 | ON | OK |
| 2026-02-24 | Tue | 석간 | 15:00 | ON | OK |
| 2026-02-25 | Wed | 석간 | 15:00 | ON | OK |
| 2026-02-26 | Thu | 석간 | 15:00 | ON | OK |
| 2026-02-27 | Fri | 석간 | 15:00 | ON | OK |
| 2026-02-28 | Sat | 휴무 | - | OFF | OK |
| 2026-03-01 | Sun | 휴무 | - | OFF | OK HOLIDAY |
| 2026-03-02 | Mon | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-03 | Tue | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-04 | Wed | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-05 | Thu | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-06 | Fri | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-07 | Sat | 휴무 | - | OFF | OK |
| 2026-03-08 | Sun | 휴무 | - | OFF | OK |
| 2026-03-09 | Mon | 야간 | 23:00 | ON | OK |
| 2026-03-10 | Tue | 야간 | 23:00 | ON | OK |
| 2026-03-11 | Wed | 야간 | 23:00 | ON | OK |
| 2026-03-12 | Thu | 야간 | 23:00 | ON | OK |
| 2026-03-13 | Fri | 야간 | 23:00 | ON | OK |
| 2026-03-14 | Sat | 휴무 | - | OFF | OK |
| 2026-03-15 | Sun | 휴무 | - | OFF | OK |
| 2026-03-16 | Mon | 석간 | 15:00 | ON | OK |
| 2026-03-17 | Tue | 석간 | 15:00 | ON | OK |
| 2026-03-18 | Wed | 석간 | 15:00 | ON | OK |
| 2026-03-19 | Thu | 석간 | 15:00 | ON | OK |
| 2026-03-20 | Fri | 석간 | 15:00 | ON | OK |
| 2026-03-21 | Sat | 휴무 | - | OFF | OK |
| 2026-03-22 | Sun | 휴무 | - | OFF | OK |
| 2026-03-23 | Mon | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-24 | Tue | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-25 | Wed | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-26 | Thu | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-27 | Fri | 주간 | 07:00 | ON | Valid (Nonsan) |
| 2026-03-28 | Sat | 휴무 | - | OFF | OK |
| 2026-03-29 | Sun | 휴무 | - | OFF | OK |
| 2026-03-30 | Mon | 야간 | 23:00 | ON | OK |
| 2026-03-31 | Tue | 야간 | 23:00 | ON | OK |
### Simulation Report: User 3 (Jeonju-A Customizer) (Jeonju - Team A)
| Date | Day | Shift | Alarm Time | Status | Logic Check |
|---|---|---|---|---|---|
| 2026-02-01 | Sun | 석간 | 14:00 | ON | OK |
| 2026-02-02 | Mon | 석간 | 14:00 | ON | OK |
| 2026-02-03 | Tue | 석간 | 14:00 | ON | OK |
| 2026-02-04 | Wed | 휴무 | - | OFF | OK |
| 2026-02-05 | Thu | 휴무 | - | OFF | OK |
| 2026-02-06 | Fri | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-07 | Sat | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-08 | Sun | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-09 | Mon | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-10 | Tue | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-11 | Wed | 휴무 | - | OFF | OK HOLIDAY |
| 2026-02-12 | Thu | 휴무 | - | OFF | OK |
| 2026-02-13 | Fri | 야간 | 22:00 | ON | OK |
| 2026-02-14 | Sat | 휴무 | - | OFF | OK [Manual Override] |
| 2026-02-15 | Sun | 주간 | 06:00 | ON | Valid (Default) [Manual Override] |
| 2026-02-16 | Mon | 야간 | 22:00 | ON | OK |
| 2026-02-17 | Tue | 야간 | 22:00 | ON | OK |
| 2026-02-18 | Wed | 휴무 | - | OFF | OK |
| 2026-02-19 | Thu | 석간 | 14:00 | ON | OK |
| 2026-02-20 | Fri | 석간 | 05:00 | ON | OK [Date Rule] |
| 2026-02-21 | Sat | 석간 | 14:00 | ON | OK |
| 2026-02-22 | Sun | 석간 | 14:00 | ON | OK |
| 2026-02-23 | Mon | 석간 | 14:00 | ON | OK |
| 2026-02-24 | Tue | 휴무 | - | OFF | OK |
| 2026-02-25 | Wed | 휴무 | - | OFF | OK |
| 2026-02-26 | Thu | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-27 | Fri | 주간 | 06:00 | ON | Valid (Default) |
| 2026-02-28 | Sat | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-01 | Sun | 주간 | 06:00 | ON | Valid (Default) HOLIDAY |
| 2026-03-02 | Mon | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-03 | Tue | 휴무 | - | OFF | OK |
| 2026-03-04 | Wed | 휴무 | - | OFF | OK |
| 2026-03-05 | Thu | 야간 | 22:00 | ON | OK |
| 2026-03-06 | Fri | 야간 | 22:00 | ON | OK |
| 2026-03-07 | Sat | 야간 | 22:00 | ON | OK |
| 2026-03-08 | Sun | 야간 | 22:00 | ON | OK |
| 2026-03-09 | Mon | 야간 | 22:00 | ON | OK |
| 2026-03-10 | Tue | 휴무 | - | OFF | OK |
| 2026-03-11 | Wed | 석간 | 14:00 | ON | OK |
| 2026-03-12 | Thu | 석간 | 14:00 | ON | OK |
| 2026-03-13 | Fri | 석간 | 14:00 | ON | OK |
| 2026-03-14 | Sat | 석간 | 14:00 | ON | OK |
| 2026-03-15 | Sun | 석간 | 14:00 | ON | OK |
| 2026-03-16 | Mon | 휴무 | - | OFF | OK |
| 2026-03-17 | Tue | 휴무 | - | OFF | OK |
| 2026-03-18 | Wed | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-19 | Thu | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-20 | Fri | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-21 | Sat | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-22 | Sun | 주간 | 06:00 | ON | Valid (Default) |
| 2026-03-23 | Mon | 휴무 | - | OFF | OK |
| 2026-03-24 | Tue | 휴무 | - | OFF | OK |
| 2026-03-25 | Wed | 야간 | 22:00 | ON | OK |
| 2026-03-26 | Thu | 야간 | 22:00 | ON | OK |
| 2026-03-27 | Fri | 야간 | 22:00 | ON | OK |
| 2026-03-28 | Sat | 야간 | 22:00 | ON | OK |
| 2026-03-29 | Sun | 야간 | 22:00 | ON | OK |
| 2026-03-30 | Mon | 휴무 | - | OFF | OK |
| 2026-03-31 | Tue | 석간 | 14:00 | ON | OK |

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Alarm & Full Screen -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Service & Notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_alarm_blue"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_alarm_blue"
android:supportsRtl="true"
android:theme="@style/Theme.ShiftAlarm">
<activity android:name=".SettingsActivity" android:exported="false" android:configChanges="uiMode"/>
<activity
android:name=".NoticeActivity"
android:exported="false"
android:parentActivityName=".SettingsActivity"
android:label="변경사항"/>
<activity
android:name=".ManualActivity"
android:exported="false"
android:parentActivityName=".SettingsActivity"
android:label="사용설명서"
android:configChanges="uiMode"/>
<activity
android:name=".AlarmActivity"
android:exported="false"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.AppCompat.NoActionBar"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:screenOrientation="portrait"
android:launchMode="singleTask"
android:documentLaunchMode="never"
android:configChanges="orientation|screenSize|keyboardHidden"
android:allowEmbedded="false"
android:resizeableActivity="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".AlarmReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.example.shiftalarm.ALARM_TRIGGER" />
<action android:name="com.example.shiftalarm.SNOOZE" />
</intent-filter>
</receiver>
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".AlarmForegroundService"
android:foregroundServiceType="specialUse"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,554 @@
# Changelog
## [1.1.3] - 2026-02-16
### Added
- **앱 안정성 설정 통합**: 설정 화면에서 '배터리 최적화 제외', '다른 앱 위에 표시', '전체화면 알림' 등 알람 가동에 필수적인 권한 상태를 한눈에 확인하고 직접 설정할 수 있는 섹션 추가
- **안드로이드 16 잠금화면 우회**: 최신 OS에서도 지문/패턴 해제 없이 알람 화면이 즉시 나타나도록 `requestDismissKeyguard` 로직 적용 및 안정성 강화
## [1.1.2] - 2026-02-15
### Fixed
- **알람 삭제 버그**: 알람이 켜진 상태에서 삭제해도 알람이 울리던 문제 수정
- **삭제 시 자동 취소**: 알람 삭제 시 시스템에 등록된 향후 모든 스케줄을 즉시 취소하도록 로직 강화
## [1.1.1] - 2026-02-15
### Fixed
- **잠금 화면 위 표시**: 잠금 화면을 풀지 않아도 알람 해제 화면이 즉시 나타나도록 윈도우 플래그 및 핸들링 로직 수정 (Android 14/15 완벽 대응)
- **전체화면 권한 안내**: 권한이 누락된 경우 설정 화면으로 바로 이동하도록 안내 로직 개선
## [1.1.0] - 2026-02-15
### 🚀 알람 신뢰도 100% 달성 및 시스템 고도화
- **3단계 알람 안전장치 도입**:
- **Room DB 전환**: 사용자 알람 데이터를 SQLite 데이터베이스로 마이그레이션하여 대규모 데이터 처리 및 보존 안정성 확보
- **AlarmClock API 최우선 순위**: 절전 모드를 무력화하는 최고 수준의 신뢰도 API 적용. 상단바 알람 아이콘 활성화로 예약 상태 가시성 확보
- **30일 확장 동기화 엔진**: 근무 변경이나 설정 수정 시 향후 30일간의 알람을 즉시 재계산 및 예약
- **권한 및 알림 일원화**:
- **통합 권한 센터**: 필수 권한(정확한 알람, 배터리 제외, 전체화면 알림)을 한 화면에서 순차적으로 설정할 수 있도록 흐름 개선
- **단일 알림 포그라운드 서비스 적용**: 알람 알림이 중복되거나 지워지지 않도록 포그라운드 서비스 기반의 단일 알림 시스템 구축
- **레거시 제거 및 최적화**: 미사용 파라미터 제거 및 알람 엔진 성능 최적화
## [1.0.1] - 2026-02-14
### Added
- **프리미엄 알람 디자인**: `lock.html` 디자인을 기반으로 한 화려한 알람 화면 도입 (오로라 펄스 애니메이션, 글래스모피즘 버튼)
- **달력 년/월 휠 선택**: 달력 상단 년/월 클릭 시 휠 다이얼로 즉시 이동하는 기능 추가
- **알람 설정 최적화**: 알람 목록 로딩 속도 개선 및 사운드 타이틀 캐싱 적용
### Changed
- **마스터 알람 스위치**: 거대한 카드 대신 세련된 텍스트 레이블 형태로 알람 설정 좌측 상단에 배치
- **토글 버튼 개선**: 이질적인 회색 배경을 제거하고 부드러운 Material 3 애니메이션 스위치 적용
- **사용 설명서 개편**: 최신 기능(년/월 피커, 신규 알람 UI 등)에 맞춰 상세 설명 업데이트
### Fixed
- **설정 진입 속도**: 알람 설정 탭 클릭 시 발생하던 미세한 지연 시간 단축
## [0.9.1] - 2026-02-11
### Fixed
- **알람 엔진 안정화**: PendingIntent ID 충돌 및 권한 누락 안내 기능 추가
- **배경 작업 최적화**: 부팅 후 알람 복구 로직 중복 실행 방지 및 효율성 개선
- **보안 강화**: 앱 서명 비밀번호 분리 관리 및 루팅 기기 대응 준비
- **시간대 통일**: 모든 알람 로직에 Asia/Seoul 표준 시간대 강제 적용
## [0.9.0] - 2026-02-11
### Added
- **One UI 8 디자인 완성**: 설정 화면, 알람 설정, 공지사항 등 앱 전반에 걸쳐 One UI 8 스타일의 카드 레이아웃 및 28dp 라운딩 적용
- **자동 업데이트 확인**: 앱 접속 시 최신 버전을 자동으로 체크하고 원클릭으로 업데이트를 수행하는 스마트 엔진 탑재
- **UI 일관성 강화**: 모든 다이얼로그 및 팝업에 고도화된 Glassmorphism 디자인과 통일된 여백 시스템 도입
- **상태 기반 헤더**: 메인 화면 상단에 오늘 근무 및 선택된 반 정보를 실시간으로 표시하는 다이나믹 헤더 추가
### Changed
- **아이콘 시스템 정밀화**: 설정 아이콘 및 액션 버튼에 Lucide Icons 스타일 적용 및 시인성 개선
- **가독성 최적화**: 시간 선택기(TimePickerDialog)를 최신 시스템 테마로 업데이트하고 폰트 가독성 상향
---
## [0.8.0] - 2026-02-11
### Added
- **One UI 8 스타일 적용**: Jetpack Compose 기반의 최신 삼성 One UI 8 디자인 시스템 통합 (Soft Blur, Pill-shape, Dynamic Color 지원)
- **알람 신뢰도 엔진 (Android 14+ 대응)**: 포그라운드 서비스(shortService) 및 배터리 최적화 예외 유도 로직을 통해 삼성 기기에서의 95%+ 알람 성공률 확보
- **정확한 알람 권한 관리**: `AlarmPermissionUtil`을 통해 Android 14+ 알람/리마인더 권한 설정 인터페이스 개선
### Changed
- **빌드 시스템 인프라**: Jetpack Compose 및 Material 3 환경 구축
---
## [0.6.3] - 2026-02-10
### Changed
- **리브랜딩**: 앱 전체에서 '닥잡아/dakjaba' 표기를 '교대링(Shiftring)'으로 통일
- **달력 근무 표기 변경**: 약어(주/석/야/맞/휴) → 풀네임(주간/석간/야간/맞교대/휴무)
- **메모 표시 레이아웃 수정**: 근무 텍스트 아래에 정확히 배치되도록 마진 및 제약조건 재설정
- **사용설명서 전면 업데이트**: 이모지 대신 Lucide 스타일 텍스트 아이콘 사용, 알람 테스트 안내 추가
- **알림 텍스트 브랜딩**: '교대 근무 알람' → '교대링 알람'
### Fixed
- **알람 테스트 수정**: 알람 테스트 버튼이 실제로 알람을 트리거하도록 테스트 알람 바이패스 로직 추가
---
## [0.5.8] - 2026-02-10
### Added
- **알람 동기화 시스템 전면 재정비**: 알람이 울리기 직전 근무 종류뿐만 아니라 설정된 '시간'까지 재검증하는 2중 동기화 로직을 도입하여, 어떤 상황에서도 정확한 알람이 울리도록 개선
### Fixed
- **다크 모드 눈 피로도 감소**: 근무별 배경색의 채도를 더 낮추고 부드러운 톤으로 조정하여 야간 사용 시 시각적 편안함 증대
- **다크 모드 가독성 수정**: 알람 추가 및 근무 변경 팝업의 배경과 텍스트 시인성 확보
- **버그 수정**: 야간 맞교대 근무 시 알람 시간 키값이 잘못 지정되던 문제 수정
---
## [0.5.7] - 2026-02-10
### Fixed
- **알람 동기화 긴급 수정**: 근무가 변경된 경우 이전 알람이 울리지 않도록 수신부(AlarmReceiver)에서 현재 근무를 재검증하는 로직 추가
- **다크 모드 팝업 시인성 개선**: 다크 모드에서 '알람 추가', '근무 변경' 팝업의 배경색이 보이지 않던 문제 수정 (테마 대응 컬러 적용)
- **근무 배경 색상 최적화**: 다크 모드에서 근무 배경색의 채도를 낮추어 눈의 피로도를 줄임
### Changed
- **용어 통일**: '스누즈' 표기를 '다시 울림'으로 일괄 변경
---
## [0.5.6] - 2026-02-09
### Changed
- **'오늘' 버튼 디자인 개편**: 달력 그리드 디자인과 조화로운 미니멀한 테두리 스타일로 변경
- **가독성 개선**: 라이트 모드에서 시인성이 낮았던 민트색 섹션 제목을 시인성이 높은 진보라색으로 변경하여 가독성 향상
---
## [0.5.5] - 2026-02-09
### Changed
- **설정 화면 레이아웃 최적화**: '회사 선택'과 '반 선택'을 한 행에 2열 그리드로 배치하여 공간 효율성 개선
- **용어 순화 및 변경**: 알람 설정 내 '스누즈 및 소리'를 사용자 친화적인 '다시 울림 및 소리'로 명칭 변경
---
## [0.5.4] - 2026-02-09
### Fixed
- **다크 모드 빈 격자 색상 수정**: 달력 시작/종료 전후의 빈 격자가 다크 모드에서도 테마에 맞는 색상으로 표시되도록 수정
### Improved
- **공간 활용성 극대화**: 각 격자의 높이를 상향(82dp) 조정하여, 하단의 남는 공간을 최소화하고 화면을 더 꽉 차게 보이도록 개선
---
## [0.5.3] - 2026-02-09
### Fixed
- **다크 모드 완벽 지원**: 하드코딩된 색상을 테마 리소스(bg_grid_cell_default, grid_divider 등)로 교체하여 다크 모드에서도 달력이 정상적으로 표시되도록 수정
- **사용자 편의성 강화**: 메인 화면의 알람 시간 표시 영역을 터치하면 즉시 알람 설정 화면으로 이동하도록 개선
---
## [0.5.2] - 2026-02-09
### Added
- **그리드 완성도 향상**: 달력 시작일 이전의 빈 공간에도 격자선을 표시하여 디자인적 일관성 확보
- **가독성 최적화**: 근무 표시 글자 크기 확대 및 격자 높이 상향 (66dp)
- **UI 시각적 개선**: '오늘' 버튼 디자인 개선 및 배경색/테두리 최적화
- **대비 및 명시성 강화**: 흰색 텍스트가 잘 보이지 않던 팀(A반)의 배경색을 더욱 진하게 조정
- **오늘 강조 변경**: 오늘 날짜를 연한 파란색 배경으로 강조하여 시각적 직관성 제공
---
## [0.5.1] - 2026-02-09
### Added
- **격자 디자인 고도화**: 일반 달력처럼 배경을 흰색으로 변경하고, 근무 표시(주, 석, 야 등)에만 개별 배경색 적용
- **공휴일 정보 통합**: 교대 달력 모드에서도 근무 표시 옆에 공휴일 이름을 함께 표시
- **화면 실용성 극대화**: 상단 헤더 및 알람바 높이를 축소하여 6주 달력도 스크롤 없이 한 화면에 표시
- **가독성 개선**: 근무 글자를 칸 좌측 상단 모서리에 밀착 배치하고 최적화된 글자 크기 적용
---
## [0.5.0] - 2026-02-09
### Added
- **달력 디자인 전면 개편**: 카드 스타일에서 세련된 격자(Grid) 스타일로 변경
- **근무 표기 최적화**: 좌측 상단에 한 글자(주, 석, 야, 맞, 휴)로 직관적인 근무 표시
- **가독성 강화**: 중앙에 큰 날짜 배치 및 날짜 옆 작은 공휴일 마커 추가
- **색상 체계 변경**: 주간(레몬), 석간(회색), 야간(검정), 휴무(빨강), 맞교대(보라)로 배경색 구분
- **전체 화면 최적화**: 불필요한 여백을 제거하고 화면을 최대한 활용하도록 레이아웃 수정
---
## [0.4.4] - 2026-02-09
### Changed
- **달력 행 수 최적화**: 1~4주만 필요한 달은 5행(35셀)만 표시하고 스크롤 없이 고정, 5주 이상 필요한 달만 6행(42셀)으로 스크롤 활성화
- **안전한 최적화 방식**: 레이아웃 높이 수정 없이, 데이터 패딩과 스크롤 설정만 조정하여 해상도에 관계없이 안정적으로 동작
---
## [0.4.3] - 2026-02-09
### Fixed
- **달력 화면 완전 복원**: 0.3.9 안정 버전의 달력 코드로 완전 롤백하여 달력이 보이지 않던 문제를 완벽하게 해결
- **해상도 최적화 이슈 제거**: 불안정한 동적 높이 조절 로직을 모두 제거하고 검증된 고정 레이아웃으로 복원
---
## [0.4.2] - 2026-02-09
### Fixed
- **달력 화면 완전 복구**: 달력 항목의 높이를 70dp로 명시적으로 고정하여, 일부 기기에서 레이아웃 측정 오류로 달력 내용이 보이지 않던 문제를 완벽하게 해결
- **스크롤 기능 강화**: 모든 해상도에서 화면 잘림 없이 달력을 확인할 수 있도록 스크롤 기능 상시 활성화 유지
---
## [0.4.1] - 2026-02-09
### Fixed
- **스크롤 제한 긴급 수정**: 작은 화면의 기기에서 달력 하단이 잘리는 현상을 수정하기 위해 스크롤 기능을 상시 활성화
- **달력 레이아웃 안정화**: 해상도 최적화 로직의 일부 불안정성을 제거하고 표준 높이(68dp)로 복구하여 안정성 확보
---
## [0.4.0] - 2026-02-09
### Added
- **버전 넘버링 체계 확립**: Patch가 9를 넘으면 Minor를 올리는 규칙 적용 (0.3.9 -> 0.4.0)
- **해상도별 달력 최적화**: 5행 달력은 스크롤 없이 고정하고, 6행일 때만 스크롤되도록 고도화
- **레이아웃 안정성**: 다양한 해상도의 폰에서도 달력 모양이 일정하게 유지되도록 수정
### Fixed
- **메인 공간 및 UI 개선**: 0.3.10의 변경사항(헤더 축소, 버튼 강조 등)을 정식 반영
---
## [0.3.9] - 2026-02-09
### Added
- **오늘 버튼 동작 개선**: 달력에서 '오늘' 클릭 시 보고 있던 조와 상관없이 나의 본래 조와 오늘 날짜 달력으로 즉시 복귀
- **사용자 알람 연동 버그 수정**: 이제 사용자 알람 추가/수정 시에도 오늘 근무표를 확인하여 본인 근무와 일치할 때만 스케줄링되도록 수정
### Fixed
- **업데이트 내역 가독성**: 변경사항 목록 하단에 불필요하게 표시되던 구분선(-) 제거
### Changed
- **기본 설정 가독성**: 회사/반 선택 레이블 폰트 크기 확대 및 굵게 표시하여 식별력 강화
---
## [v0.6.0] - 2026-02-12
### Added
- **Major Rebranding**: App name changed to **"교대링" (Shiftring)** with English locale support.
- **Room Database Migration**: Replaced `SharedPreferences` with Room DB for robust storage of shift overrides and memos.
- **Daily Memos**: Added ability to save and view daily notes on the calendar.
- **Lucide Icons**: Integrated modern Lucide iconography system across the app.
- **Advanced Alarm Synchronization**: Reliable "double-check" system ensuring alarms match current shift and time preferences.
- **Additional Features Tab**: New section in settings for upcoming capabilities.
### Fixed
- **Dark Mode Visibility**: Improved "Night" (야간) shift visibility and dialog accessibility in dark theme.
- **Coroutines & Performance**: Refactored database operations to use Coroutines for smoother UI performance.
- **Icon Prefix**: Standardized all icons with `ic_` prefix.
---
## [0.3.8] - 2026-02-09
### Added
- **지난 알람 숨김**: 오늘 이미 시간이 지난 사용자 알람은 달력 화면에서 표시되지 않도록 개선
- **가독성 향상**: 라이트 모드에서도 알람 시간이 잘 보이도록 색상 및 굵기 개선
### Updated
- **사용 설명서 개편**: 최신 기능 반영 및 불필요한 서식을 제거하여 깔끔하게 정리
---
## [0.3.7] - 2026-02-09
### Added
- **사용자 알람 근무표 연동**: 기타를 제외한 사용자 알람이 나의 근무표와 연동 (예: 주간 선택 시 주간 날에만 울림)
- **달력 팀 표시 개선**: 다이얼로그에서 나의 반 표시 시 "(나)" 추가
### Changed
- **알람 표시 개선**: (사용자) 텍스트 제거, 색상으로 구분
- **여러 알람 표시**: 3개 이하면 모두 표시, 초과 시 "XX:XX 외 N개" 형식
- **설정 섹션 타이틀 강화**: 글자 크기 16sp로 확대, 굵게 표시
---
## [0.3.6] - 2026-02-09
### Added
- **사용자 알람 실제 작동**: 사용자 알람이 실제로 울리도록 알람 스케줄링 구현
- **알람 수정 기능**: 사용자 알람에 수정 버튼 추가
- **달력 알람 표시 개선**: 사용자 알람이 근무 알람보다 빠르면 함께 표시
### Fixed
- **알람 신뢰성 강화**: 알람 추가 시 즉시 스케줄링되도록 개선
### Changed
- **근무별 알람시간 레이블 굵게 표시**: 주간/석간/야간/야맞 레이블을 굵게 표시
---
## [0.3.5] - 2026-02-09
### Added
- **사용자 알람 추가**: 알람 설정에서 '+ 알람 추가' 버튼으로 사용자 정의 알람 등록 가능
- **시간 및 근무 유형 선택**: 사용자 알람 추가 시 시간과 근무 유형(주간/석간/야간/기타) 설정
### Fixed
- **공지사항 버전 표시 수정**: 업데이트 내역에서 버전명이 정확하게 파싱되도록 로직 전면 개선
---
## [0.3.4] - 2026-02-09
### Improved
- **공지사항 버전 파싱 개선**: 업데이트 내역 상단에 버전명이 더 정확하게 표시되도록 로직 보강
- **사용 설명서 버전 자동화**: 설명서 상단의 앱 버전이 하드코딩 대신 현재 설치된 버전으로 자동 표시
- **날짜 숫자 크기 확대**: 달력 날짜 숫자를 더 크게(12sp) 키워 가독성 향상
---
## [0.3.3] - 2026-02-09
### Fixed
- **달력 스와이프 감도 개선**: RecyclerView 터치 간섭 문제를 해결하기 위해 `addOnItemTouchListener` 적용
- **스와이프 영역 확대**: 달력 내부뿐만 아니라 달력 컨테이너 전체에서 스와이프 제스처가 작동하도록 개선
---
## [0.3.2] - 2026-02-08
### Fixed
- **업데이트 내역 버전 파싱 수정**: 버전 번호가 정확하게 표시되도록 정규표현식 파싱 적용
- **근무 변경 팝업 불투명도 향상**: 배경 글자가 비치지 않도록 95% 불투명도 적용
- **공휴일/음력 표시 개선**: 카드 중앙에 크게 표시, 생략(...)없이 전체 표시
- **설명서 불필요한 내용 제거**: 알람 프라이버시 설명 삭제 (본인 폰에만 설정되므로 불필요)
---
## [0.3.1] - 2026-02-08
### Improved
- **용어 통일**: '조' 표기를 '반'으로 전체 통일 (다른 반 근무, 설명서, 메뉴 등)
- **업데이트 내역 버전 표기**: 공지사항에 'v0.3.1 업데이트 내용' 형식으로 버전 표시
- **공휴일 글자 크게 표시**: 공휴일/음력 날짜 텍스트를 13sp 굵은 글씨로 표시
- **사용 설명서 최신화**: v0.3.1 기준 최신 기능 및 용어 반영
---
## [0.3.0] - 2026-02-08
### Improved
- **공휴일 체크 위치 고정**: 다른 조 달력 조회 시에도 공휴일 체크박스가 우측에 고정
- **음력 날짜 표시 개선**: 12.25 형식의 음력 날짜가 생략 없이 전체 표시
- **설명서 UI 통일**: 공지사항과 동일한 핑크 카드 스타일로 변경
- **공지사항 UI 업그레이드**: 메시 그라디언트 배경과 세련된 헤더 카드 적용
- **전체 UI/UX 통일감 강화**: 앱 전반에 걸쳐 일관된 디자인 언어 적용
---
## [0.2.9] - 2026-02-08
### Improved
- **공휴일 글자 크게 표시**: 공휴일 명칭도 근무 글자와 동일하게 크게 (14sp) 표시
- **다시 울림 옵션 추가**: 1분, 3분 스누즈 간격 추가 (총 8개 옵션)
- **알람 표시 개선**: 다른 조 달력 조회 시 알람 영역 완전히 숨김 처리
- **공지사항 표시 제한**: 최대 7개 항목만 표시하여 가독성 향상
- **사용 설명서 최신화**: v0.2.9 기준 모든 기능 반영
---
## [0.2.8] - 2026-02-08
### Improved
- **달력 근무 텍스트 대폭 확대**: 주간, 석간, 야간 등 근무 글자를 16sp로 키워 가독성 극대화
- **기본 달력 모드 변경**: 앱 실행 시 '교대달력'이 기본으로 표시 (공휴일 체크 해제 상태)
- **달력 레이아웃 균등 배분**: 날짜, 근무, 음력이 균등하게 배치되어 깔끔한 정렬
- **알람 표시 개선**: 다른 조 달력 조회 시 알람 시간이 표시되지 않음 (내 조만 알람 표시)
- **오늘 날짜 강조**: 오늘 날짜에 파란색 테두리로 눈에 띄게 표시
- **사용 설명서 UI 전면 개편**: Glassmorphism 3.0 디자인 적용, 앱 아이콘 헤더, 글래스 카드 스타일
---
## [0.2.7] - 2026-02-08
### Fixed
- **체크 표시 로직 수정**: 근무 중인 날에는 V 체크 표시가 나타나지 않도록 수정 (휴무/휴가일에만 표시)
- **사용 설명서 업데이트**: 최신 앱 기능(글래스모피즘 3.0, 탭 설정, 오늘 이동 등) 반영 및 갱신
---
## [0.2.6] - 2026-02-08
### Added
- **공휴일 모드**: '공휴일' 체크 시 근무 표시를 숨기고 공휴일 명칭만 표시 (일반 달력 모드)
- **근무 변경 팝업 디자인**: 근무 변경 화면에 Glassmorphism 3.0 디자인 적용
---
## [0.2.5] - 2026-02-08
### Fixed
- **용어 수정**: '근무표 직접 수정' 버튼 명칭을 **'사용 설명서'**로 정정 (기능과 명칭 불일치 해결)
---
## [0.2.4] - 2026-02-08
### Fixed
- **다크 모드 가독성 개선**: 닫기 버튼 및 달력 날짜 텍스트 가독성 향상
- **UI/UX 개선**: 달력에 오늘 날짜로 이동하는 버튼 추가
- **디자인 수정**: 공휴일 체크 방식 변경 (V 체크 표시) 및 글래스모피즘 효과 강화
- **용어 수정**: '직접 입력 관리' → '근무표 직접 수정'으로 변경하여 이해도 향상
---
## [0.2.3] - 2026-02-08
### Changed
- **디자인 업그레이드 (Glassmorphism 3.0)**: 더욱 아름답고 세련된 반투명 디자인 적용
- **설정 화면 개편**: 기본 설정과 알람 설정 탭으로 분리하여 사용성 강화
- **완전 한글화**: 달력 요일 및 설정 메뉴 100% 한글 적용
- **회사 명칭 간소화**: '모나리자 전주' → '전주', '모나리자 논산' → '논산'으로 변경
### Fixed
- **알람 시간 초기화**: 회사 변경 시 해당 회사의 기본 출근 시간으로 자동 변경
---
## [0.2.2] - 2026-02-08
### Added
- **다크/라이트 모드 지원**: 설정 > 화면 테마에서 시스템/다크/라이트 모드 선택 가능
- **업데이트 내역 실시간 동기화**: 앱 내 변경사항이 서버와 즉시 연동되도록 개선
### Changed
- **용어 수정**: '공장' → '회사', '스누즈' → '다시 울림'으로 변경하여 친숙함 강화
- **UI 개선**: 알람 화면 '미루기' 버튼 가독성 향상 및 텍스트 수정
- **설정 화면**: 하단에 앱 버전 정보 및 제작자(산적이얌) 표시 추가
---
## [0.2.1] - 2026-02-08
### Changed (UI Overhaul - Glassmorphism 2.0)
- **전체 디자인 리뉴얼**: 고급스러운 메쉬 그라데이션(Deep Purple-Blue) 배경 적용
- **설정 화면 개선**: 알람 시간 버튼 색상을 투명한 글래스 스타일로 변경하여 가독성 대폭 향상
- **메인 캘린더**: 투명 카드 UI 적용, 텍스트 색상을 배경에 맞춰 화이트 톤으로 최적화
- **알람 화면**: 슬라이더 및 시간 표시 가독성 개선, 부드러운 글래스 패널 적용
- **세부 디테일**: 섹션 타이틀 영문 표기, 아이콘 및 버튼 스타일 통일
---
## [0.2.0] - 2026-02-08
### Changed
- **스누즈 메시지 개선**: "스누즈 설정됨" → "X분 뒤 다시 울림" 형태로 설정된 시간 표시
- **알람 화면 글래스모피즘 적용**: 반투명 카드, 슬라이더, 썸네일에 부드러운 투명도 효과
- **알람 화면 배경 개선**: 다크 블루 그라데이션 팔레트로 세련됨 향상
- **설정 화면 버튼 개선**: 진한 색상을 부드러운 글래스모피즘 스타일로 변경
- **스누즈 슬라이더에 시간 표시**: 알람 화면에서 설정된 스누즈 시간을 바로 확인 가능
---
## [0.1.9] - 2026-02-08
### Changed
- 알람 화면: 알람 끄기(빨간색)를 위로, 스누즈(파란색)를 아래로 위치 변경
- 설정 화면: 글래스모피즘 디자인 적용 (반투명 카드, 부드러운 그라데이션 배경)
- 설정 화면: 이모지 대신 안드로이드 시스템 아이콘으로 대체하여 세련됨 향상
- 사용설명서: 한글 깨짐 수정 및 알람 해제 방법 내용 추가
- 변경사항: 불필요한 구분선 및 빈 불릿 필터링으로 가독성 개선
---
## [0.1.8] - 2026-02-08
### Changed
- **설정 화면 전면 개편**: 섹션별 카드 분리, 구분선 및 여백 적용으로 가독성 대폭 개선
- **알람 화면 UI 고도화**:
- 그라데이션 배경 및 대형 시간 표시로 프리미엄 느낌 강화
- 스누즈(블루) / 해제(레드) 슬라이더 색상 분리로 직관성 향상
- 슬라이더 핸들 디자인 고급화 (원형 + 중심 컬러)
---
## [0.1.7] - 2026-02-08
### Added
- **슬라이더 방식 알람 UI**: 아이폰/갤럭시 스타일의 '밀어서 해제/스누즈' UI 도입
- **스누즈 (좌→우)**: 상단 슬라이더를 밀어서 잠시 미룸
- **알람 해제 (우→좌)**: 하단 슬라이더를 밀어서 정지
- **디자인 고도화**: 시각적인 조작 가이드(트랙 및 핸들)를 적용하여 직관성 및 미관 대폭 개선
---
## [0.1.6] - 2026-02-08
### Fixed
- 알람 화면 긴급 수정: 버튼 대신 **스와이프 전용 레이아웃** 도입 (우→좌: 정지, 좌→우: 스누즈)
- 화면 켜짐 보장: 잠금 화면에서도 알람 시 즉시 화면이 켜지도록 로직 강화
- 논산 야맞 알람 오류: 출근 시간(20:00) 1시간 전인 **19:00**에 알람이 기본 설정되도록 수정
- 축약어 적용: '다른 조 오늘 근무' 섹션에서도 '야간 맞교대' 대신 **'야맞'**으로 통일
### Added
- 알람 화면에 현재 시간 표시 및 스와이프 안내 가이드 추가
---
## [0.1.5] - 2026-02-08
### Added
- 알람 스와이프 제스처 기능: **우→좌(알람 정지)**, **좌→우(스누즈)**
- 알람 시 화면 자동 켜짐 및 잠금화면 표시 기능 강화
- 변경사항(Notice) 화면에 '닫기' 버튼 추가
### Changed
- 논산 공장 야간 맞교대(야맞) 기본 시간을 **20:00**으로 조정 및 라벨 업데이트
- 달력 내 '야간 맞교대' 표기를 **'야맞'**으로 축약하여 가독성 개선
- 5행 달력의 스크롤을 완전히 고정하여 불필요한 움직임 제거
- 달력 제목 형식을 연.월(예: 2026.02)로 변경하여 더 깔끔하게 개선
### Fixed
- 스누즈 버튼 클릭 시 알람이 즉시 다시 울리던 현상 수정
- 알람 정지 시 상단 바 알림이 사라지지 않던 문제 해결
---
## [0.1.4] - 2026-02-08
### Added
- 고급스러운 디자인 시스템 적용 (카드 곡률, 현대적 컬러 팔레트)
- 알람 테스트 기능 구현 (설정 > 5초 후 테스트 알람)
- 사용설명서 앱 내 뷰어 연동 및 최신 내용 반영
### Changed
- 달력 줄 수(5행/6행)에 따른 상하 스크롤 자동 제어
- 체인지로그 표시 시 불필요한 마크다운 기호를 제거하여 가독성 향상
- 설정 화면의 '깃허브 다운로드' 버튼 제거 (앱 내 업데이트로 통일)
### Fixed
- 음력 날짜가 표시되지 않거나 부정확하던 문제 수정
- 메인 화면 상단 알람 정보의 실시간 반영 로직 개선
---
## [0.1.3] - 2026-02-08
### Added
- 설정 내 '변경사항' 메뉴가 실제 CHANGELOG 내용을 표시하도록 연동
- '직접 입력' 근무 설정 시 알람 시간을 즉시 입력받도록 개선
### Changed
- '오늘 다른 조' 표시를 모든 팀(전주 4개, 논산 3개)이 보이도록 복원
- 일별 근무 변경 시 표준 근무(주간/석간/야간/야맞)는 설정된 시간을 즉시 적용하도록 간소화
- 야간 맞교대(야맞) 알람 기본 시간을 공장별로 차별화 (논산: 19:00, 전주: 18:00)
### Fixed
- 야간 맞교대 시간 설정이 저장되지 않던 문제 수정
---
## [0.1.2] - 2026-02-08
### Added
- 앱 내 직접 APK 다운로드 및 설치 기능 (진행률 표시 포함)
- 스와이프 제스처로 캘린더 월 탐색 기능
### Changed
- '오늘 다른 조' 표시를 현재 보고 있는 팀 제외한 팀만 표시하도록 간소화
- 캘린더 높이를 항상 6줄(42칸)로 고정하여 일관된 UI 제공
- 업데이트 다운로드 방식을 웹페이지 이동에서 앱 내 직접 다운로드로 변경
### Fixed
- 팀 표시에서 지역명(전주/논산) 제거하여 UI 정리
---
## [0.1.1] - 2026-02-08
### Changed
- 릴리즈 저장소 분리 (dakjaba-releases)
- 버전 체크 URL 업데이트
---
## [0.1.0] - 2026-02-08
### Added
- 사용설명서(Manual) 메뉴 추가
- 설정 화면 내 수동 업데이트 메뉴 개선
- 달력 내 음력 표시 기능 추가
- 릴리즈 빌드 서명(Signing) 적용
### Changed
- 알람 설정 단위를 1분으로 세분화
- 반 선택 방식을 라디오 버튼에서 드롭다운(Spinner)으로 변경
- 전주 D반 복구 및 공장별 맞춤형 반 선택 로직 적용
- 하단 '오늘의 근무' 레이아웃 최적화
### Fixed
- 설정 변경 시 달력에 즉시 반영되지 않던 문제 수정
- 공휴일 텍스트 잘림 현상 개선
---
## [2026-02-01]
### Added
- 기본 알람 스케줄 엔진
### Fixed
- Doze 모드에서 알람 누락 문제 수정

View File

@@ -0,0 +1,47 @@
# 교대링(Shiftring) 상세 사용 가이드
본 가이드는 **교대링 v1.1.8**의 주요 기능과 설정을 안내합니다. 별도의 복잡한 설정 없이도 **자신의 반(A/B/C/D)**만 선택하면 즉시 모든 일정과 알람이 세팅됩니다.
## 1. 스마트 달력 사용법
- **일정 한눈에 보기**: 달력에 주간(노랑), 석간(연두), 야간(보라), 휴무(빨강) 등 색상별로 근무가 자동 표시됩니다.
- **월 이동 제스처**: 화면을 좌우로 가볍게 밀어서(스와이프) 이전 달이나 다음 달로 빠르게 이동할 수 있습니다.
- **빠른 년/월 이동**: 상단 중앙의 **'2026년 02월'** 텍스트를 터치하면 휠 다이얼을 돌려 원하는 년도와 월로 즉시 이동할 수 있습니다.
- **오늘로 돌아오기**: 상단의 **'오늘'** 버튼을 누르면 언제 어디서든 현재 날짜로 즉시 돌아옵니다.
- **타 조 근무 확인**: 하단의 '오늘의 타 조 근무' 섹션에서 다른 조 이름을 터치하면, 해당 조의 달력 뷰로 잠시 전환됩니다.
## 2. 근무 변경 및 개인 메모
기본 스케줄 외의 변경 사항을 달력에 직접 기록하고 관리할 수 있습니다.
- **날짜 선택**: 수정하고 싶은 날짜를 터치하면 상세 설정 팝업이 나타납니다.
- **근무 상태 수정**: 연차, 교육, 월차, 반차 등 해당일의 상태를 선택하세요. 달력에 즉시 반영되며 관련 알람도 자동 조정됩니다.
- **메모장 활용**: 하단 메모란에 내용을 입력하고 저장하면, 달력 날짜 아래에 작은 점(•)이 표시되며 메모 내용을 확인할 수 있습니다.
- **설정 초기화**: 수정한 일정을 원래의 기본 순번대로 되돌리려면 **'원래대로'** 버튼을 누르세요.
## 3. 프리미엄 알람 시스템
최신 트렌드를 반영한 아름답고 신뢰할 수 있는 알람 기능을 제공합니다. 교대링은 **3단계 안전장치**를 통해 100% 신뢰도를 지향합니다.
- **자동 예약**: 선택된 근무(주/야/석)에 따라 알람 시간이 자동으로 계산되어 예약됩니다. (향후 30일치 사전 예약)
- **정밀 알람 엔진**: `AlarmClock` API를 통해 절전 모드에서도 정확하게 작동하며, 상단바에 알람 아이콘이 표시되어 작동 여부를 쉽게 확인할 수 있습니다.
- **실시간 동기화**: 근무를 변경하거나 설정에서 알람 시간을 바꾸는 즉시 전체 스케줄이 실시간으로 재구성됩니다.
- **시간 커스텀**: 설정(⚙️) → **알람 설정** 탭에서 각 근무별 기본 알람 시간을 본인의 기상 패턴에 맞게 수정할 수 있습니다.
- **전체 알람 마스터 스위치**: 알람 설정 페이지 좌측 상단의 **'전체 알람 켜짐/꺼짐'** 버튼으로 모든 예약을 일시 정지하거나 활성화할 수 있습니다.
- **럭셔리 디자인**: **글래스모피즘(유리 질감)**과 화려한 그라데이션이 적용된 알람 화면은 가독성과 디자인을 모두 잡았습니다.
- **직관적인 버튼 제어**:
- **다시 울림**: 상단 유리 질감 버튼을 누르면 설정된 간격만큼 알람을 미룹니다.
- **해제**: 중앙의 거대한 원형 버튼을 누르면 알람이 즉시 종료됩니다. (주변에 은은한 오로라 광채 애니메이션이 작동합니다)
- **부드러운 스위치**: 알람 항목의 온/오프 스위치는 가볍고 부드러운 애니메이션을 제공하며, 불필요한 배경 요소를 제거하여 시각적 이질감을 없앴습니다.
## 4. 물때표 및 특수 설정
- **물때표 표시**: 설정 → 기타 설정에서 **'물때표 보기'**를 활성화하면 달력 상단에 물때 정보가 나타납니다.
- **지역 전환**: 달력 상단의 지역 이름(군산, 변산, 여수, 태안)을 터치하여 간편하게 지역별 물때를 확인할 수 있습니다.
- **기본 공장 설정**: 본인이 속한 공장(전주 또는 논산)을 선택하여 공장별 특화된 교대 로직을 적용받으세요.
## 5. 데이터 백업 및 앱 공유
- **안전한 백업**: 설정 → 기타 설정에서 현재의 근무 기록과 메모를 파일로 저장하거나 다시 불러올 수 있습니다.
- **설치 파일 직접 전송**: **'앱 공유하기'** 기능을 통해 동료들에게 설치 파일(APK)을 직접 보내주어 간편한 설치를 도울 수 있습니다.
---
알람이 누락되지 않도록 앱 실행 시 안내되는 **통합 권한 설정**을 반드시 완료해주세요:
1. 배터리 사용량을 **'제한 없음'**으로 설정 (배터리 최적화 제외)
2. **'정확한 알람 및 리마인더'** 권한 허용 (필수)
3. **'전체화면 알림'** 권한 허용 (잠금 화면 즉시 표시)
4. 알람 볼륨 및 진동 설정 확인

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()
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:color="@color/primary" />
<item android:color="#20000000" /> <!-- Semi-transparent black instead of opaque gray -->
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="30%"
android:centerY="20%"
android:gradientRadius="800dp"
android:startColor="#6c4bb5"
android:centerColor="#120b2d"
android:endColor="#000000" />
</shape>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20000000">
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke
android:width="0.8dp"
android:color="@color/btn_today_text" />
<corners android:radius="6dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#20007AFF">
<item>
<shape android:shape="rectangle">
<solid android:color="#0D007AFF" />
<stroke
android:width="1dp"
android:color="#1A007AFF" />
<corners android:radius="32dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF"/>
<corners android:radius="24dp"/>
<stroke android:width="0dp" android:color="#00000000"/>
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/white" />
<size
android:width="44dp"
android:height="44dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#007AFF"/>
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#007AFF"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF3B30"/>
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF3B30"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/glass_panel_bg"/>
<corners android:radius="8dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E7F1FF"/>
<corners android:radius="8dp"/>
<stroke android:width="2dp" android:color="#0D6EFD"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/glass_panel_bg"/>
<corners android:radius="12dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
<gradient android:startColor="#10FFFFFF" android:endColor="#05FFFFFF" android:angle="45" />
</shape>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- Today's date highlight: Primary color tint with stronger glass effect -->
<gradient
android:startColor="#400D6EFD"
android:endColor="#200D6EFD"
android:angle="135"/>
<corners android:radius="10dp"/>
<stroke
android:width="2dp"
android:color="#800D6EFD"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Dialog background with more rounded corners for One UI 8 -->
<solid android:color="@color/dialog_bg"/>
<corners android:radius="28dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="135"
android:startColor="#800381FE"
android:endColor="#805856D6"
android:type="linear"/>
<corners android:topLeftRadius="28dp" android:topRightRadius="28dp"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/dialog_bg"/>
<corners android:radius="32dp"/>
<stroke android:width="1dp" android:color="@color/grid_divider"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#0c091c" />
<stroke android:width="6dp" android:color="#998C6EFF" />
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 눌렸을 때 -->
<item android:state_pressed="true">
<shape>
<solid android:color="#30FFFFFF"/>
<corners android:radius="28dp"/>
<stroke android:width="1dp" android:color="#50FFFFFF"/>
</shape>
</item>
<!-- 기본 -->
<item>
<shape>
<solid android:color="#00FFFFFF"/>
<corners android:radius="28dp"/>
<stroke android:width="0.5dp" android:color="#20FFFFFF"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#40FFFFFF"/>
<corners android:radius="28dp"/>
<stroke android:width="1dp" android:color="#30FFFFFF"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#605C6BC0"/>
<corners android:radius="28dp"/>
<stroke android:width="1dp" android:color="#40FFFFFF"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/button_glass_bg"/>
<corners android:radius="28dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E6FFFFFF"/>
<corners android:radius="20dp"/>
<stroke android:width="1dp" android:color="#20000000"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#30FFFFFF"/>
<corners android:radius="24dp"/>
<stroke android:width="1dp" android:color="#25FFFFFF"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#33FFFFFF"/>
<stroke android:width="1dp" android:color="#4DFFFFFF"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/glass_panel_bg"/>
<corners android:radius="16dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/glass_panel_bg"/>
<corners android:radius="32dp"/>
<stroke android:width="0.8dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- Glass Element: 20% Translucent White -->
<solid android:color="#33FFFFFF" />
<!-- Full Pill Shape -->
<corners android:radius="100dp" />
<!-- Subtle Border: 30% Translucent White -->
<stroke
android:width="1dp"
android:color="#4DFFFFFF" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="44dp" />
<solid android:color="#26FFFFFF" />
<stroke android:width="1.8dp" android:color="#4DFFFFFF" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#7C4DFF"
android:endColor="#5C6BC0"
android:angle="135"/>
<corners android:radius="16dp"/>
<stroke android:width="1dp" android:color="#40FFFFFF"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#20000000"/>
<corners android:radius="12dp"/>
<stroke android:width="1dp" android:color="#15000000"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#40FFFFFF"/>
<corners android:radius="40dp"/>
<stroke android:width="1.5dp" android:color="#501976D2"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#40FFFFFF"/>
<corners android:radius="40dp"/>
<stroke android:width="1.5dp" android:color="#50E53935"/>
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#B0FFFFFF"/>
<size android:width="64dp" android:height="64dp"/>
<stroke android:width="2dp" android:color="#801976D2"/>
</shape>
</item>
<item android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp">
<shape android:shape="oval">
<gradient
android:startColor="#1976D2"
android:endColor="#42A5F5"
android:angle="135"/>
<size android:width="32dp" android:height="32dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#B0FFFFFF"/>
<size android:width="64dp" android:height="64dp"/>
<stroke android:width="2dp" android:color="#80E53935"/>
</shape>
</item>
<item android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp">
<shape android:shape="oval">
<gradient
android:startColor="#E53935"
android:endColor="#EF5350"
android:angle="135"/>
<size android:width="32dp" android:height="32dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#C5E8F8"/>
<corners android:radius="12dp"/>
<stroke android:width="1dp" android:color="#60A5C8D8"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/transparent" />
<stroke
android:width="0.5dp"
android:color="@color/grid_divider" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/bg_grid_cell_today"/>
<stroke android:width="1dp" android:color="@color/primary" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/bg_grid_cell_default" />
<stroke android:width="0.5dp" android:color="@color/grid_divider" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#1A000000"> <!-- Subtle dark gray for light theme ripple -->
<item android:id="@android:id/mask">
<shape>
<solid android:color="@color/white" />
<corners android:radius="28dp" />
</shape>
</item>
<item>
<selector>
<item android:state_pressed="true">
<shape>
<solid android:color="#0D000000" /> <!-- 5% gray overlay -->
<corners android:radius="28dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/glass_panel_bg" />
<corners android:radius="28dp" />
<stroke android:width="0.8dp" android:color="@color/glass_panel_stroke" />
</shape>
</item>
</selector>
</item>
</ripple>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="@color/background_mesh_start"
android:centerColor="@color/background_mesh_center"
android:endColor="@color/background_mesh_end"
android:type="linear"
android:angle="135"/>
<corners android:radius="0dp"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFFFF"/> <!-- Fully Opaque to hide everything behind -->
<corners android:radius="32dp"/>
<stroke android:width="1.2dp" android:color="#1A000000"/>
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#4DFFFFFF">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/primary" />
<corners android:radius="28dp" />
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/primary"/>
<corners android:radius="12dp"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/surface_variant"/>
<stroke android:width="1dp" android:color="@color/outline"/>
<corners android:radius="12dp"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/primary"/>
<corners android:radius="20dp"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/button_glass_bg"/>
<corners android:radius="20dp"/>
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F2FFFFFF"/> <!-- 95% White -->
<corners android:radius="28dp"/>
<stroke android:width="0dp" android:color="@android:color/transparent"/>
</shape>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<gradient
android:type="radial"
android:gradientRadius="140dp"
android:centerX="50%"
android:centerY="50%"
android:startColor="#CC8C6EFF"
android:centerColor="#40785AFF"
android:endColor="#00000000" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF"/>
<corners android:radius="16dp"/>
<padding android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#E8F0FE"
android:endColor="#F5F7FF"
android:angle="135"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/white" />
<size android:width="32dp" android:height="32dp" />
</shape>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Left Half Vector -->
<item android:drawable="@drawable/ic_shift_half_red_vector" />
<!-- Full Circle Stroke Overlay -->
<item>
<shape android:shape="oval">
<stroke android:width="1.5dp" android:color="@color/shift_red"/>
<solid android:color="@android:color/transparent"/>
<size android:width="44dp" android:height="44dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/primary" />
<size android:width="44dp" android:height="44dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke android:width="1.5dp" android:color="@color/primary" />
<size android:width="44dp" android:height="44dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#401976D2" /> <!-- 25% Opacity Blue -->
<corners android:radius="36dp" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#40D32F2F" /> <!-- 25% Opacity Red -->
<corners android:radius="36dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#1565C0"
android:centerColor="#1976D2"
android:endColor="#42A5F5"
android:angle="135"/>
<corners android:radius="40dp"/>
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#C62828"
android:centerColor="#E53935"
android:endColor="#EF5350"
android:angle="135"/>
<corners android:radius="40dp"/>
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF"/>
<corners android:radius="30dp"/>
<size android:width="60dp" android:height="60dp"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="40dp" /> <!-- Fully rounded corners for pill shape -->
<solid android:color="#00000000" /> <!-- Transparent fill -->
<stroke android:width="1.5dp" android:color="#40FFFFFF" /> <!-- Glass border -->
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#FFFFFF"/>
<size android:width="64dp" android:height="64dp"/>
</shape>
</item>
<item android:left="18dp" android:top="18dp" android:right="18dp" android:bottom="18dp">
<shape android:shape="oval">
<solid android:color="#1976D2"/>
<size android:width="28dp" android:height="28dp"/>
</shape>
</item>
</layer-list>

Some files were not shown because too many files have changed in this diff Show More