1 Commits

Author SHA1 Message Date
380b2c5a6c refactor: 알람 시스템 단순화 - 기본 알람 제거, ID 체계 개선
- 기본 알람(legacy) 로직 완전 제거
- 알람 ID 체계 단순화: alarmDbId * 100 + dayOffset
- 예약 범위 축소: 오늘/내일만 예약 (복잡한 30일/365일 예약 제거)
- AlarmSyncManager 단순화: 즉시 반영 로직
- AlarmReceiver 강화: 삭제된 알람 필터링 개선
- suspend 함수로 변경하여 비동기 처리 개선

Fixes: 삭제된 알람이 울리는 문제 해결

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:10:46 +09:00
39 changed files with 642 additions and 1287 deletions

View File

@@ -1,12 +0,0 @@
# 로컬 설정 파일
## Personal Access Token (Gitea)
- 토큰: `e3b515eaa0a6683c921ca3bf718e281ed30a6075`
- 용도: git.webpluss.net API 접근 (Release 업로드, version.json 업데이트)
- **Git에 Commit되지 않도록 주의**
## 사용 예시
```bash
curl -u "sanjeok77:e3b515eaa0a6683c921ca3bf718e281ed30a6075" \
"https://git.webpluss.net/api/v1/repos/sanjeok77/NeFLIX/releases"
```

Binary file not shown.

View File

@@ -1,206 +0,0 @@
# Gitea 릴리즈 작업 가이드
> ShiftRing 프로젝트 Gitea 릴리즈 자동화 문서
> 저장소: https://git.webpluss.net/sanjeok77/ShiftRing
---
## 🔐 인증 정보
**Personal Access Token (PAT)**
- 위치: `.env.local` 파일
- 형식: `e3b515eaa0a6683c921ca3bf718e281ed30a6075`
- 사용자: `sanjeok77`
**인증 헤더**
```bash
-u "sanjeok77:TOKEN"
```
---
## 📋 릴리즈 생성 절차
### 1. 버전 업데이트 (3곳)
#### 1.1 `app/build.gradle.kts` - 앱 날부 버전
```kotlin
defaultConfig {
versionCode = 1125 // ← 이전: 1124
versionName = "1.2.5" // ← 이전: "1.2.4"
}
```
#### 1.2 `version.json` - 서버 버전 정보
```json
{
"versionCode": 1125,
"versionName": "1.2.5",
"apkUrl": "https://git.webpluss.net/attachments/{UUID}",
"changelog": "v1.2.5: 변경사항 요약",
"forceUpdate": false
}
```
#### 1.3 Git 태그 및 릴리즈
- 태그: `v1.2.5`
- 브랜치: `dev` (개발) → `main` (배포)
---
### 2. 릴리즈 생성 (API)
**엔드포인트**
```bash
POST https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases
```
**요청 예시**
```bash
curl -X POST \
-H "Content-Type: application/json" \
-u "sanjeok77:TOKEN" \
"https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases" \
-d '{
"tag_name": "v1.2.5",
"name": "v1.2.5 - 릴리즈 제목",
"body": "## 변경사항\n\n- 기능1\n- 기능2",
"prerelease": false,
"target_commitish": "dev"
}'
```
**응답 예시**
```json
{
"id": 30,
"tag_name": "v1.2.5",
"upload_url": "https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases/30/assets"
}
```
---
### 3. APK 빌드
**릴리즈 빌드**
```bash
./gradlew :app:assembleRelease
```
**출력 경로**
```
app/build/outputs/apk/release/app-release.apk
```
**서명 설정** (`keystore.properties`)
```properties
storePassword=비밀번호
keyAlias=별칭
keyPassword=비밀번호
storeFile=../release.jks
```
---
### 4. APK 업로드 (API)
**엔드포인트**
```bash
POST https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases/{release_id}/assets
```
**요청 예시**
```bash
curl -X POST \
-u "sanjeok77:TOKEN" \
-H "Content-Type: multipart/form-data" \
-F "attachment=@app/build/outputs/apk/release/app-release.apk" \
"https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases/30/assets?name=app.apk"
```
**응답 예시**
```json
{
"id": 37,
"name": "app.apk",
"size": 5236988,
"browser_download_url": "https://git.webpluss.net/attachments/b8f53c11-743f-416c-87ae-bd478c781abf"
}
```
---
### 5. version.json 업데이트
**APK URL 업데이트**
```json
{
"apkUrl": "https://git.webpluss.net/attachments/{UUID}"
}
```
**주의**: `releases/download/v1.2.5/app.apk` 형식이 아닌 `attachments/{UUID}` 형식 사용
---
## 🔧 유틸리티 명령어
### 릴리즈 조회
```bash
curl -s -u "sanjeok77:TOKEN" \
"https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases" | jq '.[].tag_name'
```
### 특정 릴리즈 조회
```bash
curl -s -u "sanjeok77:TOKEN" \
"https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases/30"
```
### 첨부파일 삭제
```bash
curl -s -u "sanjeok77:TOKEN" \
-X DELETE \
"https://git.webpluss.net/api/v1/repos/sanjeok77/ShiftRing/releases/30/assets/{asset_id}"
```
---
## ⚠️ 주의사항
1. **버전 일치**: `build.gradle.kts`, `version.json`, Git 태그 3곳 모두 동일 버전 사용
2. **APK 파일명**: 반드시 `app.apk`로 업로드 (클리어 이름 지정)
3. **UUID**: 업로드 후 반환된 UUID를 `version.json`에 반영
4. **브랜치**:
- 개발: `dev` 브랜치에 커밋
- 배포: `main` 브랜치에 cherry-pick
---
## 📁 관련 파일
| 파일 | 설명 |
|------|------|
| `app/build.gradle.kts` | 앱 날부 버전 설정 |
| `version.json` | 서버 버전 정보 |
| `keystore.properties` | 서명 키 설정 |
| `release.jks` | 서명 키스토어 |
| `.env.local` | API 토큰 저장 |
---
## 📝 변경 이력
| 날짜 | 버전 | 작업 |
|------|------|------|
| 2026-02-28 | v1.2.5 | 알람 시스템 단순화 릴리즈 |
| 2026-02-28 | v1.2.4 | 버그 수정 릴리즈 |
---
## 🔗 참고 링크
- 릴리즈 페이지: https://git.webpluss.net/sanjeok77/ShiftRing/releases
- API 문서: https://git.webpluss.net/api/swagger
- Swagger UI: https://git.webpluss.net/api/swagger#/repository/repoCreateRelease

BIN
app.apk

Binary file not shown.

View File

