59 Commits

Author SHA1 Message Date
dcbc14b8a1 chore: 버전 업데이트 v1.5.4 (1154)
- versionCode: 1153 → 1154

- versionName: 1.5.3 → 1.5.4

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:49:12 +09:00
46400ae58a fix: 다크모드 UI 개선
- 날짜 이동 팝업 배경을 theme-aware 색상으로 변경

- 토스트 배경 및 글자색 theme-aware로 수정

- 다크모드에서 텍스트 가시성 개선

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:49:05 +09:00
e190ac76b5 perf: 달력 월이동 애니메이션 속도 개선
- 애니메이션 지속시간: 280ms → 150ms로 단축

- 더 빠른 월 이동 경험 제공

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:48:58 +09:00
e62f21c86a feat: 연장근무를 해당월 토요일 횟수 기본값으로 표시
- 기존: 실제 근무한 토요일만 계산

- 변경: 해당 월의 토요일 개수 × 2시간으로 기본 표시

- 예: 4개 토요일 → 8시간, 5개 토요일 → 10시간

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:48:50 +09:00
9819af3111 chore: version.json APK URL 업데이트 (v1.5.3)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:34:14 +09:00
73e954c8f4 chore: 버전 업데이트 v1.5.3 (1153)
- versionCode: 1152 → 1153

- versionName: 1.5.2 → 1.5.3

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:33:34 +09:00
734aca3ec7 ui: 연장근무 표시 문구 개선
- '토요일 연장근무' → '연장근무'로 변경

- 설명 텍스트도 일관되게 수정

- 사용 설명서 업데이트

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:33:27 +09:00
5e828d344c fix: 스와이프 월 이동 버그 수정
- onInterceptTouchEvent에서 false 반환하도록 수정

- RecyclerView 터치 이벤트 차단 문제 해결

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:33:20 +09:00
0e3f3641b7 chore: version.json APK URL 업데이트 (v1.5.2)
- 새로운 APK 다운로드 URL 반영

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:27:56 +09:00
139274067c chore: 버전 업데이트 v1.5.2 (1152)
- versionCode: 1151 → 1152

- versionName: 1.5.1 → 1.5.2

- CHANGELOG.md v1.5.2 업데이트 내역 추가

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:21:41 +09:00
0f58376c25 docs: 사용 설명서에 연차 및 토요일 연장근무 기능 추가
- 근무 관리 섹션 추가 (연차 설정 및 토요일 연장근무 계산)

- 섹션 번호 정리 (중복 번호 수정)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:21:34 +09:00
9421f67d13 fix: 달력 스와이프 애니메이션 버그 수정
- GestureDetector의 onDown 메서드가 true를 반환하도록 수정

