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
34 changed files with 648 additions and 1177 deletions

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 = 1143 versionCode = 1124
versionName = "1.4.3" versionName = "1.2.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
create("release") { create("release") {

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 (alarmDbId != -1) {
// 비동기로 DB 확인
if (!alarmExists) { val scope = CoroutineScope(Dispatchers.IO)
Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)") scope.launch {
scope.cancel() try {
return@launch 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) scheduleNextAlarm(context, alarm, team, factory)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}") 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 prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val targetDate = today.plusDays(i.toLong()) val team = prefs.getString("selected_team", "A") ?: "A"
val shift = repo.getShift(targetDate, val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A", scheduleNextAlarm(context, updatedAlarm, team, factory)
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}") 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)
}
/**
* 마지막 동기화 시간 확인 val repo = ShiftRepository(context)
*/ val team = prefs.getString("selected_team", "A") ?: "A"
fun getLastSyncTime(context: Context, key: String): Long { val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val alarms = repo.getAllCustomAlarms()
.getLong(key, 0) 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,242 +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()
}
}
* 다크모드 지원 커스텀 토스트 표시
*/
fun showCustomToast(context: Context, message: String, duration: Int = android.widget.Toast.LENGTH_SHORT) {
try {
// Use application context with theme for proper dark mode support
val themedContext = android.view.ContextThemeWrapper(context.applicationContext, R.style.Theme_ShiftAlarm)
val inflater = android.view.LayoutInflater.from(themedContext)
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.applicationContext)
toast.duration = duration
toast.view = layout
toast.setGravity(android.view.Gravity.BOTTOM or android.view.Gravity.CENTER_HORIZONTAL, 0, 100)
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()
// ============================================
// 사용자 알람 예약
// ============================================
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(":") // 범위 체크: -30일 ~ +30일 범위 내에서만 유효
if (dayOffset < -30 || dayOffset > 30) {
return -1 // 유효하지 않음
}
// ID 생성: alarmDbId * 100 + dayOffset (음수 처리)
// 예: alarmDbId=5, 오늘(dayOffset=0) -> 500
// 예: alarmDbId=5, 내일(dayOffset=1) -> 501
return alarmDbId * 100 + dayOffset + 30 // +30으로 음수 방지
}
// ============================================
// 다음 알람 예약 (오늘 또는 내일 중 다음 발생)
// ============================================
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 if (parts.size != 2) return
val hour = parts[0].toIntOrNull() ?: return val hour = parts[0].toIntOrNull() ?: return
val min = parts[1].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) 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)
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
// 과거 시간이면 예약 안함
if (alarmTime <= System.currentTimeMillis()) {
return
}
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", label) putExtra("EXTRA_ALARM_DB_ID", alarm.id)
putExtra("EXTRA_DATE", date.toString()) putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", time) putExtra("EXTRA_TIME", alarm.time)
putExtra("EXTRA_ALARM_ID", alarmId) putExtra("EXTRA_SHIFT_TYPE", alarm.shiftType)
putExtra("EXTRA_IS_CUSTOM", true) putExtra("EXTRA_SOUND", alarm.soundUri)
putExtra("EXTRA_UNIQUE_ID", uniqueId) // DB 검증용 putExtra("EXTRA_SNOOZE", alarm.snoozeInterval)
putExtra("EXTRA_SOUND", soundUri) putExtra("EXTRA_SNOOZE_REPEAT", alarm.snoozeRepeat)
putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent, context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min)) setExactAlarm(context, alarmTime, pendingIntent)
.withSecond(0).withNano(0) Log.d(TAG, "알람 예약 완료: $date ${alarm.time} (AlarmID: $alarmId, DbID: ${alarm.id})")
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
if (alarmTime > System.currentTimeMillis()) {
setExactAlarm(context, alarmTime, pendingIntent)
Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)")
}
} }
// ============================================ // ============================================
// 알람 취소 (전체 범위) // 알람 취소 - 특정 알람의 모든 예약 취소
// ============================================ // ============================================
fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) { 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
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
}
val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
}
// 현재 연도 기준으로 여러 해에 걸친 가능한 ID들 // 스누즈 알람도 취소
val currentYear = LocalDate.now(SEOUL_ZONE).year % 100 cancelSnoozeAlarm(context)
val years = listOf(currentYear - 1, currentYear, currentYear + 1)
for (year in years) { Log.d(TAG, "모든 알람 취소 완료")
if (year < 0) continue }
for (month in 1..12) {
for (day in 1..31) { // ============================================
try { // 날짜 알람 전체 동기화 (앱 시작/설정 변경 시)
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId // ============================================
val intent = Intent(context, AlarmReceiver::class.java).apply { suspend fun syncAllAlarms(context: Context) {
action = "com.example.shiftalarm.ALARM_TRIGGER" Log.d(TAG, "===== 알람 동기화 시작 =====")
}
val pendingIntent = PendingIntent.getBroadcast( val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
) Log.d(TAG, "마스터 알람 꺼짐, 모든 알람 취소")
alarmManager.cancel(pendingIntent) cancelAllAlarms(context)
pendingIntent.cancel() return
} catch (e: Exception) { }
// 무시 - 유효하지 않은 날짜 조합
} 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"
@@ -255,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) {
@@ -266,8 +317,7 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
return return
} }
} }
// 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)
@@ -279,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)
@@ -297,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 {
@@ -307,13 +355,35 @@ 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
) )
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, "스누즈 알람 취소")
} }
// ============================================ // ============================================
@@ -322,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)
} }
@@ -50,16 +51,17 @@ class CalendarAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = days[position] val item = days[position]
val context = holder.itemView.context val context = holder.itemView.context
if (item.date == null) { if (item.date == null) {
holder.itemView.visibility = View.INVISIBLE holder.itemView.visibility = View.INVISIBLE
return return
} }
holder.itemView.visibility = View.VISIBLE holder.itemView.visibility = View.VISIBLE
// 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,11 +4,8 @@ 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.Toast
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
class FragmentSettingsLab : Fragment() { class FragmentSettingsLab : Fragment() {
@@ -23,56 +20,6 @@ class FragmentSettingsLab : Fragment() {
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupNumberPicker()
loadAnnualLeave()
setupSaveButton()
}
private fun setupNumberPicker() {
binding.npTotalDays.apply {
minValue = 1
maxValue = 25
wrapSelectorWheel = false
}
}
private fun loadAnnualLeave() {
lifecycleScope.launch {
val repo = ShiftRepository(requireContext())
val annualLeave = repo.getAnnualLeave()
annualLeave?.let {
binding.npTotalDays.value = it.totalDays.toInt()
binding.tvRemainingDays.text = String.format("%.1f", it.remainingDays)
} ?: run {
// Default: 15 days
binding.npTotalDays.value = 15
binding.tvRemainingDays.text = "15.0"
}
}
}
private fun setupSaveButton() {
binding.btnSaveAnnualLeave.setOnClickListener {
val totalDays = binding.npTotalDays.value.toFloat()
lifecycleScope.launch {
val repo = ShiftRepository(requireContext())
repo.recalculateAndSaveAnnualLeave(totalDays)
val updated = repo.getAnnualLeave()
updated?.let {
binding.tvRemainingDays.text = String.format("%.1f", it.remainingDays)
showCustomToast(requireContext(), "연차가 저장되었습니다. (남은 연차: ${String.format("%.1f", it.remainingDays)}일)")
}
}
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

@@ -187,33 +187,22 @@ class MainActivity : AppCompatActivity() {
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A" currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A"
updateTideButtonVisibility()
updateCalendar()
// 일원화된 통합 권한 체크 실행 (신뢰도 100% 보장)
AlarmPermissionUtil.checkAndRequestAllPermissions(this)
updateTideButtonVisibility() // 설정 변경 시 즉시 반영을 위한 강제 동기화 (30일 스케줄링)
updateCalendar() lifecycleScope.launch {
syncAllAlarms(this@MainActivity)
// 일원화된 통합 권한 체크 실행 (신뢰도 100% 보장) }
AlarmPermissionUtil.checkAndRequestAllPermissions(this) }
// 설정 변경 시 즉시 반영을 위한 강제 동기화 (30일 스케줄링)
lifecycleScope.launch {
syncAllAlarms(this@MainActivity)
}
// 연차 정보 업데이트
lifecycleScope.launch {
val repo = ShiftRepository(this@MainActivity)
val annualLeave = repo.getAnnualLeave()
annualLeave?.let {
binding.tvAnnualLeave.text = "연차: ${String.format("%.1f", it.remainingDays)}"
} ?: run {
binding.tvAnnualLeave.text = "연차: --"
}
}
}
private fun showMonthYearPicker() { private fun showMonthYearPicker() {
val dialogView = layoutInflater.inflate(R.layout.dialog_month_year_picker, null) val dialogView = layoutInflater.inflate(R.layout.dialog_month_year_picker, null)
@@ -306,7 +295,7 @@ class MainActivity : AppCompatActivity() {
} }
val days = generateDaysForMonthWithData(currentViewMonth, currentViewTeam, factory, overrides, memos) val days = generateDaysForMonthWithData(currentViewMonth, currentViewTeam, factory, overrides, memos)
val adapter = CalendarAdapter(days, object : CalendarAdapter.OnDayClickListener { val adapter = CalendarAdapter(days, object : CalendarAdapter.OnDayClickListener {
override fun onDayClick(date: LocalDate, currentShift: String) { override fun onDayClick(date: LocalDate, currentShift: String) {
showDaySettingsDialog(date, currentShift) showDaySettingsDialog(date, currentShift)
@@ -325,17 +314,10 @@ class MainActivity : AppCompatActivity() {
binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.warning_red)) binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.warning_red))
} else { } else {
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 = "연차: ${String.format("%.1f", it.remainingDays)}"
} ?: run {
binding.tvAnnualLeave.text = "연차: --"
}
}
updateOtherTeamsLayout(today, factory, prefs) updateOtherTeamsLayout(today, factory, prefs)
} }
@@ -391,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)
} }
@@ -613,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)
@@ -639,7 +620,6 @@ class MainActivity : AppCompatActivity() {
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()
repo.updateRemainingAnnualLeave()
} }
} }
} }

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,47 +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()
annualLeave?.let {
val usedDays = calculateUsedAnnualLeave()
val remainingDays = it.totalDays - usedDays
dao.insertAnnualLeave(it.copy(remainingDays = remainingDays))
}
}
} }

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