@@ -16,15 +16,15 @@ android {
namespace = "com.example.shiftalarm" namespace = "com.example.shiftalarm"
compileSdk = 35 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.example.shiftalarm" applicationId = "com.example.shiftalarm"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1150 versionCode = 1124
versionName = "1.5.0" versionName = "1.2.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
create("release") { create("release") {

View File

@@ -1,27 +1,5 @@
# Changelog # Changelog
## [1.5.0] - 2026-03-13
### Improved
- **달력 월 이동 애니메이션 대폭 개선**: AccelerateDecelerateInterpolator 적용으로 자연스러운 가속/감속 곡선 구현
- **스케일 효과 추가**: 월 전환 시 0.95배 축소/확대 효과로 깊이감 있는 화면 전환 제공
- **애니메이션 지속시간 최적화**: 200ms → 280ms로 조정하여 더 부드러운 전환 경험
- **알파 페이드 개선**: 0.5 → 0.7로 조정하여 전환 중 가독성 향상
### Changed
- 사용 설명서(MANUAL.md) 전면 개편: 현재 기능 정확히 반영, 섹션 구조 명확화
- 애니메이션 인터폴레이터 및 스케일 효과 적용으로 프리미엄 캘린더 앱 수준의 UX 구현
## [1.4.0] - 2026-03-13
### Improved
- **달력 월 이동 애니메이션 개선**: 월 전환 시 부드러운 슬라이드와 스케일 효과가 적용되어 자연스러운 화면 전환 제공
- **AccelerateDecelerateInterpolator 적용**: 가속/감속 곡선을 사용하여 더욱 자연스러운 애니메이션 구현
- **사용 설명서 최신화**: 현재 기능을 정확히 반영하도록 전면 업데이트
### Changed
- 애니메이션 지속시간을 200ms에서 280ms로 조정하여 더 부드러운 전환 제공
- 알파 페이드를 0.5에서 0.7로 조정하여 가독성 향상
- 스케일 효과(0.95) 추가로 깊이감 있는 화면 전환 구현
## [1.1.3] - 2026-02-16 ## [1.1.3] - 2026-02-16
### Added ### Added
- **앱 안정성 설정 통합**: 설정 화면에서 '배터리 최적화 제외', '다른 앱 위에 표시', '전체화면 알림' 등 알람 가동에 필수적인 권한 상태를 한눈에 확인하고 직접 설정할 수 있는 섹션 추가 - **앱 안정성 설정 통합**: 설정 화면에서 '배터리 최적화 제외', '다른 앱 위에 표시', '전체화면 알림' 등 알람 가동에 필수적인 권한 상태를 한눈에 확인하고 직접 설정할 수 있는 섹션 추가

View File

@@ -1,19 +1,19 @@
# 교대링(Shiftring) 상세 사용 가이드 # 교대링(Shiftring) 상세 사용 가이드
본 가이드는 **교대링**의 주요 기능과 설정을 안내합니다. 별도의 복잡한 설정 없이도 **자신의 반(A/B/C/D)**만 선택하면 즉시 모든 일정과 알람이 세팅됩니다. 본 가이드는 **교대링 v1.1.8**의 주요 기능과 설정을 안내합니다. 별도의 복잡한 설정 없이도 **자신의 반(A/B/C/D)**만 선택하면 즉시 모든 일정과 알람이 세팅됩니다.
## 1. 스마트 달력 사용법 ## 1. 스마트 달력 사용법
- **일정 한눈에 보기**: 달력에 주간(레몬), 석간(연두), 야간(보라), 휴무(빨강) 등 색상별로 근무가 자동 표시됩니다. - **일정 한눈에 보기**: 달력에 주간(노랑), 석간(연두), 야간(보라), 휴무(빨강) 등 색상별로 근무가 자동 표시됩니다.
- **월 이동 제스처**: 화면을 좌우로 가볍게 밀어서(스와이프) 이전 달이나 다음 달로 부드럽게 이동할 수 있습니다. - **월 이동 제스처**: 화면을 좌우로 가볍게 밀어서(스와이프) 이전 달이나 다음 달로 빠르게 이동할 수 있습니다.
- **빠른 년/월 이동**: 상단 중앙의 **'2026년 02월'** 텍스트를 터치하면 휠 다이얼을 돌려 원하는 년도와 월로 즉시 이동할 수 있습니다. - **빠른 년/월 이동**: 상단 중앙의 **'2026년 02월'** 텍스트를 터치하면 휠 다이얼을 돌려 원하는 년도와 월로 즉시 이동할 수 있습니다.
- **오늘로 돌아오기**: 상단의 **'오늘'** 버튼을 누르면 언제 어디서든 현재 날짜로 즉시 돌아옵니다. - **오늘로 돌아오기**: 상단의 **'오늘'** 버튼을 누르면 언제 어디서든 현재 날짜로 즉시 돌아옵니다.
- **타 근무 확인**: 하단의 반 버튼(A반/B반/C반/D반)을 터치하면 해당 의 달력 뷰로 전환됩니다. - **타 근무 확인**: 하단의 '오늘의 타 조 근무' 섹션에서 다른 조 이름을 터치하면, 해당 의 달력 뷰로 잠시 전환됩니다.
## 2. 근무 변경 및 개인 메모 ## 2. 근무 변경 및 개인 메모
기본 스케줄 외의 변경 사항을 달력에 직접 기록하고 관리할 수 있습니다. 기본 스케줄 외의 변경 사항을 달력에 직접 기록하고 관리할 수 있습니다.
- **날짜 선택**: 수정하고 싶은 날짜를 터치하면 상세 설정 팝업이 나타납니다. - **날짜 선택**: 수정하고 싶은 날짜를 터치하면 상세 설정 팝업이 나타납니다.
- **근무 상태 수정**: 연차, 교육, 월차, 반월, 반년, 휴무 등 해당일의 상태를 선택하세요. 달력에 즉시 반영되며 관련 알람도 자동 조정됩니다. - **근무 상태 수정**: 연차, 교육, 월차, 반 등 해당일의 상태를 선택하세요. 달력에 즉시 반영되며 관련 알람도 자동 조정됩니다.
- **메모 기능**: 팝업 하단 메모란에 내용을 입력하 달력 날짜 아래에 메모 내용이 표시됩니다. - **메모장 활용**: 하단 메모란에 내용을 입력하고 저장하면, 달력 날짜 아래에 작은 점(•)이 표시되며 메모 내용을 확인할 수 있습니다.
- **설정 초기화**: 수정한 일정을 원래의 기본 순번대로 되돌리려면 **'원래대로'** 버튼을 누르세요. - **설정 초기화**: 수정한 일정을 원래의 기본 순번대로 되돌리려면 **'원래대로'** 버튼을 누르세요.
## 3. 프리미엄 알람 시스템 ## 3. 프리미엄 알람 시스템
@@ -27,24 +27,16 @@
- **직관적인 버튼 제어**: - **직관적인 버튼 제어**:
- **다시 울림**: 상단 유리 질감 버튼을 누르면 설정된 간격만큼 알람을 미룹니다. - **다시 울림**: 상단 유리 질감 버튼을 누르면 설정된 간격만큼 알람을 미룹니다.
- **해제**: 중앙의 거대한 원형 버튼을 누르면 알람이 즉시 종료됩니다. (주변에 은은한 오로라 광채 애니메이션이 작동합니다) - **해제**: 중앙의 거대한 원형 버튼을 누르면 알람이 즉시 종료됩니다. (주변에 은은한 오로라 광채 애니메이션이 작동합니다)
- **부드러운 스위치**: 알람 항목의 온/오프 스위치는 가볍고 부드러운 애니메이션을 제공니다. - **부드러운 스위치**: 알람 항목의 온/오프 스위치는 가볍고 부드러운 애니메이션을 제공하며, 불필요한 배경 요소를 제거하여 시각적 이질감을 없앴습니다.
## 4. 물때표 및 특수 설정 ## 4. 물때표 및 특수 설정
- **물때표 표시**: 설정 → 부가 기능에서 **'물때 정보 표시'**를 활성화하면 달력 각 날짜에 물때 정보가 나타납니다. - **물때표 표시**: 설정 → 기타 설정에서 **'물때표 보기'**를 활성화하면 달력 상단에 물때 정보가 나타납니다.
- **지역 전환**: 달력 상단의 지역 이름(군산, 변산, 여수, 태안)을 터치하여 간편하게 지역별 물때를 확인할 수 있습니다. - **지역 전환**: 달력 상단의 지역 이름(군산, 변산, 여수, 태안)을 터치하여 간편하게 지역별 물때를 확인할 수 있습니다.
- **기본 공장 설정**: 설정 → 기본 설정에서 본인이 속한 공장(전주 또는 논산)을 선택하여 공장별 특화된 교대 로직을 적용받으세요. - **기본 공장 설정**: 본인이 속한 공장(전주 또는 논산)을 선택하여 공장별 특화된 교대 로직을 적용받으세요.
## 5. 데이터 백업 및 복구 ## 5. 데이터 백업 및 앱 공유
- **안전한 백업**: 설정 → 부가 기능에서 **'백업하기'** 버튼으로 현재의 근무 기록과 메모를 JSON 파일로 저장 수 있습니다. - **안전한 백업**: 설정 → 기타 설정에서 현재의 근무 기록과 메모를 파일로 저장하거나 다시 불러올 수 있습니다.
- **간편한 복구**: **'복구하기'** 버튼으로 백업 파일을 불러와 이전 상태를 복원할 수 있습니다. - **설치 파일 직접 전송**: **'앱 공유하기'** 기능을 통해 동료들에게 설치 파일(APK)을 직접 보내주어 간편한 설치를 도울 수 있습니다.
- **백업 범위**: 근무 변경 내역, 메모, 환경 설정이 저장됩니다.
## 6. 앱 공유 및 초기화
- **앱 공유하기**: 설정 → 부가 기능에서 **'교대링 앱 친구에게 공유하기'**를 통해 동료들에게 설치 파일(APK)을 직접 보낼 수 있습니다.
- **데이터 초기화**: 설정 → 부가 기능 하단의 **'개별 근무/알람 설정 전체 삭제'**로 달력에서 개별적으로 바꾼 모든 설정을 초기화할 수 있습니다.
## 7. 화면 테마 설정
- **테마 선택**: 설정 → 부가 기능에서 **'화면 테마 설정'**을 통해 시스템 기본/라이트 모드/다크 모드 중 선택할 수 있습니다.
--- ---

View File

@@ -13,7 +13,7 @@ class AlarmReceiver : BroadcastReceiver() {
private val TAG = "AlarmReceiver" private val TAG = "AlarmReceiver"
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
Log.d(TAG, "===== 알람 수신 (Receiver) =====") Log.d(TAG, "===== 알람 수신 =====")
// 마스터 알람이 꺼져있으면 알람 무시 // 마스터 알람이 꺼져있으면 알람 무시
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
@@ -22,36 +22,69 @@ class AlarmReceiver : BroadcastReceiver() {
return return
} }
val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1 val action = intent?.action
val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false
// 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결) when (action) {
if (isCustom && alarmId != -1) { "com.example.shiftalarm.SNOOZE" -> {
val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1) // 스누즈 알람
if (customAlarmId != -1) { startAlarm(context, intent)
// 비동기로 DB 확인 }
val scope = CoroutineScope(Dispatchers.IO) else -> {
scope.launch { // 일반 알람 - DB에서 여전히 유효한지 확인
val repo = ShiftRepository(context) val alarmDbId = intent?.getIntExtra("EXTRA_ALARM_DB_ID", -1) ?: -1
val alarms = repo.getAllCustomAlarms()
val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled }
if (!alarmExists) { if (alarmDbId != -1) {
Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)") // 비동기로 DB 확인
scope.cancel() val scope = CoroutineScope(Dispatchers.IO)
return@launch scope.launch {
try {
val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val alarm = alarms.find { it.id == alarmDbId }
// 알람이 존재하지 않거나 비활성화되었으면 무시
if (alarm == null) {
Log.w(TAG, "DB에서 알람을 찾을 수 없음 (삭제됨): ID=$alarmDbId")
scope.cancel()
return@launch
}
if (!alarm.isEnabled) {
Log.w(TAG, "알람이 비활성화됨: ID=$alarmDbId")
scope.cancel()
return@launch
}
// 근무 조건 확인
val dateStr = intent?.getStringExtra("EXTRA_DATE")
if (dateStr != null) {
val date = java.time.LocalDate.parse(dateStr)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val shift = repo.getShift(date, team, factory)
if (alarm.shiftType != "기타" && alarm.shiftType != shift) {
Log.w(TAG, "근무 조건이 맞지 않음: 알람=${alarm.shiftType}, 실제=$shift")
scope.cancel()
return@launch
}
}
// 모든 검증 통과 - 알람 실행
startAlarm(context, intent)
scope.cancel()
} catch (e: Exception) {
Log.e(TAG, "알람 검증 중 오류", e)
scope.cancel()
}
} }
} else {
// 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작 // DB ID가 없는 경우 (테스트 알람 등) - 바로 실행
startAlarm(context, intent) startAlarm(context, intent)
scope.cancel()
} }
return
} }
} }
// 일반 알람은 바로 직접 실행
startAlarm(context, intent)
} }
private fun startAlarm(context: Context, intent: Intent?) { private fun startAlarm(context: Context, intent: Intent?) {
@@ -62,15 +95,20 @@ class AlarmReceiver : BroadcastReceiver() {
PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"ShiftAlarm::AlarmWakeLock" "ShiftAlarm::AlarmWakeLock"
) )
wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간 wakeLock.acquire(30 * 1000L) // 30초
try { try {
// 1. Foreground Service 시작 (알림 표시 및 시스템에 알람 실행 중 알림) val shiftType = intent?.getStringExtra("EXTRA_SHIFT_TYPE") ?: "근무"
val soundUri = intent?.getStringExtra("EXTRA_SOUND")
val snoozeMin = intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5
val snoozeRepeat = intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3
// 1. Foreground Service 시작
val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply { val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") putExtra("EXTRA_SHIFT", shiftType)
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -81,28 +119,24 @@ class AlarmReceiver : BroadcastReceiver() {
Log.d(TAG, "ForegroundService 시작 완료") Log.d(TAG, "ForegroundService 시작 완료")
// 2. AlarmActivity 직접 실행 (알람 화면 표시) // 2. AlarmActivity 직접 실행
val activityIntent = Intent(context, AlarmActivity::class.java).apply { val activityIntent = Intent(context, AlarmActivity::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") putExtra("EXTRA_SHIFT", shiftType)
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
// 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 기존 인스턴스 재사용 및 최상위로 가져오기
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// 잠금 화면 위에 표시
addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT) addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT)
// 화면 켜기
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
} }
// 지연 후 Activity 시작 (ForegroundService가 알림을 먼저 표시하도록) // 지연 후 Activity 시작 (ForegroundService가 먼저 시작되도록)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { try {
context.startActivity(activityIntent) context.startActivity(activityIntent)
Log.d(TAG, "AlarmActivity 실행 완료") Log.d(TAG, "AlarmActivity 실행 완료: $shiftType")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "AlarmActivity 실행 실패", e) Log.e(TAG, "AlarmActivity 실행 실패", e)
} }
@@ -111,7 +145,7 @@ class AlarmReceiver : BroadcastReceiver() {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 실행 실패", e) Log.e(TAG, "알람 실행 실패", e)
} finally { } finally {
// WakeLock은 Activity가 화면을 켜고 나서 해제 // WakeLock 해제
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (wakeLock.isHeld) wakeLock.release() if (wakeLock.isHeld) wakeLock.release()
}, 5000) }, 5000)