- onInterceptTouchEvent에서 gestureDetector.onTouchEvent(e) 반환값을 리턴하도록 수정

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:21:26 +09:00
92ff23c297 chore: version.json APK URL 업데이트
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 06:09:06 +09:00
6bb31bfbb9 chore: 버전 업데이트 v1.5.1 (1151)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 03:37:01 +09:00
1866c67d5e fix: 업데이트 무한 버그 수정
- 동일 버전 확인 시 불필요한 토스트 표시 개선
- serverVersionCode == currentVersionCode 조건 추가

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 03:36:07 +09:00
ae4d31dafe feat: 토요일 연장근무 계산 기능 추가
- ShiftRepository에 getSaturdayOvertimeHours() 메소드 추가
- FragmentSettingsLab에 토요일 연장근무 시간 표시 카드 추가
- 토요일 수 * 2시간 계산 로직 구현

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 03:34:23 +09:00
8f2ef43360 fix: 달력 스와이프 애니메이션 버그 수정
- 스와이프 제스처로 월 이동 시 animateMonthTransition() 호출하도록 수정
- 이전/다음 월 버튼과 동일한 애니메이션 적용

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 03:31:09 +09:00
d4fc184d9f chore: version.json v1.5.0 업데이트
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 02:00:50 +09:00
73ebc0a8b1 build: v1.5.0 APK 업데이트
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:40:13 +09:00
4568b3237f chore: 버전 업데이트 v1.5.0 (1150)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:39:14 +09:00
55b2d376df docs: v1.5.0 업데이트 내역 추가
- 달력 월 이동 애니메이션 대폭 개선 내용 기록
- AccelerateDecelerateInterpolator 및 스케일 효과 적용 내역
- 사용 설명서 전면 개편 내역

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:38:41 +09:00
85a6908d7f docs: 사용 설명서 전면 개편
- 현재 기능 정확히 반영하도록 전면 업데이트
- 섹션 구조 명확화 (7개 섹션)
- 타 반 근무 확인, 메모 표시, 백업/복구 기능 상세 설명
- 화면 테마 설정, 앱 공유 및 초기화 기능 추가

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:38:10 +09:00
a3a4582cb1 feat: 달력 월 이동 애니메이션 개선
- AccelerateDecelerateInterpolator 적용으로 자연스러운 가속/감속 곡선 구현
- 스케일 효과(scaleX/scaleY 0.95) 추가로 깊이감 있는 화면 전환
- 애니메이션 지속시간 200ms → 280ms로 조정
- 알파 페이드 0.5 → 0.7로 조정하여 가독성 향상

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:37:21 +09:00
7774c20a45 chore: 버전 업데이트 v1.4.9 (1149)
- versionCode: 1148 → 1149
- versionName: 1.4.8 → 1.4.9

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:54:05 +09:00
cce9c48345 fix: 애니메이션 개선 및 APK 업데이트
- ViewPropertyAnimator 사용으로 더 부드러운 전환
- alpha 애니메이션 추가로 시각적 효과 개선
- APK URL 업데이트

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:50:38 +09:00
7462656c15 feat: 달력 월 이동 시 부드러운 화면 전환 애니메이션 추가
- 월 이동 버튼 클릭 시 슬라이드 애니메이션 적용
- 설정 화면 탭 전환처럼 부드러운 전환 효과
- 버전 업데이트 v1.4.8 (1148)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:48:11 +09:00
30b150bb81 fix: 버전업 오류 수정 및 UI 개선
- 난부 버전 1147 (1.4.7)로 업데이트 (중복 버전 코드 제거)
- 휴가 관리 탭 → 근무 관리로 명칭 변경
- 남은 연차 하단에 '기능추가중' 표시 추가

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:34:35 +09:00
f586d74759 fix: 휴가관리 UI 정리 및 자동 저장 기능 추가
- 중복된 '연차: -1일 / 반년: -0.5일 차감' 텍스트 제거
- 중복된 Spinner/NumberPicker 뷰 정리
- 저장 버튼 제거하고 Spinner 선택 시 자동 저장되도록 개선
- 버전 업데이트 v1.4.6 (1146)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:27:42 +09:00
608109e437 chore: 버전 업데이트 v1.4.5 (1145)
- versionCode: 1144 → 1145
- versionName: 1.4.4 → 1.4.5

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:20:18 +09:00
48cfbf3473 fix: 달력 아이템 동그라미 크기 확대
- 양쪽 마진 축소로 인한 공백 제거
- shiftChar 크기: 40dp → 48dp

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:03:34 +09:00
911ff3003f feat: 휴가 관리 NumberPicker → Spinner 변경 + 저장 버튼 복원
- NumberPicker 대신 Spinner(드롭다운) 사용
- 값 변경 시 자동 저장 제거하고 저장 버튼 복원
- 더 직관적인 UI 제공

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:02:38 +09:00
975c2cc9f6 fix: 연차/반년 최초 적용 타이밍 문제 수정
- setOverride 후 updateRemainingAnnualLeave()를 먼저 호출하고 updateCalendar() 호출
- 연차 계산이 완료된 후 화면 갱신되도록 순서 변경

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 00:02:27 +09:00
b5a6abee97 chore: 버전 업데이트 v1.4.4 (1144)
- versionCode: 1143 → 1144
- versionName: 1.4.3 → 1.4.4

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:52:08 +09:00
fe050808b4 feat: 메인 화면 연차 표시 형식 개선
- 정수일 때 정수로 표시 (22)
- 0.5일 때 소숫점 표시 (21.5)
- formatRemainingDays() 함수 추가

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:45:07 +09:00
08c130f448 feat: 휴가 관리 컴팩트화 + 자동 저장 + 소숫점 표시 개선
- 레이아웃 컴팩트하게 변경 (패딩/마진 축소, 가로 배치)
- 저장 버튼 제거하고 NumberPicker 변경 시 자동 저장
- 소숫점 없을 때 정수로 표시 (22), 0.5일 때 소숫점 표시 (21.5)
- ScrollView 제거 (불필요한 스크롤 최소화)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:44:57 +09:00
7d50263e65 fix: 연차/반년 최초 적용 안되는 문제 수정
- updateRemainingAnnualLeave()에서 AnnualLeave 없을 때 기본값 15일로 생성
- 총 연차 설정 없이도 달력에서 연차/반년 사용 가능하도록 개선

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:44:45 +09:00
693704686f fix: 달력 양쪽 마진 3dp로 축소
- calendarCard marginHorizontal: 12dp → 3dp
- otherTeamsCard marginHorizontal: 12dp → 3dp

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:44:35 +09:00
ccbd943c56 chore: 버전 업데이트 v1.4.3 (1143)
- versionCode: 1142 → 1143
- versionName: 1.4.2 → 1.4.3

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:32:09 +09:00
761e02fd94 fix: 달력 동적 높이 계산 제거 (1.3.0 스타일로 복원)
- RecyclerView 높이 동적 계산 코드 제거
- CalendarAdapter rowCount 파라미터 제거
- 아이템 높이를 XML에서 고정하도록 변경
- 달력이 자연스럽게 화면에 채워지도록 개선

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:30:28 +09:00
eda76a0ef6 fix: 달력 레이아웃 1.3.0 버전처럼 복원
- 양쪽 마진 4dp → 12dp 복원
- 달력 아이템 높이 92dp 고정으로 복원
- shiftChar 크기 48dp → 40dp 복원
- 달력 화면이 ConstraintLayout에 자연스럽게 채워지도록 수정

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:30:19 +09:00
8a2dacb104 chore: 버전 업데이트 v1.4.2 (1142)
- versionCode: 1141 → 1142
- versionName: 1.4.1 → 1.4.2

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:21:19 +09:00
2f4b2ebe4c fix: 연차 설정 화면 ScrollView 추가
- 작은 화면에서 저장 버튼이 잘리지 않도록 ScrollView로 감싸기
- 저장 버튼 가시성 확보

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:18:31 +09:00
89068b4d05 fix: 토스트 다크모드 완전 지원
- 배경색을 반투명 회색(#CC333333)으로 변경하여 라이트/다크 모두 가독성 확보
- 텍스트 색상을 흰색(@android:color/white)으로 변경
- showCustomToast() 함수 개선

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:18:22 +09:00
7835d0ab65 fix: 달력 5행 고정높이, 6행 스크롤 지원
- 5행: RecyclerView 높이를 행 수에 맞게 고정, 스크롤 없음
- 6행: RecyclerView 높이를 6행으로 고정, 스크롤 활성화
- 아이템 높이를 화면 너비 기준 정사각형으로 통일

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:18:12 +09:00
819495323e chore: 버전 업데이트 v1.4.1 (1141)
- versionCode: 1140 → 1141
- versionName: 1.4.0 → 1.4.1
- dialog_day_settings.xml 레이아웃 개선

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 23:01:36 +09:00
5a0f6de646 fix: 연차 저장 및 계산 로직 수정
- calculateUsedAnnualLeave()에서 Seoul 타임존 명시
- FragmentSettingsLab 토스트를 커스텀 토스트로 변경
- 연차 저장 시 남은 연차 자동 계산 로직 개선

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 22:46:05 +09:00
f884e991a3 feat: 다크모드 지원 커스텀 토스트 구현
- showCustomToast() 함수 추가 (AlarmUtils)
- 커스텀 토스트 레이아웃 및 배경 drawable 추가
- values/colors.xml 및 values-night/colors.xml 리소스 사용

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 22:44:48 +09:00
b8454f76d1 feat: 달력 5행 동적 높이 조정 및 연차 메인 화면 연동
- 달력 5행일 때 아이템 높이를 동적으로 계산하여 화면 꽉 채우기
- 메인 화면 tvAnnualLeave에 남은 연차 표시 기능 추가
- onResume에서 연차 정보 자동 업데이트

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 22:43:44 +09:00
a4482f0b7b fix: DB 마이그레이션 추가 및 연차 저장/다크모드/달력 UI 개선
- DB 마이그레이션으로 기존 알람 데이터 보존
- 연차 저장 문제 수정 (원래대로/연차 설정 시 updateRemainingAnnualLeave 호출)
- 달력 5행 스크롤 없이 표시 (85dp 높이)
- 알람 편집/월년 선택 다크모드 지원
- 설정 앱정보 버전 동기화 표시

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:47:39 +09:00
29cc215346 chore: 버전 업데이트 v1.4.0 (1140)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:26:30 +09:00
666e38558d ui: 근무 표시 동그라미 크기 확대 (44dp → 52dp)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:26:22 +09:00
8e7f212352 feat: 남은 연차 표시 및 달력 UI 개선 (넓은 화면, 큰 근무 표시)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:26:14 +09:00
639b22948b feat: 휴가 관리 화면 구현 (휠 다이얼 연차 설정)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:26:05 +09:00
03c3fcd6f0 feat: 연차 관리 기능 추가 (AnnualLeave 엔티티, DAO, Repository)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 21:25:55 +09:00
b832f87a7b fix: version.json 중복 JSON 제거
- v1.2.5 JSON 블록 삭제
- v1.3.0만 유지

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 20:19:17 +09:00
0e60b62fd2 feat: v1.3.0 - versionCode 기반 업데이트 체크
- 버전 1.2.5 → 1.3.0 (versionCode 1130)
- AppUpdateManager: versionCode로 업데이트 비교
- versionName 비교 로직 제거
- 더 정확한 업데이트 감지

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:55:57 +09:00
597880e807 fix: build.gradle 버전 1.2.5로 업데이트
- versionCode: 1124 → 1125
- versionName: 1.2.4 → 1.2.5
- 무한 업데이트 버그 수정

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:43:29 +09:00
6fb83848f5 docs: Gitea 릴리즈 작업 가이드 추가
- 릴리즈 생성 절차 문서화
- API 엔드포인트 및 예시 명령어 정리
- 버전 업데이트 체크리스트 포함
- 인증 정보 및 주의사항 기록

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:37:32 +09:00
fef630d266 chore: 버전 업데이트 v1.2.5
- versionCode: 1124 → 1125
- versionName: 1.2.4 → 1.2.5
- changelog 업데이트

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:26:08 +09:00
40 changed files with 1402 additions and 642 deletions

12
.env.local Normal file
View File

@@ -0,0 +1,12 @@
# 로컬 설정 파일
## 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"
```

BIN
.env.local:Zone.Identifier Normal file

Binary file not shown.

206
GITEA_RELEASE_GUIDE.md Normal file
View File

@@ -0,0 +1,206 @@
# 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

@@ -20,8 +20,8 @@ android {
applicationId = "com.example.shiftalarm" applicationId = "com.example.shiftalarm"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1124 versionCode = 1154
versionName = "1.2.4" versionName = "1.5.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

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

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, "===== 알람 수신 =====") Log.d(TAG, "===== 알람 수신 (Receiver) =====")
// 마스터 알람이 꺼져있으면 알람 무시 // 마스터 알람이 꺼져있으면 알람 무시
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
@@ -22,70 +22,37 @@ class AlarmReceiver : BroadcastReceiver() {
return return
} }
val action = intent?.action val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1
val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false
when (action) { // 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결)
"com.example.shiftalarm.SNOOZE" -> { if (isCustom && alarmId != -1) {
// 스누즈 알람 val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1)
startAlarm(context, intent) if (customAlarmId != -1) {
}
else -> {
// 일반 알람 - DB에서 여전히 유효한지 확인
val alarmDbId = intent?.getIntExtra("EXTRA_ALARM_DB_ID", -1) ?: -1
if (alarmDbId != -1) {
// 비동기로 DB 확인 // 비동기로 DB 확인
val scope = CoroutineScope(Dispatchers.IO) val scope = CoroutineScope(Dispatchers.IO)
scope.launch { scope.launch {
try {
val repo = ShiftRepository(context) val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms() val alarms = repo.getAllCustomAlarms()
val alarm = alarms.find { it.id == alarmDbId } val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled }
// 알람이 존재하지 않거나 비활성화되었으면 무시 if (!alarmExists) {
if (alarm == null) { Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)")
Log.w(TAG, "DB에서 알람을 찾을 수 없음 (삭제됨): ID=$alarmDbId")
scope.cancel() scope.cancel()
return@launch return@launch
} }
if (!alarm.isEnabled) { // 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작
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) startAlarm(context, intent)
scope.cancel() scope.cancel()
}
return
}
}
} catch (e: Exception) { // 일반 알람은 바로 직접 실행
Log.e(TAG, "알람 검증 중 오류", e)
scope.cancel()
}
}
} else {
// DB ID가 없는 경우 (테스트 알람 등) - 바로 실행
startAlarm(context, intent) startAlarm(context, intent)
} }
}
}
}
private fun startAlarm(context: Context, intent: Intent?) { private fun startAlarm(context: Context, intent: Intent?) {
// WakeLock 획득 (화면 켜기 및 Activity 실행 보장) // WakeLock 획득 (화면 켜기 및 Activity 실행 보장)
@@ -95,20 +62,15 @@ 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초 wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간
try { try {
val shiftType = intent?.getStringExtra("EXTRA_SHIFT_TYPE") ?: "근무" // 1. Foreground Service 시작 (알림 표시 및 시스템에 알람 실행 중 알림)
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", shiftType) putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
putExtra("EXTRA_SOUND", soundUri) putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
putExtra("EXTRA_SNOOZE", snoozeMin) putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -119,24 +81,28 @@ 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", shiftType) putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
putExtra("EXTRA_SOUND", soundUri) putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
putExtra("EXTRA_SNOOZE", snoozeMin) putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
// 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_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 실행 완료: $shiftType") Log.d(TAG, "AlarmActivity 실행 완료")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "AlarmActivity 실행 실패", e) Log.e(TAG, "AlarmActivity 실행 실패", e)
} }
@@ -145,7 +111,7 @@ class AlarmReceiver : BroadcastReceiver() {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 실행 실패", e) Log.e(TAG, "알람 실행 실패", e)
} finally { } finally {
// WakeLock 해제 // WakeLock은 Activity가 화면을 켜고 나서 해제
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,22 +4,25 @@ 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. 알람 추가/삭제/수정 시 즉시 AlarmManager에 반영 * 1. DB 작업과 AlarmManager 작업을 원자적으로 처리
* 2. 복잡한 예약 취소 로직 제거 - 단순한 ID 체계 사용 * 2. 실패 시 롤백 메커니즘 제공
* 3. 매일 자정 WorkManager가 다음날 알람 스케줄링 * 3. 동기화 상태 추적 및 재시도
*/ */
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 {
@@ -29,32 +32,64 @@ 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 prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val today = LocalDate.now(SEOUL_ZONE)
val team = prefs.getString("selected_team", "A") ?: "A" val customAlarms = repo.getAllCustomAlarms()
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" val addedAlarm = customAlarms.find { it.id == alarmId.toInt() }
val addedAlarm = alarm.copy(id = alarmId.toInt()) if (addedAlarm == null) {
scheduleNextAlarm(context, addedAlarm, team, factory) Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId")
return@withContext Result.failure(Exception("알람을 찾을 수 없습니다"))
}
if (addedAlarm.isEnabled) {
// 향후 30일치 예약
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (addedAlarm.shiftType == "기타" || addedAlarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
addedAlarm.id,
addedAlarm.shiftType,
addedAlarm.time,
addedAlarm.soundUri,
addedAlarm.snoozeInterval,
addedAlarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 AlarmManager 예약 완료: ID=$alarmId")
}
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_add_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 추가 실패", e) Log.e(TAG, "알람 추가 동기화 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 수정 * 알람 수정 동기화
* DB 수정 후 기존 예약 취소 후 재예약 * DB 수정 후 기존 AlarmManager 예약 취소 후 재예약
*/ */
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. 기존 예약 취소 // 1. 기존 AlarmManager 예약 취소
cancelAlarm(context, alarm.id) cancelAllCustomAlarmSchedules(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)
@@ -62,45 +97,70 @@ object AlarmSyncManager {
// 3. 활성화된 알람이면 재예약 // 3. 활성화된 알람이면 재예약
if (alarm.isEnabled) { if (alarm.isEnabled) {
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val today = LocalDate.now(SEOUL_ZONE)
val team = prefs.getString("selected_team", "A") ?: "A" for (i in 0 until 30) {
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" 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"
)
scheduleNextAlarm(context, alarm, team, factory) 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 삭제 전에 먼저!)
cancelAlarm(context, alarm.id) cancelAllCustomAlarmSchedules(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 {
@@ -110,67 +170,94 @@ 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"
)
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
val team = prefs.getString("selected_team", "A") ?: "A" scheduleCustomAlarm(
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" context,
targetDate,
scheduleNextAlarm(context, updatedAlarm, team, factory) 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 업데이트
cancelAlarm(context, alarm.id) cancelAllCustomAlarmSchedules(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, "전체 알람 동기화 시작")
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) // 1. 기존 모든 알람 취소
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) {
cancelAlarm(context, alarm.id) cancelAllCustomAlarmSchedules(context, alarm.id)
} }
Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}") Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}")
// 2. 마스터 알람이 꺼져있으면 여기서 종료 // 2. 활성화된 알람만 재예약
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { val enabledAlarms = allAlarms.filter { it.isEnabled }
Log.d(TAG, "마스터 알람 꺼짐, 동기화 종료") val today = LocalDate.now(SEOUL_ZONE)
return@withContext Result.success(SyncResult( val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
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) {
scheduleNextAlarm(context, alarm, team, factory) for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
scheduledCount++ scheduledCount++
} }
}
}
Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람") Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람, ${scheduledCount}개 예약")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_full_sync", System.currentTimeMillis())
Result.success(SyncResult( Result.success(SyncResult(
totalAlarms = allAlarms.size, totalAlarms = allAlarms.size,
@@ -184,77 +271,21 @@ object AlarmSyncManager {
} }
/** /**
* 특정 날짜 알람 스케줄링 (WorkManager에서 매일 자정 호출) * 동기화 상태 저장
*/ */
suspend fun scheduleAlarmsForDate(context: Context, date: java.time.LocalDate): Result<Unit> = withContext(Dispatchers.IO) { private fun saveSyncStatus(context: Context, key: String, timestamp: Long) {
try { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) .edit()
.putLong(key, timestamp)
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { .apply()
return@withContext Result.success(Unit)
} }
val repo = ShiftRepository(context) /**
val team = prefs.getString("selected_team", "A") ?: "A" * 마지막 동기화 시간 확인
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" */
val alarms = repo.getAllCustomAlarms() fun getLastSyncTime(context: Context, key: String): Long {
val shift = repo.getShift(date, team, factory) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getLong(key, 0)
// 해당 날짜의 기존 알람 모두 취소
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,293 +6,221 @@ 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"
// ============================================ /**
// 알람 ID 생성 - 단순화: alarmId * 10000 + 날짜 기반 오프셋 * 다크모드 지원 커스텀 토스트 표시
// ============================================ */
fun getAlarmId(alarmDbId: Int, date: LocalDate): Int { fun showCustomToast(context: Context, message: String, duration: Int = android.widget.Toast.LENGTH_SHORT) {
// alarmDbId: 1, 2, 3... try {
// 날짜 오프셋: 오늘=0, 내일=1, ... 6일 후=6 (일주일 단위로 재사용) val inflater = android.view.LayoutInflater.from(context)
val today = LocalDate.now(SEOUL_ZONE) val layout = inflater.inflate(R.layout.custom_toast, null)
val dayOffset = java.time.temporal.ChronoUnit.DAYS.between(today, date).toInt() val textView = layout.findViewById<android.widget.TextView>(R.id.toastText)
textView.text = message
// 범위 체크: -30일 ~ +30일 범위 내에서만 유효 val toast = android.widget.Toast(context)
if (dayOffset < -30 || dayOffset > 30) { toast.duration = duration
return -1 // 유효하지 않음 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 생성: alarmDbId * 100 + dayOffset (음수 처리) // ============================================
// 예: alarmDbId=5, 오늘(dayOffset=0) -> 500 // 알람 ID 생성
// 예: alarmDbId=5, 내일(dayOffset=1) -> 501 // ============================================
return alarmDbId * 100 + dayOffset + 30 // +30으로 음수 방지 fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int {
// Combine date and a unique ID from DB to avoid collisions
// Using (uniqueId % 1000) to keep it within a reasonable range
return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100)
} }
// ============================================ // ============================================
// 다음 알람 예약 (오늘 또는 내일 중 다음 발생) // 사용자 알람 예약
// ============================================ // ============================================
suspend fun scheduleNextAlarm( fun scheduleCustomAlarm(
context: Context, context: Context,
alarm: CustomAlarm, date: LocalDate,
team: String, uniqueId: Int,
factory: String shiftType: String,
time: String,
soundUri: String? = null,
snoozeMin: Int = 5,
snoozeRepeat: Int = 3
) { ) {
if (!alarm.isEnabled) { val alarmId = getCustomAlarmId(date, uniqueId)
Log.d(TAG, "알람 비활성화됨, 예약 안함: ID=${alarm.id}") val label = "사용자:$shiftType"
return
}
val repo = ShiftRepository(context) val parts = time.split(":")
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 minute = parts[1].toIntOrNull() ?: return val min = 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 intent = Intent(context, AlarmReceiver::class.java).apply {
val hour = parts[0].toIntOrNull() ?: return action = "com.example.shiftalarm.ALARM_TRIGGER"
val minute = parts[1].toIntOrNull() ?: return 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 targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, minute)) val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min))
.withSecond(0).withNano(0) .withSecond(0).withNano(0)
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli() val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
// 과거 시간이면 예약 안함 if (alarmTime > System.currentTimeMillis()) {
if (alarmTime <= System.currentTimeMillis()) {
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) setExactAlarm(context, alarmTime, pendingIntent)
Log.d(TAG, "알람 예약 완료: $date ${alarm.time} (AlarmID: $alarmId, DbID: ${alarm.id})") Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)")
}
} }
// ============================================ // ============================================
// 알람 취소 - 특정 알람의 모든 예약 취소 // 알람 취소 (전체 범위)
// ============================================ // ============================================
fun cancelAlarm(context: Context, alarmId: Int) { fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) {
val today = LocalDate.now(SEOUL_ZONE) val alarmId = getCustomAlarmId(date, uniqueId)
// 오늘, 내일 알람 취소 (다른 날짜는 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에서 삭제하기 전에 반드시 호출해야 합니다.
suspend fun cancelAlarmsForDate(context: Context, date: LocalDate) { * 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소
val repo = ShiftRepository(context) */
val alarms = repo.getAllCustomAlarms() fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) {
for (alarm in alarms) {
cancelAlarmForDate(context, alarm.id, date)
}
Log.d(TAG, "${date} 날짜의 모든 알람 취소 완료")
}
// ============================================
// 날짜에 대한 날짜 알람 스케줄링 (매일 자정 호출)
// ============================================
suspend fun scheduleAlarmsForDate(context: Context, date: LocalDate) {
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 repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val today = LocalDate.now(SEOUL_ZONE) val today = LocalDate.now(SEOUL_ZONE)
for (alarm in alarms) { // 1. 과거 30일치 취소 (혹시 모를 과거 예약)
// 오늘, 내일 알람 취소 for (i in -30 until 0) {
for (dayOffset in 0..1) { val targetDate = today.plusDays(i.toLong())
val date = today.plusDays(dayOffset.toLong()) cancelCustomAlarm(context, targetDate, uniqueId)
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()
}
} }
// 스누즈 알람도 취소 // 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버)
for (i in 0 until 365) {
val targetDate = today.plusDays(i.toLong())
cancelCustomAlarm(context, targetDate, uniqueId)
}
// 3. 스누즈 알람도 취소 (스누즈는 999999 ID 사용)
cancelSnoozeAlarm(context) cancelSnoozeAlarm(context)
Log.d(TAG, "모든 알람 취소 완료") // 4. 테스트 알람 취소 (테스트는 888888 ID 사용)
cancelTestAlarm(context)
// 5. 해당 uniqueId와 관련된 모든 가능한 PendingIntent 취소 (추가 안전장치)
cancelAllPendingIntentsForUniqueId(context, uniqueId)
Log.d(TAG, "알람 예약 완전 취소 완료 (ID: $uniqueId, 범위: -30일 ~ +365일)")
} }
// ============================================ /**
// 날짜 알람 전체 동기화 (앱 시작/설정 변경 시) * 특정 uniqueId에 대한 모든 가능한 PendingIntent를 취소합니다.
// ============================================ * 알람 ID 생성 공식의 역연산을 통해 모든 가능성을 커버합니다.
suspend fun syncAllAlarms(context: Context) { */
Log.d(TAG, "===== 알람 동기화 시작 =====") private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) // uniqueId % 100의 모든 가능한 값에 대해 취소 시도
val baseId = uniqueId % 100
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { // 현재 연도 기준으로 여러 해에 걸친 가능한 ID들
Log.d(TAG, "마스터 알람 꺼짐, 모든 알람 취소") val currentYear = LocalDate.now(SEOUL_ZONE).year % 100
cancelAllAlarms(context) val years = listOf(currentYear - 1, currentYear, currentYear + 1)
return
for (year in years) {
if (year < 0) continue
for (month in 1..12) {
for (day in 1..31) {
try {
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
} }
val pendingIntent = PendingIntent.getBroadcast(
val repo = ShiftRepository(context) context, alarmId, intent,
val team = prefs.getString("selected_team", "A") ?: "A" PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" )
val today = LocalDate.now(SEOUL_ZONE) alarmManager.cancel(pendingIntent)
val alarms = repo.getAllCustomAlarms() pendingIntent.cancel()
} catch (e: Exception) {
// 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, "===== 알람 동기화 완료: $scheduledCount 개 예약 =====") Log.d(TAG, "uniqueId $uniqueId 관련 모든 PendingIntent 취소 완료")
} }
// ============================================ /**
// 날짜 알람 취소 (날짜 날짜) * 스누즈 알람 취소
// ============================================ */
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"
@@ -306,9 +234,9 @@ internal fun cancelAlarmInternal(context: Context, alarmId: Int) {
} }
// ============================================ // ============================================
// 정밀 알람 설정 // 정밀 알람 설정 (setAlarmClock 우선)
// ============================================ // ============================================
internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) { private 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) {
@@ -318,6 +246,7 @@ internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: P
} }
} }
// 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)
@@ -329,14 +258,16 @@ internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: P
val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent) val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent)
alarmManager.setAlarmClock(clockInfo, pendingIntent) alarmManager.setAlarmClock(clockInfo, pendingIntent)
Log.d(TAG, "setAlarmClock 예약 성공") Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}")
} 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)
@@ -345,7 +276,7 @@ internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: P
} }
// ============================================ // ============================================
// 스누즈 알람 // 스누즈
// ============================================ // ============================================
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 {
@@ -355,7 +286,6 @@ 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
@@ -363,27 +293,6 @@ 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, "스누즈 알람 취소")
} }
// ============================================ // ============================================
@@ -392,16 +301,74 @@ fun cancelSnoozeAlarm(context: Context) {
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_TYPE", "테스트") putExtra("EXTRA_SHIFT", "테스트")
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,32 +1,27 @@
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 {
val tomorrow = LocalDate.now(SEOUL_ZONE).plusDays(1) syncAllAlarms(applicationContext)
// 다음날 알람 스케줄링
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, tomorrow)
// 오늘 남은 알람도 확인 (재부팅 등으로 누락됐을 수 있음)
val today = LocalDate.now(SEOUL_ZONE)
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, today)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("AlarmWorker", "알람 스케줄링 실패", e) e.printStackTrace()
Result.retry() Result.retry()
} }
} }

View File

@@ -1,9 +1,13 @@
package com.example.shiftalarm package com.example.shiftalarm
import android.content.Context import android.content.Context
import androidx.room.* import androidx.room.Database
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], version = 3, exportSchema = false) @Database(entities = [ShiftOverride::class, DailyMemo::class, CustomAlarm::class, AnnualLeave::class], version = 4, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun shiftDao(): ShiftDao abstract fun shiftDao(): ShiftDao
@@ -11,6 +15,23 @@ 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(
@@ -18,7 +39,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"shift_database" "shift_database"
) )
.fallbackToDestructiveMigration() // Simple for now .addMigrations(MIGRATION_3_4)
.build() .build()
INSTANCE = instance INSTANCE = instance
instance instance

View File

@@ -39,18 +39,25 @@ 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 (isNewerVersion(serverVersionName, currentVersionName)) { if (serverVersionCode > currentVersionCode) {
activity.runOnUiThread { activity.runOnUiThread {
showUpdateDialog(activity, serverVersionName, changelog, apkUrl) showUpdateDialog(activity, serverVersionName, changelog, apkUrl)
} }
} else if (!silent) { } else if (!silent && serverVersionCode == currentVersionCode) {
activity.runOnUiThread { activity.runOnUiThread {
Toast.makeText(ctx, "현재 최신 버전을 사용 중입니다. ($currentVersionName)", Toast.LENGTH_SHORT).show() Toast.makeText(ctx, "현재 최신 버전을 사용 중입니다. ($currentVersionName)", Toast.LENGTH_SHORT).show()
} }
@@ -71,29 +78,6 @@ 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,15 +9,12 @@ 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",
@@ -25,25 +22,16 @@ class BootReceiver : BroadcastReceiver() {
immediateWork immediateWork
) )
// 24시간 주기 반복 워커 등록 (자정에 실행) // 2) 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",
ExistingPeriodicWorkPolicy.KEEP, androidx.work.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

@@ -24,7 +24,6 @@ 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)
} }
@@ -61,7 +60,6 @@ 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
@@ -99,7 +97,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 = 13f holder.shiftChar.textSize = 15f
// "반월", "반년" (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.
@@ -111,7 +109,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 = 10f holder.shiftChar.textSize = 11f
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
@@ -120,7 +118,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 = 13f holder.shiftChar.textSize = 15f
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
@@ -137,7 +135,7 @@ class CalendarAdapter(
else -> item.shift.take(1) else -> item.shift.take(1)
} }
holder.shiftChar.text = shiftAbbreviation holder.shiftChar.text = shiftAbbreviation
holder.shiftChar.textSize = 15f holder.shiftChar.textSize = 17f
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) {
@@ -205,7 +203,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 = 10f holder.shiftChar.textSize = 11f
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,6 +1,7 @@
package com.example.shiftalarm package com.example.shiftalarm
import androidx.room.* import androidx.room.Entity
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(
@@ -28,3 +29,12 @@ 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,6 +93,7 @@ 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() {
@@ -211,6 +212,18 @@ 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,13 +4,19 @@ 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?,
@@ -20,6 +26,95 @@ class FragmentSettingsLab : Fragment() {
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupSpinner()
loadAnnualLeave()
loadSaturdayOvertime()
}
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)
}
}
private fun loadSaturdayOvertime() {
lifecycleScope.launch {
val repo = ShiftRepository(requireContext())
val overtimeHours = repo.getSaturdayOvertimeHours()
binding.tvSaturdayOvertime.text = "$overtimeHours"
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null

View File

@@ -10,6 +10,7 @@ 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
@@ -80,8 +81,7 @@ class MainActivity : AppCompatActivity() {
} }
binding.prevMonth.setOnClickListener { binding.prevMonth.setOnClickListener {
currentViewMonth = currentViewMonth.minusMonths(1) animateMonthTransition(-1)
updateCalendar()
} }
binding.monthTitle.setOnClickListener { binding.monthTitle.setOnClickListener {
@@ -89,8 +89,7 @@ class MainActivity : AppCompatActivity() {
} }
binding.nextMonth.setOnClickListener { binding.nextMonth.setOnClickListener {
currentViewMonth = currentViewMonth.plusMonths(1) animateMonthTransition(1)
updateCalendar()
} }
binding.btnToday.setOnClickListener { binding.btnToday.setOnClickListener {
@@ -143,6 +142,10 @@ class MainActivity : AppCompatActivity() {
private val SWIPE_THRESHOLD = 100 private val SWIPE_THRESHOLD = 100
private val SWIPE_VELOCITY_THRESHOLD = 100 private val SWIPE_VELOCITY_THRESHOLD = 100
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling( override fun onFling(
e1: MotionEvent?, e1: MotionEvent?,
e2: MotionEvent, e2: MotionEvent,
@@ -158,12 +161,10 @@ class MainActivity : AppCompatActivity() {
if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
if (diffX > 0) { if (diffX > 0) {
// Swipe Right -> Previous Month // Swipe Right -> Previous Month
currentViewMonth = currentViewMonth.minusMonths(1) animateMonthTransition(-1)
updateCalendar()
} else { } else {
// Swipe Left -> Next Month // Swipe Left -> Next Month
currentViewMonth = currentViewMonth.plusMonths(1) animateMonthTransition(1)
updateCalendar()
} }
return true return true
} }
@@ -202,6 +203,17 @@ 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() {
@@ -316,8 +328,15 @@ 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)
} }
@@ -377,7 +396,7 @@ class MainActivity : AppCompatActivity() {
if (currentViewTeam != t) { if (currentViewTeam != t) {
currentViewTeam = t currentViewTeam = t
updateCalendar() updateCalendar()
Toast.makeText(context, "${t}반 근무표를 표시합니다.", Toast.LENGTH_SHORT).show() showCustomToast(context, "${t}반 근무표를 표시합니다.")
} }
} }
} }
@@ -595,6 +614,7 @@ 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)
@@ -617,6 +637,8 @@ 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()
@@ -686,6 +708,58 @@ 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()
// Reset position for incoming animation (keep scale at 0.95f for continuity)
card.translationX = if (direction > 0) width else -width
card.alpha = 0.7f
card.animate()
.translationX(0f)
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(150)
.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,6 +1,11 @@
package com.example.shiftalarm package com.example.shiftalarm
import androidx.room.* import androidx.room.Dao
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 {
@@ -57,4 +62,17 @@ 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,4 +57,74 @@ 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
))
}
}
suspend fun getSaturdayOvertimeHours(): Int = withContext(Dispatchers.IO) {
val today = LocalDate.now()
val yearMonth = java.time.YearMonth.of(today.year, today.monthValue)
// 해당 월의 토요일 개수 계산
var saturdayCount = 0
var date = yearMonth.atDay(1)
val lastDay = yearMonth.atEndOfMonth()
while (!date.isAfter(lastDay)) {
if (date.dayOfWeek == java.time.DayOfWeek.SATURDAY) {
saturdayCount++
}
date = date.plusDays(1)
}
saturdayCount * 2
}
} }

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFFFF"/> <!-- Fully Opaque to hide everything behind --> <solid android:color="@color/surface"/> <!-- Theme-aware surface color -->
<corners android:radius="32dp"/> <corners android:radius="32dp"/>
<stroke android:width="1.2dp" android:color="#1A000000"/> <stroke android:width="1.2dp" android:color="#1A000000"/>
</shape> </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="44dp" android:height="44dp"/> <size android:width="52dp" android:height="52dp"/>
</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="44dp" android:height="44dp" /> <size android:width="52dp" android:height="52dp" />
</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="44dp" android:height="44dp" /> <size android:width="52dp" android:height="52dp" />
</shape> </shape>

View File

@@ -1,12 +1,13 @@
<?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="44dp" android:width="52dp"
android:height="44dp" android:height="52dp"
android:viewportWidth="44" android:viewportWidth="52"
android:viewportHeight="44"> android:viewportHeight="52">
<!-- 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="M22,0 A22,22 0 0 0 22,44 L22,0 Z" /> android:pathData="M26,0 A26,26 0 0 0 26,52 L26,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="12dp" android:layout_marginHorizontal="3dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:cardCornerRadius="28dp" app:cardCornerRadius="28dp"
app:cardElevation="0dp" app:cardElevation="0dp"
@@ -160,7 +160,20 @@
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/btnTideLocation"/> app:layout_constraintEnd_toStartOf="@id/tvAnnualLeave"/>
<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"
@@ -227,7 +240,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="12dp" android:layout_marginHorizontal="3dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardCornerRadius="20dp" app:cardCornerRadius="20dp"
app:cardElevation="0dp" app:cardElevation="0dp"
@@ -261,5 +274,4 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,20 @@
<?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="@color/text_primary"
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/black" android:textColor="@color/text_primary"
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/white" app:cardBackgroundColor="@color/surface"
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/white" app:cardBackgroundColor="@color/surface"
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/white" app:cardBackgroundColor="@color/surface"
android:layout_marginBottom="16dp"> android:layout_marginBottom="16dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -56,16 +56,32 @@
<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"
<TextView android:id="@+id/btnWolcha" style="@style/ShiftCircleButton" android:text="월차" android:textSize="13sp" android:textColor="@color/secondary"/> app:layout_constraintTop_toTopOf="parent"
<TextView android:id="@+id/btnYeoncha" style="@style/ShiftCircleButton" android:text="연차" android:textSize="13sp" android:textColor="@color/secondary"/> app:layout_constraintStart_toStartOf="parent"/>
<TextView android:id="@+id/btnBanwol" style="@style/ShiftCircleButton" android:text="월" android:textSize="13sp" android:textColor="@color/shift_red"/> <TextView android:id="@+id/btnWolcha" style="@style/ShiftCircleButton" android:text="월" android:textSize="13sp" android:textColor="@color/secondary"
<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"/>
<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"
<TextView android:id="@+id/btnReset" style="@style/ShiftCircleButton" android:text="초기" android:textSize="13sp" android:textColor="@color/text_secondary"/> app:layout_constraintTop_toTopOf="parent"
<TextView android:id="@+id/btnManual" style="@style/ShiftCircleButton" android:text="직접" android:textSize="14sp" android:textColor="@color/shift_gray"/> app:layout_constraintStart_toStartOf="parent"/>
<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/black" android:textColor="@color/text_primary"
android:layout_marginBottom="32dp" android:layout_marginBottom="32dp"
android:layout_gravity="center_horizontal"/> android:layout_gravity="center_horizontal"/>

View File

@@ -361,5 +361,17 @@
</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,33 +4,183 @@
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="24dp" android:padding="16dp"
android:gravity="center"> android:gravity="center_horizontal">
<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_secondary" android:textColor="@color/text_primary"
android:layout_marginBottom="8dp"/> android:layout_marginBottom="24dp"/>
<!-- 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 <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="14sp"
android:textColor="@color/text_tertiary" android:textColor="@color/text_tertiary"
android:gravity="center" android:layout_marginStart="4dp"/>
android:lineSpacingExtra="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
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="※ 연차: -1일 / 반년: -0.5일 차감"
android:textSize="12sp"
android:textColor="@color/text_tertiary"
android:layout_marginBottom="8dp"
android:gravity="center"/>
<!-- Saturday Overtime 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/tvSaturdayOvertime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="@color/shift_red"/>
<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
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="※ 연장근무: 1일당 2시간 계산"
android:textSize="12sp"
android:textColor="@color/text_tertiary"
android:layout_marginBottom="8dp"
android:gravity="center"/>
</LinearLayout> </LinearLayout>

View File

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

View File

@@ -1,3 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">shiftring</string> <string name="app_name">Shift Alarm</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 Normal file → Executable file
View File

View File

@@ -1,7 +1,7 @@
{ {
"versionCode": 1124, "versionCode": 1154,
"versionName": "1.2.4", "versionName": "1.5.4",
"apkUrl": "https://git.webpluss.net/sanjeok77/ShiftRing/releases/download/v1.2.4/app.apk", "apkUrl": "https://git.webpluss.net/attachments/df8bf0ef-db2a-4e45-ba93-0071760305fb",
"changelog": "v1.2.4: Deprecation 경고 수정 및 삭제 알람 버그 수정", "changelog": "v1.5.4: 토요일 연장근무 기본값 표시, 애니메이션 속도 개선, 다크모드 개선",
"forceUpdate": false "forceUpdate": false
} }