@@ -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"
@@ -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

@@ -1,135 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"> android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<LinearLayout <ImageView
android:layout_width="match_parent" 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"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:text="실험실 기능 준비 중"
android:padding="24dp" android:textSize="18sp"
android:gravity="center_horizontal"> android:textStyle="bold"
android:textColor="@color/text_secondary"
android:layout_marginBottom="8dp"/>
<!-- 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="더욱 편리한 기능을 개발하고 있습니다.\n다음 업데이트를 기대해 주세요!"
android:text="나의 연차 설정" android:textSize="14sp"
android:textSize="20sp" android:textColor="@color/text_tertiary"
android:textStyle="bold" android:gravity="center"
android:textColor="@color/text_primary" android:lineSpacingExtra="4dp"/>
android:layout_marginBottom="32dp"/>
<!-- Total Annual Leave Setting --> </LinearLayout>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
app:cardCornerRadius="16dp"
app:cardElevation="4dp"
app:cardBackgroundColor="@color/surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="총 연차"
android:textSize="16sp"
android:textColor="@color/text_secondary"
android:layout_marginBottom="16dp"/>
<!-- NumberPicker for Total Days -->
<NumberPicker
android:id="@+id/npTotalDays"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="일"
android:textSize="14sp"
android:textColor="@color/text_tertiary"/>
</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="32dp"
app:cardCornerRadius="16dp"
app:cardElevation="4dp"
app:cardBackgroundColor="@color/surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="남은 연차"
android:textSize="16sp"
android:textColor="@color/text_secondary"
android:layout_marginBottom="8dp"/>
<TextView
android:id="@+id/tvRemainingDays"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.0"
android:textSize="36sp"
android:textStyle="bold"
android:textColor="@color/primary"
android:layout_marginBottom="4dp"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="일"
android:textSize="14sp"
android:textColor="@color/text_tertiary"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Calculation Info -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="※ 연차: -1일 차감 / 반년: -0.5일 차감"
android:textSize="13sp"
android:textColor="@color/text_tertiary"
android:layout_marginBottom="24dp"
android:gravity="center"/>
<!-- Save Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveAnnualLeave"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="저장"
android:textSize="16sp"
android:textStyle="bold"
app:cornerRadius="12dp"
android:backgroundTint="@color/primary"/>
</LinearLayout>
</ScrollView>

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,6 +1,6 @@
# Release signing configuration # Release signing configuration
# You should keep this file out of version control # You should keep this file out of version control
storePassword=my-password storePassword=my-password
keyAlias=my-alias keyAlias=my-alias
keyPassword=my-password keyPassword=my-password
storeFile=../release.jks storeFile=../release.jks

View File

@@ -1,7 +1,7 @@
{ {
"versionCode": 1143, "versionCode": 1124,
"versionName": "1.4.3", "versionName": "1.2.4",
"apkUrl": "https://git.webpluss.net/attachments/40d6f89e-58c9-41a2-a4d1-c72784a95b70", "apkUrl": "https://git.webpluss.net/sanjeok77/ShiftRing/releases/download/v1.2.4/app.apk",
"changelog": "v1.4.3: 달력 레이아웃 1.3.0 버전처럼 복원 (마진 12dp, 고정 높이)", "changelog": "v1.2.4: Deprecation 경고 수정 및 삭제 알람 버그 수정",
"forceUpdate": false "forceUpdate": false
} }