View File

@@ -4,25 +4,22 @@ import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate
/** /**
* 알람 동기화 관리자 * 단순화된 알람 동기화 관리자
* DB와 AlarmManager 간의 실시간 동기화를 보장합니다.
* *
* 동기화 전략: * 핵심 원칙:
* 1. DB 작업과 AlarmManager 작업을 원자적으로 처리 * 1. 알람 추가/삭제/수정 시 즉시 AlarmManager에 반영
* 2. 실패 시 롤백 메커니즘 제공 * 2. 복잡한 예약 취소 로직 제거 - 단순한 ID 체계 사용
* 3. 동기화 상태 추적 및 재시도 * 3. 매일 자정 WorkManager가 다음날 알람 스케줄링
*/ */
object AlarmSyncManager { object AlarmSyncManager {
private const val TAG = "AlarmSyncManager" private const val TAG = "AlarmSyncManager"
private const val PREFS_NAME = "AlarmSyncPrefs"
/** /**
* 알람 추가 동기화 * 알람 추가
* DB에 추가 후 AlarmManager에 즉시 예약 * DB에 추가 후 즉시 AlarmManager에 예약
*/ */
suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
@@ -32,64 +29,32 @@ object AlarmSyncManager {
val alarmId = repo.addCustomAlarm(alarm) val alarmId = repo.addCustomAlarm(alarm)
Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId") Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId")
// 2. AlarmManager에 예약 // 2. AlarmManager에 즉시 예약
val today = LocalDate.now(SEOUL_ZONE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val customAlarms = repo.getAllCustomAlarms() val team = prefs.getString("selected_team", "A") ?: "A"
val addedAlarm = customAlarms.find { it.id == alarmId.toInt() } val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
if (addedAlarm == null) { val addedAlarm = alarm.copy(id = alarmId.toInt())
Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId") scheduleNextAlarm(context, addedAlarm, team, factory)
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) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 추가 동기화 실패", e) Log.e(TAG, "알람 추가 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 수정 동기화 * 알람 수정
* DB 수정 후 기존 AlarmManager 예약 취소 후 재예약 * DB 수정 후 기존 예약 취소 후 재예약
*/ */
suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
val repo = ShiftRepository(context) val repo = ShiftRepository(context)
// 1. 기존 AlarmManager 예약 취소 // 1. 기존 예약 취소
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
Log.d(TAG, "기존 알람 예약 취소 완료: ID=${alarm.id}") Log.d(TAG, "기존 알람 취소 완료: ID=${alarm.id}")
// 2. DB 업데이트 // 2. DB 업데이트
repo.updateCustomAlarm(alarm) repo.updateCustomAlarm(alarm)
@@ -97,70 +62,45 @@ object AlarmSyncManager {
// 3. 활성화된 알람이면 재예약 // 3. 활성화된 알람이면 재예약
if (alarm.isEnabled) { if (alarm.isEnabled) {
val today = LocalDate.now(SEOUL_ZONE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
for (i in 0 until 30) { val team = prefs.getString("selected_team", "A") ?: "A"
val targetDate = today.plusDays(i.toLong()) val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
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) { scheduleNextAlarm(context, alarm, team, factory)
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}") Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}")
} }
// 4. 동기화 상태 저장
saveSyncStatus(context, "last_update_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 수정 동기화 실패", e) Log.e(TAG, "알람 수정 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 삭제 동기화 * 알람 삭제
* AlarmManager 예약 먼저 취소 후 DB에서 삭제 * AlarmManager 예약 먼저 취소 후 DB에서 삭제
*/ */
suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
val repo = ShiftRepository(context)
// 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!) // 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!)
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}") Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}")
// 2. DB에서 삭제 // 2. DB에서 삭제
val repo = ShiftRepository(context)
repo.deleteCustomAlarm(alarm) repo.deleteCustomAlarm(alarm)
Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}") Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_delete_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 삭제 동기화 실패", e) Log.e(TAG, "알람 삭제 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 토글 동기화 (활성화/비활성화) * 알람 토글 (활성화/비활성화)
*/ */
suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) { suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
@@ -170,94 +110,67 @@ object AlarmSyncManager {
if (enable) { if (enable) {
// 활성화: DB 업데이트 후 예약 // 활성화: DB 업데이트 후 예약
repo.updateCustomAlarm(updatedAlarm) 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) { val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
scheduleCustomAlarm( val team = prefs.getString("selected_team", "A") ?: "A"
context, val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
targetDate,
alarm.id, scheduleNextAlarm(context, updatedAlarm, team, factory)
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}") Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}")
} else { } else {
// 비활성화: 예약 취소 후 DB 업데이트 // 비활성화: 예약 취소 후 DB 업데이트
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
repo.updateCustomAlarm(updatedAlarm) repo.updateCustomAlarm(updatedAlarm)
Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}") Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}")
} }
saveSyncStatus(context, "last_toggle_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 토글 동기화 실패", e) Log.e(TAG, "알람 토글 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 전체 알람 동기화 (앱 시작 시 호출) * 전체 알람 동기화 (앱 시작/설정 변경 시)
* 깨끗한 상태에서 모든 활성화된 알람 재예약
*/ */
suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) { suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) {
try { try {
Log.d(TAG, "전체 알람 동기화 시작") Log.d(TAG, "전체 알람 동기화 시작")
// 1. 기존 모든 알람 취소 val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val repo = ShiftRepository(context) val repo = ShiftRepository(context)
val allAlarms = repo.getAllCustomAlarms() val allAlarms = repo.getAllCustomAlarms()
// 1. 모든 기존 알람 취소
for (alarm in allAlarms) { for (alarm in allAlarms) {
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
} }
Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}") Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}")
// 2. 활성화된 알람만 재예약 // 2. 마스터 알람이 꺼져있으면 여기서 종료
val enabledAlarms = allAlarms.filter { it.isEnabled } if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
val today = LocalDate.now(SEOUL_ZONE) Log.d(TAG, "마스터 알람 꺼짐, 동기화 종료")
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) return@withContext Result.success(SyncResult(
totalAlarms = allAlarms.size,
enabledAlarms = 0,
scheduledAlarms = 0
))
}
// 3. 활성화된 알람만 재예약
val team = prefs.getString("selected_team", "A") ?: "A" val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val enabledAlarms = allAlarms.filter { it.isEnabled }
var scheduledCount = 0 var scheduledCount = 0
for (alarm in enabledAlarms) { for (alarm in enabledAlarms) {
for (i in 0 until 30) { scheduleNextAlarm(context, alarm, team, factory)
val targetDate = today.plusDays(i.toLong()) scheduledCount++
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}개 예약") Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_full_sync", System.currentTimeMillis())
Result.success(SyncResult( Result.success(SyncResult(
totalAlarms = allAlarms.size, totalAlarms = allAlarms.size,
@@ -271,21 +184,77 @@ object AlarmSyncManager {
} }
/** /**
* 동기화 상태 저장 * 특정 날짜 알람 스케줄링 (WorkManager에서 매일 자정 호출)
*/ */
private fun saveSyncStatus(context: Context, key: String, timestamp: Long) { suspend fun scheduleAlarmsForDate(context: Context, date: java.time.LocalDate): Result<Unit> = withContext(Dispatchers.IO) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) try {
.edit() val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.putLong(key, timestamp)
.apply()
}
/** if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
* 마지막 동기화 시간 확인 return@withContext Result.success(Unit)
*/ }
fun getLastSyncTime(context: Context, key: String): Long {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val repo = ShiftRepository(context)
.getLong(key, 0) val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val alarms = repo.getAllCustomAlarms()
val shift = repo.getShift(date, team, factory)
// 해당 날짜의 기존 알람 모두 취소
for (alarm in alarms) {
val alarmId = getAlarmId(alarm.id, date)
if (alarmId != -1) {
cancelAlarmInternal(context, alarmId)
}
}
// 조건에 맞는 활성화된 알람만 재예약
for (alarm in alarms) {
if (!alarm.isEnabled) continue
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
// 오늘 기준으로 시간이 지났는지 확인
val now = java.time.LocalDateTime.now(SEOUL_ZONE)
val parts = alarm.time.split(":")
val hour = parts[0].toIntOrNull() ?: continue
val minute = parts[1].toIntOrNull() ?: continue
val alarmTime = java.time.LocalDateTime.of(date, java.time.LocalTime.of(hour, minute))
// 오늘이고 이미 지난 시간이면 예약 안함
if (date == java.time.LocalDate.now(SEOUL_ZONE) && alarmTime.isBefore(now)) {
continue
}
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) continue
val intent = android.content.Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_ALARM_DB_ID", alarm.id)
putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", alarm.time)
putExtra("EXTRA_SHIFT_TYPE", alarm.shiftType)
putExtra("EXTRA_SOUND", alarm.soundUri)
putExtra("EXTRA_SNOOZE", alarm.snoozeInterval)
putExtra("EXTRA_SNOOZE_REPEAT", alarm.snoozeRepeat)
}
val pendingIntent = android.app.PendingIntent.getBroadcast(
context, alarmId, intent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
val triggerTime = alarmTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
setExactAlarm(context, triggerTime, pendingIntent)
}
}
Log.d(TAG, "${date} 알람 스케줄링 완료")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 스케줄링 실패: $date", e)
Result.failure(e)
}
} }
/** /**

View File

@@ -6,221 +6,293 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.widget.Toast
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.util.concurrent.TimeUnit
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul") val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
const val TAG = "ShiftAlarm" const val TAG = "ShiftAlarm"
/**
* 다크모드 지원 커스텀 토스트 표시
*/
fun showCustomToast(context: Context, message: String, duration: Int = android.widget.Toast.LENGTH_SHORT) {
try {
val inflater = android.view.LayoutInflater.from(context)
val layout = inflater.inflate(R.layout.custom_toast, null)
val textView = layout.findViewById<android.widget.TextView>(R.id.toastText)
textView.text = message
val toast = android.widget.Toast(context)
toast.duration = duration
toast.view = layout
toast.setGravity(android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL, 0, 150)
toast.show()
} catch (e: Exception) {
// Fallback to default toast if custom toast fails
android.widget.Toast.makeText(context, message, duration).show()
}
}
// ============================================ // ============================================
// 알람 ID 생성 // 알람 ID 생성 - 단순화: alarmId * 10000 + 날짜 기반 오프셋
// ============================================ // ============================================
fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int { fun getAlarmId(alarmDbId: Int, date: LocalDate): Int {
// Combine date and a unique ID from DB to avoid collisions // alarmDbId: 1, 2, 3...
// Using (uniqueId % 1000) to keep it within a reasonable range // 날짜 오프셋: 오늘=0, 내일=1, ... 6일 후=6 (일주일 단위로 재사용)
return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100) val today = LocalDate.now(SEOUL_ZONE)
} val dayOffset = java.time.temporal.ChronoUnit.DAYS.between(today, date).toInt()
// ============================================ // 범위 체크: -30일 ~ +30일 범위 내에서만 유효
// 사용자 알람 예약 if (dayOffset < -30 || dayOffset > 30) {
// ============================================ return -1 // 유효하지 않음
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( // ID 생성: alarmDbId * 100 + dayOffset (음수 처리)
context, alarmId, intent, // 예: alarmDbId=5, 오늘(dayOffset=0) -> 500
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // 예: alarmDbId=5, 내일(dayOffset=1) -> 501
) return alarmDbId * 100 + dayOffset + 30 // +30으로 음수 방지
}
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min)) // ============================================
// 다음 알람 예약 (오늘 또는 내일 중 다음 발생)
// ============================================
suspend fun scheduleNextAlarm(
context: Context,
alarm: CustomAlarm,
team: String,
factory: String
) {
if (!alarm.isEnabled) {
Log.d(TAG, "알람 비활성화됨, 예약 안함: ID=${alarm.id}")
return
}
val repo = ShiftRepository(context)
val today = LocalDate.now(SEOUL_ZONE)
val now = LocalDateTime.now(SEOUL_ZONE)
// 시간 파싱
val parts = alarm.time.split(":")
if (parts.size != 2) return
val hour = parts[0].toIntOrNull() ?: return
val minute = parts[1].toIntOrNull() ?: return
// 오늘 날짜의 알람 시간
val todayAlarmTime = LocalDateTime.of(today, LocalTime.of(hour, minute))
// 오늘 근무 확인
val todayShift = repo.getShift(today, team, factory)
// 오늘 알람이 아직 안 울렸고, 근무 조건이 맞으면 오늘 예약
val shouldRingToday = todayAlarmTime.isAfter(now) &&
(alarm.shiftType == "기타" || alarm.shiftType == todayShift)
if (shouldRingToday) {
scheduleAlarmForDate(context, alarm, today, team, factory)
Log.d(TAG, "오늘 알람 예약: ${alarm.time} (ID=${alarm.id})")
} else {
// 내일 알람 예약
val tomorrow = today.plusDays(1)
val tomorrowShift = repo.getShift(tomorrow, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == tomorrowShift) {
scheduleAlarmForDate(context, alarm, tomorrow, team, factory)
Log.d(TAG, "내일 알람 예약: ${alarm.time} (ID=${alarm.id})")
} else {
// 근무 조건이 맞지 않으면 취소만 하고 예약 안함
cancelAlarmForDate(context, alarm.id, today)
cancelAlarmForDate(context, alarm.id, tomorrow)
}
}
}
// ============================================
// 특정 날짜 알람 예약
// ============================================
private suspend fun scheduleAlarmForDate(
context: Context,
alarm: CustomAlarm,
date: LocalDate,
team: String,
factory: String
) {
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) return
// 먼저 기존 알람 취소
cancelAlarmInternal(context, alarmId)
val parts = alarm.time.split(":")
val hour = parts[0].toIntOrNull() ?: return
val minute = parts[1].toIntOrNull() ?: return
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, minute))
.withSecond(0).withNano(0) .withSecond(0).withNano(0)
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli() val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
if (alarmTime > System.currentTimeMillis()) { // 과거 시간이면 예약 안함
setExactAlarm(context, alarmTime, pendingIntent) if (alarmTime <= System.currentTimeMillis()) {
Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)") return
} }
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_ALARM_DB_ID", alarm.id)
putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", alarm.time)
putExtra("EXTRA_SHIFT_TYPE", alarm.shiftType)
putExtra("EXTRA_SOUND", alarm.soundUri)
putExtra("EXTRA_SNOOZE", alarm.snoozeInterval)
putExtra("EXTRA_SNOOZE_REPEAT", alarm.snoozeRepeat)
}
val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setExactAlarm(context, alarmTime, pendingIntent)
Log.d(TAG, "알람 예약 완료: $date ${alarm.time} (AlarmID: $alarmId, DbID: ${alarm.id})")
} }
// ============================================ // ============================================
// 알람 취소 (전체 범위) // 알람 취소 - 특정 알람의 모든 예약 취소
// ============================================ // ============================================
fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) { fun cancelAlarm(context: Context, alarmId: Int) {
val alarmId = getCustomAlarmId(date, uniqueId) val today = LocalDate.now(SEOUL_ZONE)
// 오늘, 내일 알람 취소 (다른 날짜는 WorkManager가 매일 재설정)
cancelAlarmForDate(context, alarmId, today)
cancelAlarmForDate(context, alarmId, today.plusDays(1))
Log.d(TAG, "알람 취소 완료: ID=$alarmId")
}
// ============================================
// 특정 날짜 알람 취소
// ============================================
private fun cancelAlarmForDate(context: Context, alarmDbId: Int, date: LocalDate) {
val alarmId = getAlarmId(alarmDbId, date)
if (alarmId == -1) return
cancelAlarmInternal(context, alarmId) cancelAlarmInternal(context, alarmId)
} }
/** // ============================================
* 특정 알람의 모든 예약을 완전히 취소합니다. // 날짜에 대한 날짜 알람 취소
* DB에서 삭제하기 전에 반드시 호출해야 합니다. // ============================================
* 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소 suspend fun cancelAlarmsForDate(context: Context, date: LocalDate) {
*/ val repo = ShiftRepository(context)
fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) { val alarms = repo.getAllCustomAlarms()
val today = LocalDate.now(SEOUL_ZONE)
// 1. 과거 30일치 취소 (혹시 모를 과거 예약) for (alarm in alarms) {
for (i in -30 until 0) { cancelAlarmForDate(context, alarm.id, date)
val targetDate = today.plusDays(i.toLong())
cancelCustomAlarm(context, targetDate, uniqueId)
} }
// 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버) Log.d(TAG, "${date} 날짜의 모든 알람 취소 완료")
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 생성 공식의 역연산을 통해 모든 가능성을 커버합니다. // ============================================
*/ suspend fun scheduleAlarmsForDate(context: Context, date: LocalDate) {
private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) { val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
Log.d(TAG, "마스터 알람 꺼짐, 스케줄링 중단")
return
}
val repo = ShiftRepository(context)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
// 해당 날짜의 근무 확인
val shift = repo.getShift(date, team, factory)
val alarms = repo.getAllCustomAlarms()
// 활성화된 알람 중 근무 조건이 맞는 것만 예약
for (alarm in alarms) {
if (!alarm.isEnabled) continue
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleAlarmForDate(context, alarm, date, team, factory)
} else {
// 조건 안 맞으면 취소
cancelAlarmForDate(context, alarm.id, date)
}
}
Log.d(TAG, "${date} 날짜 알람 스케줄링 완료")
}
// ============================================
// 모든 알람 취소 (마스터 오프 시)
// ============================================
suspend fun cancelAllAlarms(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val today = LocalDate.now(SEOUL_ZONE)
// uniqueId % 100의 모든 가능한 값에 대해 취소 시도 for (alarm in alarms) {
val baseId = uniqueId % 100 // 오늘, 내일 알람 취소
for (dayOffset in 0..1) {
val date = today.plusDays(dayOffset.toLong())
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) continue
// 현재 연도 기준으로 여러 해에 걸친 가능한 ID들 val intent = Intent(context, AlarmReceiver::class.java).apply {
val currentYear = LocalDate.now(SEOUL_ZONE).year % 100 action = "com.example.shiftalarm.ALARM_TRIGGER"
val years = listOf(currentYear - 1, currentYear, currentYear + 1) }
val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
}
for (year in years) { // 스누즈 알람도 취소
if (year < 0) continue cancelSnoozeAlarm(context)
for (month in 1..12) {
for (day in 1..31) { Log.d(TAG, "모든 알람 취소 완료")
try { }
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId
val intent = Intent(context, AlarmReceiver::class.java).apply { // ============================================
action = "com.example.shiftalarm.ALARM_TRIGGER" // 날짜 알람 전체 동기화 (앱 시작/설정 변경 시)
} // ============================================
val pendingIntent = PendingIntent.getBroadcast( suspend fun syncAllAlarms(context: Context) {
context, alarmId, intent, Log.d(TAG, "===== 알람 동기화 시작 =====")
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel() if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
} catch (e: Exception) { Log.d(TAG, "마스터 알람 꺼짐, 모든 알람 취소")
// 무시 - 유효하지 않은 날짜 조합 cancelAllAlarms(context)
} return
}
val repo = ShiftRepository(context)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val today = LocalDate.now(SEOUL_ZONE)
val alarms = repo.getAllCustomAlarms()
// 1. 모든 알람 취소 (깨끗한 상태에서 시작)
for (alarm in alarms) {
for (dayOffset in -1..1) { // 어제, 오늘, 내일
val date = today.plusDays(dayOffset.toLong())
cancelAlarmForDate(context, alarm.id, date)
}
}
// 2. 활성화된 알람 재예약
var scheduledCount = 0
for (alarm in alarms) {
if (!alarm.isEnabled) continue
// 오늘과 내일만 예약
for (dayOffset in 0..1) {
val date = today.plusDays(dayOffset.toLong())
val shift = repo.getShift(date, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleAlarmForDate(context, alarm, date, team, factory)
scheduledCount++
} }
} }
} }
Log.d(TAG, "uniqueId $uniqueId 관련 모든 PendingIntent 취소 완료") Log.d(TAG, "===== 알람 동기화 완료: $scheduledCount 개 예약 =====")
} }
/** // ============================================
* 스누즈 알람 취소 // 날짜 알람 취소 (날짜 날짜)
*/ // ============================================
/** internal fun cancelAlarmInternal(context: Context, alarmId: Int) {
* 스누즈 알람 취소 - 모든 가능한 스누즈 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 alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER" action = "com.example.shiftalarm.ALARM_TRIGGER"
@@ -234,9 +306,9 @@ private fun cancelAlarmInternal(context: Context, alarmId: Int) {
} }
// ============================================ // ============================================
// 정밀 알람 설정 (setAlarmClock 우선) // 정밀 알람 설정
// ============================================ // ============================================
private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) { internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -246,7 +318,6 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
} }
} }
// setAlarmClock은 Doze 모드에서도 정확하게 작동하며 상단바 알람 아이콘을 활성화함 (신뢰도 최고)
try { try {
val viewIntent = Intent(context, MainActivity::class.java).apply { val viewIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -258,16 +329,14 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent) val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent)
alarmManager.setAlarmClock(clockInfo, pendingIntent) alarmManager.setAlarmClock(clockInfo, pendingIntent)
Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}") Log.d(TAG, "setAlarmClock 예약 성공")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "setAlarmClock 실패, fallback 사용", e) Log.e(TAG, "setAlarmClock 실패, fallback 사용", e)
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
Log.d(TAG, "setExactAndAllowWhileIdle 예약 성공")
} else { } else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
Log.d(TAG, "setExact 예약 성공")
} }
} catch (e2: Exception) { } catch (e2: Exception) {
Log.e(TAG, "모든 알람 예약 방법 실패", e2) Log.e(TAG, "모든 알람 예약 방법 실패", e2)
@@ -276,7 +345,7 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
} }
// ============================================ // ============================================
// 스누즈 // 스누즈 알람
// ============================================ // ============================================
fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) { fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) {
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
@@ -286,6 +355,7 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
putExtra("EXTRA_SNOOZE", snoozeMin) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, 999999, intent, context, 999999, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
@@ -293,6 +363,27 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000) val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000)
setExactAlarm(context, triggerTime, pendingIntent) setExactAlarm(context, triggerTime, pendingIntent)
Log.d(TAG, "스누즈 알람 예약: ${snoozeMin}분 후")
}
// ============================================
// 스누즈 알람 취소
// ============================================
fun cancelSnoozeAlarm(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.SNOOZE"
}
val pendingIntent = PendingIntent.getBroadcast(
context, 999999, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(TAG, "스누즈 알람 취소")
} }
// ============================================ // ============================================
@@ -301,74 +392,16 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
fun scheduleTestAlarm(context: Context) { fun scheduleTestAlarm(context: Context) {
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER" action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_SHIFT", "테스트") putExtra("EXTRA_SHIFT_TYPE", "테스트")
putExtra("EXTRA_TIME", "TEST")
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, 888888, intent, context, 888888, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val triggerTime = System.currentTimeMillis() + 5000 val triggerTime = System.currentTimeMillis() + 5000
setExactAlarm(context, triggerTime, pendingIntent) setExactAlarm(context, triggerTime, pendingIntent)
} Log.d(TAG, "테스트 알람 예약: 5초 후")
// ============================================
// 전체 동기화 (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

@@ -1,27 +1,32 @@
package com.example.shiftalarm package com.example.shiftalarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate 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) { class AlarmWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try { try {
syncAllAlarms(applicationContext) val tomorrow = LocalDate.now(SEOUL_ZONE).plusDays(1)
// 다음날 알람 스케줄링
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, tomorrow)
// 오늘 남은 알람도 확인 (재부팅 등으로 누락됐을 수 있음)
val today = LocalDate.now(SEOUL_ZONE)
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, today)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() android.util.Log.e("AlarmWorker", "알람 스케줄링 실패", e)
Result.retry() Result.retry()
} }
} }

View File

@@ -1,13 +1,9 @@
package com.example.shiftalarm package com.example.shiftalarm
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room.*
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [ShiftOverride::class, DailyMemo::class, CustomAlarm::class, AnnualLeave::class], version = 4, exportSchema = false) @Database(entities = [ShiftOverride::class, DailyMemo::class, CustomAlarm::class], version = 3, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun shiftDao(): ShiftDao abstract fun shiftDao(): ShiftDao
@@ -15,23 +11,6 @@ abstract class AppDatabase : RoomDatabase() {
@Volatile @Volatile
private var INSTANCE: AppDatabase? = null private var INSTANCE: AppDatabase? = null
// Migration from version 3 to 4: Add AnnualLeave table
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
// Create AnnualLeave table
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS annual_leave (
id INTEGER PRIMARY KEY NOT NULL,
totalDays REAL NOT NULL,
remainingDays REAL NOT NULL,
updatedAt INTEGER NOT NULL
)
""".trimIndent()
)
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@@ -39,7 +18,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"shift_database" "shift_database"
) )
.addMigrations(MIGRATION_3_4) .fallbackToDestructiveMigration() // Simple for now
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@@ -39,21 +39,14 @@ object AppUpdateManager {
reader.close() reader.close()
val json = JSONObject(result) val json = JSONObject(result)
val serverVersionCode = json.getInt("versionCode")
val serverVersionName = json.getString("versionName") val serverVersionName = json.getString("versionName")
val apkUrl = json.getString("apkUrl") val apkUrl = json.getString("apkUrl")
val changelog = json.optString("changelog", "버그 수정 및 성능 향상") val changelog = json.optString("changelog", "버그 수정 및 성능 향상")
val pInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0) val pInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
val currentVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pInfo.longVersionCode.toInt()
} else {
@Suppress("DEPRECATION")
pInfo.versionCode
}
val currentVersionName = pInfo.versionName ?: "0.0.0" val currentVersionName = pInfo.versionName ?: "0.0.0"
if (serverVersionCode > currentVersionCode) { if (isNewerVersion(serverVersionName, currentVersionName)) {
activity.runOnUiThread { activity.runOnUiThread {
showUpdateDialog(activity, serverVersionName, changelog, apkUrl) showUpdateDialog(activity, serverVersionName, changelog, apkUrl)
} }
@@ -78,6 +71,29 @@ object AppUpdateManager {
}.start() }.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) { private fun showUpdateDialog(activity: Activity, version: String, changelog: String, apkUrl: String) {
com.google.android.material.dialog.MaterialAlertDialogBuilder(activity) com.google.android.material.dialog.MaterialAlertDialogBuilder(activity)
.setTitle("새로운 업데이트 발견 (v$version)") .setTitle("새로운 업데이트 발견 (v$version)")

View File

@@ -9,12 +9,15 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* 부팅 완료 시 알람 복구 리시버
*/
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작") android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작")
// 1) 즉시 1회 실행 → 당일 알람을 바로 복구 // 즉시 1회 실행 → 당일 알람 복구
val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build() val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build()
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
"BootAlarmRestore", "BootAlarmRestore",
@@ -22,16 +25,25 @@ class BootReceiver : BroadcastReceiver() {
immediateWork immediateWork
) )
// 2) 24시간 주기 반복 워커 등록 // 24시간 주기 반복 워커 등록 (자정에 실행)
val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS) val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS)
.setInitialDelay(calculateDelayToMidnight(), TimeUnit.MILLISECONDS)
.build() .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork( WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"DailyShiftCheck", "DailyShiftCheck",
androidx.work.ExistingPeriodicWorkPolicy.KEEP, ExistingPeriodicWorkPolicy.KEEP,
periodicWork periodicWork
) )
android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료") android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료")
} }
} }
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(5).withSecond(0) // 00:05에 실행
return java.time.Duration.between(now, midnight).toMillis()
}
} }

View File

@@ -13,10 +13,10 @@ import androidx.recyclerview.widget.RecyclerView
import java.time.LocalDate import java.time.LocalDate
data class DayShift( data class DayShift(
val date: LocalDate?, val date: LocalDate?,
val shift: String?, val shift: String?,
val hasMemo: Boolean = false, val hasMemo: Boolean = false,
val memoContent: String? = null val memoContent: String? = null
) )
class CalendarAdapter( class CalendarAdapter(
@@ -24,6 +24,7 @@ class CalendarAdapter(
private val listener: OnDayClickListener, private val listener: OnDayClickListener,
var showHolidays: Boolean = true var showHolidays: Boolean = true
) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() { ) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() {
interface OnDayClickListener { interface OnDayClickListener {
fun onDayClick(date: LocalDate, currentShift: String) fun onDayClick(date: LocalDate, currentShift: String)
} }
@@ -60,6 +61,7 @@ class CalendarAdapter(
// Day Number // Day Number
holder.dayNumber.text = item.date.dayOfMonth.toString() holder.dayNumber.text = item.date.dayOfMonth.toString()
// Holiday / Weekend logic // Holiday / Weekend logic
val isSunday = item.date.dayOfWeek == java.time.DayOfWeek.SUNDAY val isSunday = item.date.dayOfWeek == java.time.DayOfWeek.SUNDAY
val isSaturday = item.date.dayOfWeek == java.time.DayOfWeek.SATURDAY val isSaturday = item.date.dayOfWeek == java.time.DayOfWeek.SATURDAY
@@ -97,7 +99,7 @@ class CalendarAdapter(
holder.shiftChar.background = null holder.shiftChar.background = null
holder.shiftChar.text = "" holder.shiftChar.text = ""
holder.holidayNameSmall.visibility = View.GONE holder.holidayNameSmall.visibility = View.GONE
holder.shiftChar.textSize = 15f holder.shiftChar.textSize = 13f
// "반월", "반년" (Half-Monthly, Half-Yearly) Special Logic // "반월", "반년" (Half-Monthly, Half-Yearly) Special Logic
// These are overrides or specific shifts that user sets. // These are overrides or specific shifts that user sets.
@@ -109,7 +111,7 @@ class CalendarAdapter(
// Holiday Mode (Priority): Show full holiday name, no circle // Holiday Mode (Priority): Show full holiday name, no circle
holder.shiftChar.text = fullHolidayName holder.shiftChar.text = fullHolidayName
holder.shiftChar.setTextColor(Color.parseColor("#FF5252")) holder.shiftChar.setTextColor(Color.parseColor("#FF5252"))
holder.shiftChar.textSize = 11f holder.shiftChar.textSize = 10f
holder.shiftChar.background = null holder.shiftChar.background = null
} else if (item.shift != null && item.shift != "비번") { } else if (item.shift != null && item.shift != "비번") {
// Shift Mode // Shift Mode
@@ -118,7 +120,7 @@ class CalendarAdapter(
if (item.shift == "반월" || item.shift == "반년") { if (item.shift == "반월" || item.shift == "반년") {
holder.shiftChar.text = if (item.shift == "반월") "" else "" 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.setTextColor(ContextCompat.getColor(context, R.color.black)) // Black for contrast on Half Red/Transparent
holder.shiftChar.textSize = 15f holder.shiftChar.textSize = 13f
holder.shiftChar.background = ContextCompat.getDrawable(context, R.drawable.bg_shift_half_red) holder.shiftChar.background = ContextCompat.getDrawable(context, R.drawable.bg_shift_half_red)
} else { } else {
// Standard Logic // Standard Logic
@@ -135,7 +137,7 @@ class CalendarAdapter(
else -> item.shift.take(1) else -> item.shift.take(1)
} }
holder.shiftChar.text = shiftAbbreviation holder.shiftChar.text = shiftAbbreviation
holder.shiftChar.textSize = 17f holder.shiftChar.textSize = 15f
holder.shiftChar.setTypeface(null, android.graphics.Typeface.BOLD) holder.shiftChar.setTypeface(null, android.graphics.Typeface.BOLD)
val shiftColorRes = when (item.shift) { val shiftColorRes = when (item.shift) {
@@ -203,7 +205,7 @@ class CalendarAdapter(
// holder.holidayNameSmall.text = HolidayManager.getLunarDateString(item.date) // holder.holidayNameSmall.text = HolidayManager.getLunarDateString(item.date)
holder.shiftChar.text = HolidayManager.getLunarDateString(item.date) holder.shiftChar.text = HolidayManager.getLunarDateString(item.date)
holder.shiftChar.textSize = 11f holder.shiftChar.textSize = 10f
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary)) holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
holder.shiftChar.background = null holder.shiftChar.background = null
} }

View File

@@ -1,7 +1,6 @@
package com.example.shiftalarm package com.example.shiftalarm
import androidx.room.Entity import androidx.room.*
import androidx.room.PrimaryKey
@Entity(tableName = "shift_overrides", primaryKeys = ["factory", "team", "date"]) @Entity(tableName = "shift_overrides", primaryKeys = ["factory", "team", "date"])
data class ShiftOverride( data class ShiftOverride(
@@ -29,12 +28,3 @@ data class CustomAlarm(
val snoozeInterval: Int = 5, val snoozeInterval: Int = 5,
val snoozeRepeat: Int = 3 val snoozeRepeat: Int = 3
) )
@Entity(tableName = "annual_leave")
data class AnnualLeave(
@PrimaryKey
val id: Int = 1, // Single row for app-wide annual leave
val totalDays: Float, // 총 연차 (1~25)
val remainingDays: Float, // 남은 연차
val updatedAt: Long = System.currentTimeMillis()
)

View File

@@ -93,7 +93,6 @@ class FragmentSettingsAdditional : Fragment() {
// Tide Switch // Tide Switch
binding.switchTide.isChecked = prefs.getBoolean("show_tide", false) binding.switchTide.isChecked = prefs.getBoolean("show_tide", false)
loadAppVersion()
} }
private fun setupListeners() { private fun setupListeners() {
@@ -212,18 +211,6 @@ class FragmentSettingsAdditional : Fragment() {
} }
} }
private fun loadAppVersion() {
try {
val packageInfo = requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
val versionName = packageInfo.versionName
binding.tvAppVersion.text = "버전 $versionName"
} catch (e: Exception) {
binding.tvAppVersion.text = "버전 1.4.0"
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

@@ -4,19 +4,13 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.example.shiftalarm.databinding.FragmentSettingsLabBinding import com.example.shiftalarm.databinding.FragmentSettingsLabBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
class FragmentSettingsLab : Fragment() { class FragmentSettingsLab : Fragment() {
private var _binding: FragmentSettingsLabBinding? = null private var _binding: FragmentSettingsLabBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var isInitialLoad = true
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
@@ -26,86 +20,6 @@ class FragmentSettingsLab : Fragment() {
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupSpinner()
loadAnnualLeave()
}
private fun setupSpinner() {
// 1~25일 선택 가능한 어댑터 설정
val daysList = (1..25).map { "${it}" }.toList()
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, daysList)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spinnerTotalDays.adapter = adapter
// 선택 시 자동 저장
binding.spinnerTotalDays.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (!isInitialLoad) {
val totalDays = position + 1 // 0-indexed to actual days
saveAnnualLeave(totalDays)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// Do nothing
}
}
}
private fun loadAnnualLeave() {
lifecycleScope.launch {
isInitialLoad = true
val repo = ShiftRepository(requireContext())
val annualLeave = repo.getAnnualLeave()
annualLeave?.let {
// 저장된 값이 있으면 해당 위치 선택 (0-indexed)
binding.spinnerTotalDays.setSelection(it.totalDays.toInt() - 1)
binding.tvRemainingDays.text = formatRemainingDays(it.remainingDays)
} ?: run {
// Default: 15 days (index 14)
binding.spinnerTotalDays.setSelection(14)
binding.tvRemainingDays.text = "15"
}
// 초기 로드 완료 후 플래그 변경 (약간의 딜레이로 초기 선택 이벤트 방지)
delay(300)
isInitialLoad = false
}
}
private fun saveAnnualLeave(totalDays: Int) {
lifecycleScope.launch {
val repo = ShiftRepository(requireContext())
repo.recalculateAndSaveAnnualLeave(totalDays.toFloat())
val updated = repo.getAnnualLeave()
updated?.let {
binding.tvRemainingDays.text = formatRemainingDays(it.remainingDays)
showCustomToast(requireContext(), "총 연차 ${totalDays}일로 저장되었습니다 (남은 연차: ${formatRemainingDays(it.remainingDays)}일)")
}
}
}
/**
* 남은 연차 표시 형식 개선
* - 정수면 정수로 표시 (예: 22)
* - 소숫점 있으면 소숫점 표시 (예: 21.5)
*/
private fun formatRemainingDays(days: Float): String {
return if (days == days.toInt().toFloat()) {
// 정수인 경우
days.toInt().toString()
} else {
// 소숫점이 있는 경우 (0.5 등)
String.format("%.1f", days)
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

@@ -10,7 +10,6 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.GestureDetector import android.view.GestureDetector
import android.view.MotionEvent import android.view.MotionEvent
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
@@ -81,7 +80,8 @@ class MainActivity : AppCompatActivity() {
} }
binding.prevMonth.setOnClickListener { binding.prevMonth.setOnClickListener {
animateMonthTransition(-1) currentViewMonth = currentViewMonth.minusMonths(1)
updateCalendar()
} }
binding.monthTitle.setOnClickListener { binding.monthTitle.setOnClickListener {
@@ -89,7 +89,8 @@ class MainActivity : AppCompatActivity() {
} }
binding.nextMonth.setOnClickListener { binding.nextMonth.setOnClickListener {
animateMonthTransition(1) currentViewMonth = currentViewMonth.plusMonths(1)
updateCalendar()
} }
binding.btnToday.setOnClickListener { binding.btnToday.setOnClickListener {
@@ -201,17 +202,6 @@ class MainActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
syncAllAlarms(this@MainActivity) syncAllAlarms(this@MainActivity)
} }
// 연차 정보 업데이트
lifecycleScope.launch {
val repo = ShiftRepository(this@MainActivity)
val annualLeave = repo.getAnnualLeave()
annualLeave?.let {
binding.tvAnnualLeave.text = "연차: ${formatRemainingDays(it.remainingDays)}"
} ?: run {
binding.tvAnnualLeave.text = "연차: --"
}
}
} }
private fun showMonthYearPicker() { private fun showMonthYearPicker() {
@@ -326,15 +316,8 @@ class MainActivity : AppCompatActivity() {
binding.todayStatusText.text = "오늘의 근무: $shiftForViewingTeam$teamSuffix" binding.todayStatusText.text = "오늘의 근무: $shiftForViewingTeam$teamSuffix"
binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_secondary)) binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_secondary))
} }
// Update Annual Leave display
val annualLeave = withContext(Dispatchers.IO) { repo.getAnnualLeave() }
annualLeave?.let {
binding.tvAnnualLeave.text = "연차: ${formatRemainingDays(it.remainingDays)}"
} ?: run {
binding.tvAnnualLeave.text = "연차: --"
}
} }
updateOtherTeamsLayout(today, factory, prefs) updateOtherTeamsLayout(today, factory, prefs)
} }
@@ -390,13 +373,13 @@ class MainActivity : AppCompatActivity() {
setMargins(4, 0, 4, 0) setMargins(4, 0, 4, 0)
} }
setOnClickListener { setOnClickListener {
if (currentViewTeam != t) { if (currentViewTeam != t) {
currentViewTeam = t currentViewTeam = t
updateCalendar() updateCalendar()
showCustomToast(context, "${t}반 근무표를 표시합니다.") Toast.makeText(context, "${t}반 근무표를 표시합니다.", Toast.LENGTH_SHORT).show()
} }
} }
} }
rowLayout.addView(textView) rowLayout.addView(textView)
} }
@@ -612,7 +595,6 @@ class MainActivity : AppCompatActivity() {
android.widget.Toast.makeText(this, "원래 근무로 복구되었습니다.", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(this, "원래 근무로 복구되었습니다.", android.widget.Toast.LENGTH_SHORT).show()
syncAllAlarms(this) syncAllAlarms(this)
updateCalendar() updateCalendar()
repo.updateRemainingAnnualLeave()
} }
"직접 입력" -> { "직접 입력" -> {
showCustomInputDialog(date, repo, team, factory) showCustomInputDialog(date, repo, team, factory)
@@ -635,8 +617,6 @@ class MainActivity : AppCompatActivity() {
else -> { else -> {
// New Types: 월차, 연차, 반월, 반년, 교육 -> Saved as Override with no time // New Types: 월차, 연차, 반월, 반년, 교육 -> Saved as Override with no time
repo.setOverride(date, selected, team, factory) repo.setOverride(date, selected, team, factory)
// 연차 계산을 먼저 수행하고 달력 업데이트
repo.updateRemainingAnnualLeave()
updateCalendar() updateCalendar()
syncAllAlarms(this) syncAllAlarms(this)
android.widget.Toast.makeText(this, "${selected}(으)로 기록되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(this, "${selected}(으)로 기록되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show()
@@ -706,58 +686,6 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, "⚠️ 루팅된 기기에서 시각적 오류나 알람 불안정이 발생할 수 있습니다.", Toast.LENGTH_LONG).show() Toast.makeText(this, "⚠️ 루팅된 기기에서 시각적 오류나 알람 불안정이 발생할 수 있습니다.", Toast.LENGTH_LONG).show()
} }
} }
private fun animateMonthTransition(direction: Int) {
val card = binding.calendarCard
val width = card.width.toFloat()
if (width == 0f) {
currentViewMonth = if (direction > 0) {
currentViewMonth.plusMonths(1)
} else {
currentViewMonth.minusMonths(1)
}
updateCalendar()
return
}
card.animate()
.translationX(if (direction > 0) -width else width)
.alpha(0.7f)
.scaleX(0.95f)
.scaleY(0.95f)
.setDuration(280)
.setInterpolator(AccelerateDecelerateInterpolator())
.withEndAction {
currentViewMonth = if (direction > 0) {
currentViewMonth.plusMonths(1)
} else {
currentViewMonth.minusMonths(1)
}
updateCalendar()
card.translationX = if (direction > 0) width else -width
card.scaleX = 0.95f
card.scaleY = 0.95f
card.animate()
.translationX(0f)
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(280)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}
.start()
}
private fun formatRemainingDays(days: Float): String {
return if (days == days.toInt().toFloat()) {
// 정수인 경우
days.toInt().toString()
} else {
// 소숫점이 있는 경우 (0.5 등)
String.format("%.1f", days)
}
}
} }

View File

@@ -1,11 +1,6 @@
package com.example.shiftalarm package com.example.shiftalarm
import androidx.room.Dao import androidx.room.*
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
@Dao @Dao
interface ShiftDao { interface ShiftDao {
@@ -62,17 +57,4 @@ interface ShiftDao {
@Query("DELETE FROM custom_alarms") @Query("DELETE FROM custom_alarms")
suspend fun clearCustomAlarms() suspend fun clearCustomAlarms()
// Annual Leave Queries
@Query("SELECT * FROM annual_leave WHERE id = 1")
suspend fun getAnnualLeave(): AnnualLeave?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAnnualLeave(annualLeave: AnnualLeave)
@Query("UPDATE annual_leave SET remainingDays = :remainingDays, updatedAt = :timestamp WHERE id = 1")
suspend fun updateRemainingDays(remainingDays: Float, timestamp: Long = System.currentTimeMillis())
@Query("DELETE FROM annual_leave")
suspend fun clearAnnualLeave()
} }

View File

@@ -57,55 +57,4 @@ class ShiftRepository(private val context: Context) {
suspend fun clearAllCustomAlarms() = withContext(Dispatchers.IO) { suspend fun clearAllCustomAlarms() = withContext(Dispatchers.IO) {
dao.clearCustomAlarms() dao.clearCustomAlarms()
} }
// Annual Leave
suspend fun calculateUsedAnnualLeave(): Float = withContext(Dispatchers.IO) {
val currentYear = java.time.Year.now(java.time.ZoneId.of("Asia/Seoul")).toString()
val overrides = dao.getAllOverrides()
var usedDays = 0f
for (override in overrides) {
if (override.date.startsWith(currentYear)) {
when (override.shift) {
"연차" -> usedDays += 1f
"반년" -> usedDays += 0.5f
}
}
}
usedDays
}
suspend fun getAnnualLeave(): AnnualLeave? = withContext(Dispatchers.IO) {
dao.getAnnualLeave()
}
suspend fun recalculateAndSaveAnnualLeave(totalDays: Float) {
val usedDays = calculateUsedAnnualLeave()
val remainingDays = totalDays - usedDays
dao.insertAnnualLeave(AnnualLeave(
id = 1,
totalDays = totalDays,
remainingDays = remainingDays
))
}
suspend fun updateRemainingAnnualLeave() {
val annualLeave = dao.getAnnualLeave()
val usedDays = calculateUsedAnnualLeave()
if (annualLeave != null) {
val remainingDays = annualLeave.totalDays - usedDays
dao.insertAnnualLeave(annualLeave.copy(remainingDays = remainingDays))
} else {
// AnnualLeave가 없으면 기본값 15일로 생성
dao.insertAnnualLeave(AnnualLeave(
id = 1,
totalDays = 15f,
remainingDays = 15f - usedDays
))
}
}
} }

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 배경색: 다크모드에서도 잘 보이도록 surface 색상 사용 -->
<solid android:color="#CC333333" />
<corners android:radius="16dp" />
<stroke
android:width="1dp"
android:color="@color/outline" />
</shape>

View File

@@ -8,7 +8,7 @@
<shape android:shape="oval"> <shape android:shape="oval">
<stroke android:width="1.5dp" android:color="@color/shift_red"/> <stroke android:width="1.5dp" android:color="@color/shift_red"/>
<solid android:color="@android:color/transparent"/> <solid android:color="@android:color/transparent"/>
<size android:width="52dp" android:height="52dp"/> <size android:width="44dp" android:height="44dp"/>
</shape> </shape>
</item> </item>
</layer-list> </layer-list>

View File

@@ -2,5 +2,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> android:shape="oval">
<solid android:color="@color/primary" /> <solid android:color="@color/primary" />
<size android:width="52dp" android:height="52dp" /> <size android:width="44dp" android:height="44dp" />
</shape> </shape>

View File

@@ -2,5 +2,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> android:shape="oval">
<stroke android:width="1.5dp" android:color="@color/primary" /> <stroke android:width="1.5dp" android:color="@color/primary" />
<size android:width="52dp" android:height="52dp" /> <size android:width="44dp" android:height="44dp" />
</shape> </shape>

View File

@@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="52dp" android:width="44dp"
android:height="52dp" android:height="44dp"
android:viewportWidth="52" android:viewportWidth="44"
android:viewportHeight="52"> android:viewportHeight="44">
<!-- Left Half Red --> <!-- Left Half Red -->
<path <path
android:name="left_half" android:name="left_half"
android:fillColor="@color/shift_red" android:fillColor="@color/shift_red"
android:pathData="M26,0 A26,26 0 0 0 26,52 L26,0 Z" /> android:pathData="M22,0 A22,22 0 0 0 22,44 L22,0 Z" />
</vector> </vector>

View File

@@ -63,7 +63,7 @@
android:id="@+id/calendarCard" android:id="@+id/calendarCard"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginHorizontal="3dp" android:layout_marginHorizontal="12dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:cardCornerRadius="28dp" app:cardCornerRadius="28dp"
app:cardElevation="0dp" app:cardElevation="0dp"
@@ -160,20 +160,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/tvAnnualLeave"/> app:layout_constraintEnd_toStartOf="@id/btnTideLocation"/>
<TextView
android:id="@+id/tvAnnualLeave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="연차: 0.0"
android:textSize="12sp"
android:textStyle="bold"
android:textColor="@color/primary"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toStartOf="@id/btnTideLocation"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<androidx.appcompat.widget.AppCompatButton <androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnTideLocation" android:id="@+id/btnTideLocation"
@@ -240,7 +227,7 @@
android:id="@+id/otherTeamsCard" android:id="@+id/otherTeamsCard"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="3dp" android:layout_marginHorizontal="12dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardCornerRadius="20dp" app:cardCornerRadius="20dp"
app:cardElevation="0dp" app:cardElevation="0dp"
@@ -274,4 +261,5 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/bg_custom_toast"
android:paddingHorizontal="20dp"
android:paddingVertical="12dp"
android:gravity="center">
<TextView
android:id="@+id/toastText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@android:color/white"
android:maxLines="2"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -29,7 +29,7 @@
android:text="알람 추가" android:text="알람 추가"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/text_primary" android:textColor="@color/black"
android:layout_centerInParent="true"/> android:layout_centerInParent="true"/>
<TextView <TextView
@@ -80,7 +80,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:cardCornerRadius="24dp" app:cardCornerRadius="24dp"
app:cardElevation="0dp" app:cardElevation="0dp"
app:cardBackgroundColor="@color/surface" app:cardBackgroundColor="@color/white"
android:layout_marginBottom="16dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
@@ -116,7 +116,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:cardCornerRadius="24dp" app:cardCornerRadius="24dp"
app:cardElevation="0dp" app:cardElevation="0dp"
app:cardBackgroundColor="@color/surface" app:cardBackgroundColor="@color/white"
android:layout_marginBottom="16dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:id="@+id/btnSelectSound" android:id="@+id/btnSelectSound"
@@ -162,7 +162,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:cardCornerRadius="24dp" app:cardCornerRadius="24dp"
app:cardElevation="0dp" app:cardElevation="0dp"
app:cardBackgroundColor="@color/surface" app:cardBackgroundColor="@color/white"
android:layout_marginBottom="16dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -56,32 +56,16 @@
<TextView android:id="@+id/btnYaMat" style="@style/ShiftCircleButton" android:text="야맞" android:textSize="13sp" android:textColor="@color/shift_yamat"/> <TextView android:id="@+id/btnYaMat" style="@style/ShiftCircleButton" android:text="야맞" android:textSize="13sp" android:textColor="@color/shift_yamat"/>
<!-- Row 2 --> <!-- Row 2 -->
<TextView android:id="@+id/btnOff" style="@style/ShiftCircleButton" android:text="휴" android:textColor="@color/shift_off" <TextView android:id="@+id/btnOff" style="@style/ShiftCircleButton" android:text="휴" android:textColor="@color/shift_off"/>
app:layout_constraintTop_toTopOf="parent" <TextView android:id="@+id/btnWolcha" style="@style/ShiftCircleButton" android:text="월차" android:textSize="13sp" android:textColor="@color/secondary"/>
app:layout_constraintStart_toStartOf="parent"/> <TextView android:id="@+id/btnYeoncha" style="@style/ShiftCircleButton" android:text="연차" android:textSize="13sp" android:textColor="@color/secondary"/>
<TextView android:id="@+id/btnWolcha" style="@style/ShiftCircleButton" android:text="월" android:textSize="13sp" android:textColor="@color/secondary" <TextView android:id="@+id/btnBanwol" style="@style/ShiftCircleButton" android:text="월" android:textSize="13sp" android:textColor="@color/shift_red"/>
app:layout_constraintTop_toTopOf="parent" <TextView android:id="@+id/btnBannyeon" style="@style/ShiftCircleButton" android:text="반년" android:textSize="13sp" android:textColor="@color/shift_red"/>
app:layout_constraintStart_toStartOf="parent"/>
<TextView android:id="@+id/btnYeoncha" style="@style/ShiftCircleButton" android:text="연차" android:textSize="13sp" android:textColor="@color/secondary"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView android:id="@+id/btnBanwol" style="@style/ShiftCircleButton" android:text="반월" android:textSize="13sp" android:textColor="@color/shift_red"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView android:id="@+id/btnBannyeon" style="@style/ShiftCircleButton" android:text="반년" android:textSize="13sp" android:textColor="@color/shift_red"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<!-- Row 3 --> <!-- Row 3 -->
<TextView android:id="@+id/btnEdu" style="@style/ShiftCircleButton" android:text="교육" android:textSize="13sp" android:textColor="@color/primary" <TextView android:id="@+id/btnEdu" style="@style/ShiftCircleButton" android:text="교육" android:textSize="13sp" android:textColor="@color/primary"/>
app:layout_constraintTop_toTopOf="parent" <TextView android:id="@+id/btnReset" style="@style/ShiftCircleButton" android:text="초기" android:textSize="13sp" android:textColor="@color/text_secondary"/>
app:layout_constraintStart_toStartOf="parent"/> <TextView android:id="@+id/btnManual" style="@style/ShiftCircleButton" android:text="직접" android:textSize="14sp" android:textColor="@color/shift_gray"/>
<TextView android:id="@+id/btnReset" style="@style/ShiftCircleButton" android:text="초기" android:textSize="13sp" android:textColor="@color/text_secondary"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView android:id="@+id/btnManual" style="@style/ShiftCircleButton" android:text="직접" android:textSize="14sp" android:textColor="@color/shift_gray"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<androidx.constraintlayout.helper.widget.Flow <androidx.constraintlayout.helper.widget.Flow
android:id="@+id/gridFlow" android:id="@+id/gridFlow"

View File

@@ -20,7 +20,7 @@
android:text="날짜 이동" android:text="날짜 이동"
android:textSize="24sp" android:textSize="24sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/text_primary" android:textColor="@color/black"
android:layout_marginBottom="32dp" android:layout_marginBottom="32dp"
android:layout_gravity="center_horizontal"/> android:layout_gravity="center_horizontal"/>

View File

@@ -361,17 +361,5 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
<!-- App Version -->
<TextView
android:id="@+id/tvAppVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="버전 1.4.0"
android:textColor="@color/text_tertiary"
android:textSize="12sp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"/>
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>

View File

@@ -4,133 +4,33 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp" android:padding="24dp"
android:gravity="center_horizontal"> android:gravity="center">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_settings"
app:tint="@color/text_tertiary"
android:layout_marginBottom="16dp"
android:alpha="0.5"/>
<!-- Header Title -->
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="나의 연차 설정" android:text="실험실 기능 준비 중"
android:textSize="18sp" android:textSize="18sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/text_primary" android:textColor="@color/text_secondary"
android:layout_marginBottom="24dp"/> android:layout_marginBottom="8dp"/>
<!-- Total Annual Leave Setting -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@color/surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="총 연차"
android:textSize="14sp"
android:textColor="@color/text_secondary"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<Spinner
android:id="@+id/spinnerTotalDays"
android:layout_width="80dp"
android:layout_height="48dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="일"
android:textSize="14sp"
android:textColor="@color/text_tertiary"
android:layout_marginStart="4dp"/>
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Remaining Annual Leave Display -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="@color/surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="남은 연차"
android:textSize="14sp"
android:textColor="@color/text_secondary"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="bottom">
<TextView
android:id="@+id/tvRemainingDays"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="15"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="@color/primary"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="일"
android:textSize="14sp"
android:textColor="@color/text_tertiary"
android:layout_marginStart="4dp"/>
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Calculation Info -->
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="※ 연차: -1일 / 반년: -0.5일 차감" android:text="더욱 편리한 기능을 개발하고 있습니다.\n다음 업데이트를 기대해 주세요!"
android:textSize="12sp" android:textSize="14sp"
android:textColor="@color/text_tertiary" android:textColor="@color/text_tertiary"
android:layout_marginBottom="8dp" android:gravity="center"
android:gravity="center"/> android:lineSpacingExtra="4dp"/>
<!-- 기능추가중 Notice -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="기능추가중"
android:textSize="11sp"
android:textColor="@color/primary"
android:layout_marginBottom="24dp"
android:gravity="center"/>
</LinearLayout> </LinearLayout>

View File

@@ -38,9 +38,8 @@
<!-- Shift Abbreviation Circular Indicator (Center) --> <!-- Shift Abbreviation Circular Indicator (Center) -->
<TextView <TextView
android:id="@+id/shiftChar" android:id="@+id/shiftChar"
android:layout_width="48dp" android:layout_width="40dp"
android:layout_height="48dp" android:layout_height="40dp"
android:gravity="center" android:gravity="center"
android:text="주" android:text="주"
android:textSize="15sp" android:textSize="15sp"

View File

@@ -1,28 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Shift Alarm</string> <string name="app_name">shiftring</string>
<string name="team_selection">Team selection</string>
<string name="current_shift">Current shift: %1$s</string>
<string name="next_shift">Next shift: %1$s</string>
<string name="alarm_status">Alarm Status</string>
<string name="company_selection">Company selection</string>
<string name="tab_basic">Basic Settings</string>
<string name="tab_alarm">Alarm Settings</string>
<string name="tab_additional">Extras</string>
<string name="tab_lab">Leave Management</string>
<string-array name="factory_array">
<item>Jeonju</item>
<item>Nonsan</item>
</string-array>
<string-array name="team_array">
<item>A Team</item>
<item>B Team</item>
<item>C Team</item>
<item>D Team</item>
</string-array>
<string-array name="theme_array">
<item>System Settings</item>
<item>Light</item>
<item>Dark</item>
</string-array>
</resources> </resources>

View File

@@ -9,7 +9,7 @@
<string name="tab_basic">기본 설정</string> <string name="tab_basic">기본 설정</string>
<string name="tab_alarm">알람 설정</string> <string name="tab_alarm">알람 설정</string>
<string name="tab_additional">부가기능</string> <string name="tab_additional">부가기능</string>
<string name="tab_lab">근무 관리</string> <string name="tab_lab">실험실</string>
<string-array name="factory_array"> <string-array name="factory_array">
<item>전주</item> <item>전주</item>

0
gradlew vendored Executable file → Normal file
View File

View File

@@ -1,7 +1,7 @@
{ {
"versionCode": 1149, "versionCode": 1124,
"versionName": "1.4.9", "versionName": "1.2.4",
"apkUrl": "https://git.webpluss.net/attachments/b8f51fa6-d26c-404a-b798-ee1c847a2be0", "apkUrl": "https://git.webpluss.net/sanjeok77/ShiftRing/releases/download/v1.2.4/app.apk",
"changelog": "v1.4.9: 버전 업데이트", "changelog": "v1.2.4: Deprecation 경고 수정 및 삭제 알람 버그 수정",
"forceUpdate": false "forceUpdate": false
} }