Initial commit - v1.1.9
This commit is contained in:
103
app/src/main/AndroidManifest.xml
Normal file
103
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Alarm & Full Screen -->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<!-- Service & Notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_alarm_blue"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/ic_alarm_blue"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ShiftAlarm">
|
||||
|
||||
<activity android:name=".SettingsActivity" android:exported="false" android:configChanges="uiMode"/>
|
||||
<activity
|
||||
android:name=".NoticeActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".SettingsActivity"
|
||||
android:label="변경사항"/>
|
||||
<activity
|
||||
android:name=".ManualActivity"
|
||||
android:exported="false"
|
||||
android:parentActivityName=".SettingsActivity"
|
||||
android:label="사용설명서"
|
||||
android:configChanges="uiMode"/>
|
||||
<activity
|
||||
android:name=".AlarmActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:launchMode="singleTask"
|
||||
android:documentLaunchMode="never"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:allowEmbedded="false"
|
||||
android:resizeableActivity="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".AlarmReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.example.shiftalarm.ALARM_TRIGGER" />
|
||||
<action android:name="com.example.shiftalarm.SNOOZE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".AlarmForegroundService"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
554
app/src/main/assets/CHANGELOG.md
Normal file
554
app/src/main/assets/CHANGELOG.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.3] - 2026-02-16
|
||||
### Added
|
||||
- **앱 안정성 설정 통합**: 설정 화면에서 '배터리 최적화 제외', '다른 앱 위에 표시', '전체화면 알림' 등 알람 가동에 필수적인 권한 상태를 한눈에 확인하고 직접 설정할 수 있는 섹션 추가
|
||||
- **안드로이드 16 잠금화면 우회**: 최신 OS에서도 지문/패턴 해제 없이 알람 화면이 즉시 나타나도록 `requestDismissKeyguard` 로직 적용 및 안정성 강화
|
||||
|
||||
## [1.1.2] - 2026-02-15
|
||||
### Fixed
|
||||
- **알람 삭제 버그**: 알람이 켜진 상태에서 삭제해도 알람이 울리던 문제 수정
|
||||
- **삭제 시 자동 취소**: 알람 삭제 시 시스템에 등록된 향후 모든 스케줄을 즉시 취소하도록 로직 강화
|
||||
|
||||
## [1.1.1] - 2026-02-15
|
||||
### Fixed
|
||||
- **잠금 화면 위 표시**: 잠금 화면을 풀지 않아도 알람 해제 화면이 즉시 나타나도록 윈도우 플래그 및 핸들링 로직 수정 (Android 14/15 완벽 대응)
|
||||
- **전체화면 권한 안내**: 권한이 누락된 경우 설정 화면으로 바로 이동하도록 안내 로직 개선
|
||||
|
||||
## [1.1.0] - 2026-02-15
|
||||
### 🚀 알람 신뢰도 100% 달성 및 시스템 고도화
|
||||
- **3단계 알람 안전장치 도입**:
|
||||
- **Room DB 전환**: 사용자 알람 데이터를 SQLite 데이터베이스로 마이그레이션하여 대규모 데이터 처리 및 보존 안정성 확보
|
||||
- **AlarmClock API 최우선 순위**: 절전 모드를 무력화하는 최고 수준의 신뢰도 API 적용. 상단바 알람 아이콘 활성화로 예약 상태 가시성 확보
|
||||
- **30일 확장 동기화 엔진**: 근무 변경이나 설정 수정 시 향후 30일간의 알람을 즉시 재계산 및 예약
|
||||
- **권한 및 알림 일원화**:
|
||||
- **통합 권한 센터**: 필수 권한(정확한 알람, 배터리 제외, 전체화면 알림)을 한 화면에서 순차적으로 설정할 수 있도록 흐름 개선
|
||||
- **단일 알림 포그라운드 서비스 적용**: 알람 알림이 중복되거나 지워지지 않도록 포그라운드 서비스 기반의 단일 알림 시스템 구축
|
||||
- **레거시 제거 및 최적화**: 미사용 파라미터 제거 및 알람 엔진 성능 최적화
|
||||
|
||||
## [1.0.1] - 2026-02-14
|
||||
### Added
|
||||
- **프리미엄 알람 디자인**: `lock.html` 디자인을 기반으로 한 화려한 알람 화면 도입 (오로라 펄스 애니메이션, 글래스모피즘 버튼)
|
||||
- **달력 년/월 휠 선택**: 달력 상단 년/월 클릭 시 휠 다이얼로 즉시 이동하는 기능 추가
|
||||
- **알람 설정 최적화**: 알람 목록 로딩 속도 개선 및 사운드 타이틀 캐싱 적용
|
||||
|
||||
### Changed
|
||||
- **마스터 알람 스위치**: 거대한 카드 대신 세련된 텍스트 레이블 형태로 알람 설정 좌측 상단에 배치
|
||||
- **토글 버튼 개선**: 이질적인 회색 배경을 제거하고 부드러운 Material 3 애니메이션 스위치 적용
|
||||
- **사용 설명서 개편**: 최신 기능(년/월 피커, 신규 알람 UI 등)에 맞춰 상세 설명 업데이트
|
||||
|
||||
### Fixed
|
||||
- **설정 진입 속도**: 알람 설정 탭 클릭 시 발생하던 미세한 지연 시간 단축
|
||||
|
||||
## [0.9.1] - 2026-02-11
|
||||
### Fixed
|
||||
- **알람 엔진 안정화**: PendingIntent ID 충돌 및 권한 누락 안내 기능 추가
|
||||
- **배경 작업 최적화**: 부팅 후 알람 복구 로직 중복 실행 방지 및 효율성 개선
|
||||
- **보안 강화**: 앱 서명 비밀번호 분리 관리 및 루팅 기기 대응 준비
|
||||
- **시간대 통일**: 모든 알람 로직에 Asia/Seoul 표준 시간대 강제 적용
|
||||
|
||||
## [0.9.0] - 2026-02-11
|
||||
### Added
|
||||
- **One UI 8 디자인 완성**: 설정 화면, 알람 설정, 공지사항 등 앱 전반에 걸쳐 One UI 8 스타일의 카드 레이아웃 및 28dp 라운딩 적용
|
||||
- **자동 업데이트 확인**: 앱 접속 시 최신 버전을 자동으로 체크하고 원클릭으로 업데이트를 수행하는 스마트 엔진 탑재
|
||||
- **UI 일관성 강화**: 모든 다이얼로그 및 팝업에 고도화된 Glassmorphism 디자인과 통일된 여백 시스템 도입
|
||||
- **상태 기반 헤더**: 메인 화면 상단에 오늘 근무 및 선택된 반 정보를 실시간으로 표시하는 다이나믹 헤더 추가
|
||||
|
||||
### Changed
|
||||
- **아이콘 시스템 정밀화**: 설정 아이콘 및 액션 버튼에 Lucide Icons 스타일 적용 및 시인성 개선
|
||||
- **가독성 최적화**: 시간 선택기(TimePickerDialog)를 최신 시스템 테마로 업데이트하고 폰트 가독성 상향
|
||||
|
||||
---
|
||||
|
||||
## [0.8.0] - 2026-02-11
|
||||
### Added
|
||||
- **One UI 8 스타일 적용**: Jetpack Compose 기반의 최신 삼성 One UI 8 디자인 시스템 통합 (Soft Blur, Pill-shape, Dynamic Color 지원)
|
||||
- **알람 신뢰도 엔진 (Android 14+ 대응)**: 포그라운드 서비스(shortService) 및 배터리 최적화 예외 유도 로직을 통해 삼성 기기에서의 95%+ 알람 성공률 확보
|
||||
- **정확한 알람 권한 관리**: `AlarmPermissionUtil`을 통해 Android 14+ 알람/리마인더 권한 설정 인터페이스 개선
|
||||
|
||||
### Changed
|
||||
- **빌드 시스템 인프라**: Jetpack Compose 및 Material 3 환경 구축
|
||||
|
||||
---
|
||||
|
||||
## [0.6.3] - 2026-02-10
|
||||
### Changed
|
||||
- **리브랜딩**: 앱 전체에서 '닥잡아/dakjaba' 표기를 '교대링(Shiftring)'으로 통일
|
||||
- **달력 근무 표기 변경**: 약어(주/석/야/맞/휴) → 풀네임(주간/석간/야간/맞교대/휴무)
|
||||
- **메모 표시 레이아웃 수정**: 근무 텍스트 아래에 정확히 배치되도록 마진 및 제약조건 재설정
|
||||
- **사용설명서 전면 업데이트**: 이모지 대신 Lucide 스타일 텍스트 아이콘 사용, 알람 테스트 안내 추가
|
||||
- **알림 텍스트 브랜딩**: '교대 근무 알람' → '교대링 알람'
|
||||
|
||||
### Fixed
|
||||
- **알람 테스트 수정**: 알람 테스트 버튼이 실제로 알람을 트리거하도록 테스트 알람 바이패스 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## [0.5.8] - 2026-02-10
|
||||
### Added
|
||||
- **알람 동기화 시스템 전면 재정비**: 알람이 울리기 직전 근무 종류뿐만 아니라 설정된 '시간'까지 재검증하는 2중 동기화 로직을 도입하여, 어떤 상황에서도 정확한 알람이 울리도록 개선
|
||||
### Fixed
|
||||
- **다크 모드 눈 피로도 감소**: 근무별 배경색의 채도를 더 낮추고 부드러운 톤으로 조정하여 야간 사용 시 시각적 편안함 증대
|
||||
- **다크 모드 가독성 수정**: 알람 추가 및 근무 변경 팝업의 배경과 텍스트 시인성 확보
|
||||
- **버그 수정**: 야간 맞교대 근무 시 알람 시간 키값이 잘못 지정되던 문제 수정
|
||||
|
||||
---
|
||||
|
||||
## [0.5.7] - 2026-02-10
|
||||
### Fixed
|
||||
- **알람 동기화 긴급 수정**: 근무가 변경된 경우 이전 알람이 울리지 않도록 수신부(AlarmReceiver)에서 현재 근무를 재검증하는 로직 추가
|
||||
- **다크 모드 팝업 시인성 개선**: 다크 모드에서 '알람 추가', '근무 변경' 팝업의 배경색이 보이지 않던 문제 수정 (테마 대응 컬러 적용)
|
||||
- **근무 배경 색상 최적화**: 다크 모드에서 근무 배경색의 채도를 낮추어 눈의 피로도를 줄임
|
||||
### Changed
|
||||
- **용어 통일**: '스누즈' 표기를 '다시 울림'으로 일괄 변경
|
||||
|
||||
---
|
||||
|
||||
## [0.5.6] - 2026-02-09
|
||||
### Changed
|
||||
- **'오늘' 버튼 디자인 개편**: 달력 그리드 디자인과 조화로운 미니멀한 테두리 스타일로 변경
|
||||
- **가독성 개선**: 라이트 모드에서 시인성이 낮았던 민트색 섹션 제목을 시인성이 높은 진보라색으로 변경하여 가독성 향상
|
||||
|
||||
---
|
||||
|
||||
## [0.5.5] - 2026-02-09
|
||||
### Changed
|
||||
- **설정 화면 레이아웃 최적화**: '회사 선택'과 '반 선택'을 한 행에 2열 그리드로 배치하여 공간 효율성 개선
|
||||
- **용어 순화 및 변경**: 알람 설정 내 '스누즈 및 소리'를 사용자 친화적인 '다시 울림 및 소리'로 명칭 변경
|
||||
|
||||
---
|
||||
|
||||
## [0.5.4] - 2026-02-09
|
||||
### Fixed
|
||||
- **다크 모드 빈 격자 색상 수정**: 달력 시작/종료 전후의 빈 격자가 다크 모드에서도 테마에 맞는 색상으로 표시되도록 수정
|
||||
### Improved
|
||||
- **공간 활용성 극대화**: 각 격자의 높이를 상향(82dp) 조정하여, 하단의 남는 공간을 최소화하고 화면을 더 꽉 차게 보이도록 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.5.3] - 2026-02-09
|
||||
### Fixed
|
||||
- **다크 모드 완벽 지원**: 하드코딩된 색상을 테마 리소스(bg_grid_cell_default, grid_divider 등)로 교체하여 다크 모드에서도 달력이 정상적으로 표시되도록 수정
|
||||
- **사용자 편의성 강화**: 메인 화면의 알람 시간 표시 영역을 터치하면 즉시 알람 설정 화면으로 이동하도록 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.5.2] - 2026-02-09
|
||||
### Added
|
||||
- **그리드 완성도 향상**: 달력 시작일 이전의 빈 공간에도 격자선을 표시하여 디자인적 일관성 확보
|
||||
- **가독성 최적화**: 근무 표시 글자 크기 확대 및 격자 높이 상향 (66dp)
|
||||
- **UI 시각적 개선**: '오늘' 버튼 디자인 개선 및 배경색/테두리 최적화
|
||||
- **대비 및 명시성 강화**: 흰색 텍스트가 잘 보이지 않던 팀(A반)의 배경색을 더욱 진하게 조정
|
||||
- **오늘 강조 변경**: 오늘 날짜를 연한 파란색 배경으로 강조하여 시각적 직관성 제공
|
||||
|
||||
---
|
||||
|
||||
## [0.5.1] - 2026-02-09
|
||||
### Added
|
||||
- **격자 디자인 고도화**: 일반 달력처럼 배경을 흰색으로 변경하고, 근무 표시(주, 석, 야 등)에만 개별 배경색 적용
|
||||
- **공휴일 정보 통합**: 교대 달력 모드에서도 근무 표시 옆에 공휴일 이름을 함께 표시
|
||||
- **화면 실용성 극대화**: 상단 헤더 및 알람바 높이를 축소하여 6주 달력도 스크롤 없이 한 화면에 표시
|
||||
- **가독성 개선**: 근무 글자를 칸 좌측 상단 모서리에 밀착 배치하고 최적화된 글자 크기 적용
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] - 2026-02-09
|
||||
### Added
|
||||
- **달력 디자인 전면 개편**: 카드 스타일에서 세련된 격자(Grid) 스타일로 변경
|
||||
- **근무 표기 최적화**: 좌측 상단에 한 글자(주, 석, 야, 맞, 휴)로 직관적인 근무 표시
|
||||
- **가독성 강화**: 중앙에 큰 날짜 배치 및 날짜 옆 작은 공휴일 마커 추가
|
||||
- **색상 체계 변경**: 주간(레몬), 석간(회색), 야간(검정), 휴무(빨강), 맞교대(보라)로 배경색 구분
|
||||
- **전체 화면 최적화**: 불필요한 여백을 제거하고 화면을 최대한 활용하도록 레이아웃 수정
|
||||
|
||||
---
|
||||
|
||||
## [0.4.4] - 2026-02-09
|
||||
### Changed
|
||||
- **달력 행 수 최적화**: 1~4주만 필요한 달은 5행(35셀)만 표시하고 스크롤 없이 고정, 5주 이상 필요한 달만 6행(42셀)으로 스크롤 활성화
|
||||
- **안전한 최적화 방식**: 레이아웃 높이 수정 없이, 데이터 패딩과 스크롤 설정만 조정하여 해상도에 관계없이 안정적으로 동작
|
||||
|
||||
---
|
||||
|
||||
## [0.4.3] - 2026-02-09
|
||||
### Fixed
|
||||
- **달력 화면 완전 복원**: 0.3.9 안정 버전의 달력 코드로 완전 롤백하여 달력이 보이지 않던 문제를 완벽하게 해결
|
||||
- **해상도 최적화 이슈 제거**: 불안정한 동적 높이 조절 로직을 모두 제거하고 검증된 고정 레이아웃으로 복원
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] - 2026-02-09
|
||||
### Fixed
|
||||
- **달력 화면 완전 복구**: 달력 항목의 높이를 70dp로 명시적으로 고정하여, 일부 기기에서 레이아웃 측정 오류로 달력 내용이 보이지 않던 문제를 완벽하게 해결
|
||||
- **스크롤 기능 강화**: 모든 해상도에서 화면 잘림 없이 달력을 확인할 수 있도록 스크롤 기능 상시 활성화 유지
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] - 2026-02-09
|
||||
### Fixed
|
||||
- **스크롤 제한 긴급 수정**: 작은 화면의 기기에서 달력 하단이 잘리는 현상을 수정하기 위해 스크롤 기능을 상시 활성화
|
||||
- **달력 레이아웃 안정화**: 해상도 최적화 로직의 일부 불안정성을 제거하고 표준 높이(68dp)로 복구하여 안정성 확보
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] - 2026-02-09
|
||||
### Added
|
||||
- **버전 넘버링 체계 확립**: Patch가 9를 넘으면 Minor를 올리는 규칙 적용 (0.3.9 -> 0.4.0)
|
||||
- **해상도별 달력 최적화**: 5행 달력은 스크롤 없이 고정하고, 6행일 때만 스크롤되도록 고도화
|
||||
- **레이아웃 안정성**: 다양한 해상도의 폰에서도 달력 모양이 일정하게 유지되도록 수정
|
||||
|
||||
### Fixed
|
||||
- **메인 공간 및 UI 개선**: 0.3.10의 변경사항(헤더 축소, 버튼 강조 등)을 정식 반영
|
||||
|
||||
---
|
||||
|
||||
## [0.3.9] - 2026-02-09
|
||||
### Added
|
||||
- **오늘 버튼 동작 개선**: 달력에서 '오늘' 클릭 시 보고 있던 조와 상관없이 나의 본래 조와 오늘 날짜 달력으로 즉시 복귀
|
||||
- **사용자 알람 연동 버그 수정**: 이제 사용자 알람 추가/수정 시에도 오늘 근무표를 확인하여 본인 근무와 일치할 때만 스케줄링되도록 수정
|
||||
|
||||
### Fixed
|
||||
- **업데이트 내역 가독성**: 변경사항 목록 하단에 불필요하게 표시되던 구분선(-) 제거
|
||||
|
||||
### Changed
|
||||
- **기본 설정 가독성**: 회사/반 선택 레이블 폰트 크기 확대 및 굵게 표시하여 식별력 강화
|
||||
|
||||
---
|
||||
|
||||
## [v0.6.0] - 2026-02-12
|
||||
### Added
|
||||
- **Major Rebranding**: App name changed to **"교대링" (Shiftring)** with English locale support.
|
||||
- **Room Database Migration**: Replaced `SharedPreferences` with Room DB for robust storage of shift overrides and memos.
|
||||
- **Daily Memos**: Added ability to save and view daily notes on the calendar.
|
||||
- **Lucide Icons**: Integrated modern Lucide iconography system across the app.
|
||||
- **Advanced Alarm Synchronization**: Reliable "double-check" system ensuring alarms match current shift and time preferences.
|
||||
- **Additional Features Tab**: New section in settings for upcoming capabilities.
|
||||
|
||||
### Fixed
|
||||
- **Dark Mode Visibility**: Improved "Night" (야간) shift visibility and dialog accessibility in dark theme.
|
||||
- **Coroutines & Performance**: Refactored database operations to use Coroutines for smoother UI performance.
|
||||
- **Icon Prefix**: Standardized all icons with `ic_` prefix.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.8] - 2026-02-09
|
||||
### Added
|
||||
- **지난 알람 숨김**: 오늘 이미 시간이 지난 사용자 알람은 달력 화면에서 표시되지 않도록 개선
|
||||
- **가독성 향상**: 라이트 모드에서도 알람 시간이 잘 보이도록 색상 및 굵기 개선
|
||||
|
||||
### Updated
|
||||
- **사용 설명서 개편**: 최신 기능 반영 및 불필요한 서식을 제거하여 깔끔하게 정리
|
||||
|
||||
---
|
||||
|
||||
## [0.3.7] - 2026-02-09
|
||||
### Added
|
||||
- **사용자 알람 근무표 연동**: 기타를 제외한 사용자 알람이 나의 근무표와 연동 (예: 주간 선택 시 주간 날에만 울림)
|
||||
- **달력 팀 표시 개선**: 다이얼로그에서 나의 반 표시 시 "(나)" 추가
|
||||
|
||||
### Changed
|
||||
- **알람 표시 개선**: (사용자) 텍스트 제거, 색상으로 구분
|
||||
- **여러 알람 표시**: 3개 이하면 모두 표시, 초과 시 "XX:XX 외 N개" 형식
|
||||
- **설정 섹션 타이틀 강화**: 글자 크기 16sp로 확대, 굵게 표시
|
||||
|
||||
---
|
||||
|
||||
## [0.3.6] - 2026-02-09
|
||||
### Added
|
||||
- **사용자 알람 실제 작동**: 사용자 알람이 실제로 울리도록 알람 스케줄링 구현
|
||||
- **알람 수정 기능**: 사용자 알람에 수정 버튼 추가
|
||||
- **달력 알람 표시 개선**: 사용자 알람이 근무 알람보다 빠르면 함께 표시
|
||||
|
||||
### Fixed
|
||||
- **알람 신뢰성 강화**: 알람 추가 시 즉시 스케줄링되도록 개선
|
||||
|
||||
### Changed
|
||||
- **근무별 알람시간 레이블 굵게 표시**: 주간/석간/야간/야맞 레이블을 굵게 표시
|
||||
|
||||
---
|
||||
|
||||
## [0.3.5] - 2026-02-09
|
||||
### Added
|
||||
- **사용자 알람 추가**: 알람 설정에서 '+ 알람 추가' 버튼으로 사용자 정의 알람 등록 가능
|
||||
- **시간 및 근무 유형 선택**: 사용자 알람 추가 시 시간과 근무 유형(주간/석간/야간/기타) 설정
|
||||
|
||||
### Fixed
|
||||
- **공지사항 버전 표시 수정**: 업데이트 내역에서 버전명이 정확하게 파싱되도록 로직 전면 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-02-09
|
||||
### Improved
|
||||
- **공지사항 버전 파싱 개선**: 업데이트 내역 상단에 버전명이 더 정확하게 표시되도록 로직 보강
|
||||
- **사용 설명서 버전 자동화**: 설명서 상단의 앱 버전이 하드코딩 대신 현재 설치된 버전으로 자동 표시
|
||||
- **날짜 숫자 크기 확대**: 달력 날짜 숫자를 더 크게(12sp) 키워 가독성 향상
|
||||
|
||||
---
|
||||
|
||||
## [0.3.3] - 2026-02-09
|
||||
### Fixed
|
||||
- **달력 스와이프 감도 개선**: RecyclerView 터치 간섭 문제를 해결하기 위해 `addOnItemTouchListener` 적용
|
||||
- **스와이프 영역 확대**: 달력 내부뿐만 아니라 달력 컨테이너 전체에서 스와이프 제스처가 작동하도록 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] - 2026-02-08
|
||||
### Fixed
|
||||
- **업데이트 내역 버전 파싱 수정**: 버전 번호가 정확하게 표시되도록 정규표현식 파싱 적용
|
||||
- **근무 변경 팝업 불투명도 향상**: 배경 글자가 비치지 않도록 95% 불투명도 적용
|
||||
- **공휴일/음력 표시 개선**: 카드 중앙에 크게 표시, 생략(...)없이 전체 표시
|
||||
- **설명서 불필요한 내용 제거**: 알람 프라이버시 설명 삭제 (본인 폰에만 설정되므로 불필요)
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] - 2026-02-08
|
||||
### Improved
|
||||
- **용어 통일**: '조' 표기를 '반'으로 전체 통일 (다른 반 근무, 설명서, 메뉴 등)
|
||||
- **업데이트 내역 버전 표기**: 공지사항에 'v0.3.1 업데이트 내용' 형식으로 버전 표시
|
||||
- **공휴일 글자 크게 표시**: 공휴일/음력 날짜 텍스트를 13sp 굵은 글씨로 표시
|
||||
- **사용 설명서 최신화**: v0.3.1 기준 최신 기능 및 용어 반영
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-02-08
|
||||
### Improved
|
||||
- **공휴일 체크 위치 고정**: 다른 조 달력 조회 시에도 공휴일 체크박스가 우측에 고정
|
||||
- **음력 날짜 표시 개선**: 12.25 형식의 음력 날짜가 생략 없이 전체 표시
|
||||
- **설명서 UI 통일**: 공지사항과 동일한 핑크 카드 스타일로 변경
|
||||
- **공지사항 UI 업그레이드**: 메시 그라디언트 배경과 세련된 헤더 카드 적용
|
||||
- **전체 UI/UX 통일감 강화**: 앱 전반에 걸쳐 일관된 디자인 언어 적용
|
||||
|
||||
---
|
||||
|
||||
## [0.2.9] - 2026-02-08
|
||||
### Improved
|
||||
- **공휴일 글자 크게 표시**: 공휴일 명칭도 근무 글자와 동일하게 크게 (14sp) 표시
|
||||
- **다시 울림 옵션 추가**: 1분, 3분 스누즈 간격 추가 (총 8개 옵션)
|
||||
- **알람 표시 개선**: 다른 조 달력 조회 시 알람 영역 완전히 숨김 처리
|
||||
- **공지사항 표시 제한**: 최대 7개 항목만 표시하여 가독성 향상
|
||||
- **사용 설명서 최신화**: v0.2.9 기준 모든 기능 반영
|
||||
|
||||
---
|
||||
|
||||
## [0.2.8] - 2026-02-08
|
||||
### Improved
|
||||
- **달력 근무 텍스트 대폭 확대**: 주간, 석간, 야간 등 근무 글자를 16sp로 키워 가독성 극대화
|
||||
- **기본 달력 모드 변경**: 앱 실행 시 '교대달력'이 기본으로 표시 (공휴일 체크 해제 상태)
|
||||
- **달력 레이아웃 균등 배분**: 날짜, 근무, 음력이 균등하게 배치되어 깔끔한 정렬
|
||||
- **알람 표시 개선**: 다른 조 달력 조회 시 알람 시간이 표시되지 않음 (내 조만 알람 표시)
|
||||
- **오늘 날짜 강조**: 오늘 날짜에 파란색 테두리로 눈에 띄게 표시
|
||||
- **사용 설명서 UI 전면 개편**: Glassmorphism 3.0 디자인 적용, 앱 아이콘 헤더, 글래스 카드 스타일
|
||||
|
||||
---
|
||||
|
||||
## [0.2.7] - 2026-02-08
|
||||
### Fixed
|
||||
- **체크 표시 로직 수정**: 근무 중인 날에는 V 체크 표시가 나타나지 않도록 수정 (휴무/휴가일에만 표시)
|
||||
- **사용 설명서 업데이트**: 최신 앱 기능(글래스모피즘 3.0, 탭 설정, 오늘 이동 등) 반영 및 갱신
|
||||
|
||||
---
|
||||
|
||||
## [0.2.6] - 2026-02-08
|
||||
### Added
|
||||
- **공휴일 모드**: '공휴일' 체크 시 근무 표시를 숨기고 공휴일 명칭만 표시 (일반 달력 모드)
|
||||
- **근무 변경 팝업 디자인**: 근무 변경 화면에 Glassmorphism 3.0 디자인 적용
|
||||
|
||||
---
|
||||
|
||||
## [0.2.5] - 2026-02-08
|
||||
### Fixed
|
||||
- **용어 수정**: '근무표 직접 수정' 버튼 명칭을 **'사용 설명서'**로 정정 (기능과 명칭 불일치 해결)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.4] - 2026-02-08
|
||||
### Fixed
|
||||
- **다크 모드 가독성 개선**: 닫기 버튼 및 달력 날짜 텍스트 가독성 향상
|
||||
- **UI/UX 개선**: 달력에 오늘 날짜로 이동하는 버튼 추가
|
||||
- **디자인 수정**: 공휴일 체크 방식 변경 (V 체크 표시) 및 글래스모피즘 효과 강화
|
||||
- **용어 수정**: '직접 입력 관리' → '근무표 직접 수정'으로 변경하여 이해도 향상
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3] - 2026-02-08
|
||||
### Changed
|
||||
- **디자인 업그레이드 (Glassmorphism 3.0)**: 더욱 아름답고 세련된 반투명 디자인 적용
|
||||
- **설정 화면 개편**: 기본 설정과 알람 설정 탭으로 분리하여 사용성 강화
|
||||
- **완전 한글화**: 달력 요일 및 설정 메뉴 100% 한글 적용
|
||||
- **회사 명칭 간소화**: '모나리자 전주' → '전주', '모나리자 논산' → '논산'으로 변경
|
||||
|
||||
### Fixed
|
||||
- **알람 시간 초기화**: 회사 변경 시 해당 회사의 기본 출근 시간으로 자동 변경
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2] - 2026-02-08
|
||||
### Added
|
||||
- **다크/라이트 모드 지원**: 설정 > 화면 테마에서 시스템/다크/라이트 모드 선택 가능
|
||||
- **업데이트 내역 실시간 동기화**: 앱 내 변경사항이 서버와 즉시 연동되도록 개선
|
||||
|
||||
### Changed
|
||||
- **용어 수정**: '공장' → '회사', '스누즈' → '다시 울림'으로 변경하여 친숙함 강화
|
||||
- **UI 개선**: 알람 화면 '미루기' 버튼 가독성 향상 및 텍스트 수정
|
||||
- **설정 화면**: 하단에 앱 버전 정보 및 제작자(산적이얌) 표시 추가
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1] - 2026-02-08
|
||||
### Changed (UI Overhaul - Glassmorphism 2.0)
|
||||
- **전체 디자인 리뉴얼**: 고급스러운 메쉬 그라데이션(Deep Purple-Blue) 배경 적용
|
||||
- **설정 화면 개선**: 알람 시간 버튼 색상을 투명한 글래스 스타일로 변경하여 가독성 대폭 향상
|
||||
- **메인 캘린더**: 투명 카드 UI 적용, 텍스트 색상을 배경에 맞춰 화이트 톤으로 최적화
|
||||
- **알람 화면**: 슬라이더 및 시간 표시 가독성 개선, 부드러운 글래스 패널 적용
|
||||
- **세부 디테일**: 섹션 타이틀 영문 표기, 아이콘 및 버튼 스타일 통일
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-02-08
|
||||
### Changed
|
||||
- **스누즈 메시지 개선**: "스누즈 설정됨" → "X분 뒤 다시 울림" 형태로 설정된 시간 표시
|
||||
- **알람 화면 글래스모피즘 적용**: 반투명 카드, 슬라이더, 썸네일에 부드러운 투명도 효과
|
||||
- **알람 화면 배경 개선**: 다크 블루 그라데이션 팔레트로 세련됨 향상
|
||||
- **설정 화면 버튼 개선**: 진한 색상을 부드러운 글래스모피즘 스타일로 변경
|
||||
- **스누즈 슬라이더에 시간 표시**: 알람 화면에서 설정된 스누즈 시간을 바로 확인 가능
|
||||
|
||||
---
|
||||
|
||||
## [0.1.9] - 2026-02-08
|
||||
### Changed
|
||||
- 알람 화면: 알람 끄기(빨간색)를 위로, 스누즈(파란색)를 아래로 위치 변경
|
||||
- 설정 화면: 글래스모피즘 디자인 적용 (반투명 카드, 부드러운 그라데이션 배경)
|
||||
- 설정 화면: 이모지 대신 안드로이드 시스템 아이콘으로 대체하여 세련됨 향상
|
||||
- 사용설명서: 한글 깨짐 수정 및 알람 해제 방법 내용 추가
|
||||
- 변경사항: 불필요한 구분선 및 빈 불릿 필터링으로 가독성 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.1.8] - 2026-02-08
|
||||
### Changed
|
||||
- **설정 화면 전면 개편**: 섹션별 카드 분리, 구분선 및 여백 적용으로 가독성 대폭 개선
|
||||
- **알람 화면 UI 고도화**:
|
||||
- 그라데이션 배경 및 대형 시간 표시로 프리미엄 느낌 강화
|
||||
- 스누즈(블루) / 해제(레드) 슬라이더 색상 분리로 직관성 향상
|
||||
- 슬라이더 핸들 디자인 고급화 (원형 + 중심 컬러)
|
||||
|
||||
---
|
||||
|
||||
## [0.1.7] - 2026-02-08
|
||||
### Added
|
||||
- **슬라이더 방식 알람 UI**: 아이폰/갤럭시 스타일의 '밀어서 해제/스누즈' UI 도입
|
||||
- **스누즈 (좌→우)**: 상단 슬라이더를 밀어서 잠시 미룸
|
||||
- **알람 해제 (우→좌)**: 하단 슬라이더를 밀어서 정지
|
||||
- **디자인 고도화**: 시각적인 조작 가이드(트랙 및 핸들)를 적용하여 직관성 및 미관 대폭 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.1.6] - 2026-02-08
|
||||
### Fixed
|
||||
- 알람 화면 긴급 수정: 버튼 대신 **스와이프 전용 레이아웃** 도입 (우→좌: 정지, 좌→우: 스누즈)
|
||||
- 화면 켜짐 보장: 잠금 화면에서도 알람 시 즉시 화면이 켜지도록 로직 강화
|
||||
- 논산 야맞 알람 오류: 출근 시간(20:00) 1시간 전인 **19:00**에 알람이 기본 설정되도록 수정
|
||||
- 축약어 적용: '다른 조 오늘 근무' 섹션에서도 '야간 맞교대' 대신 **'야맞'**으로 통일
|
||||
|
||||
### Added
|
||||
- 알람 화면에 현재 시간 표시 및 스와이프 안내 가이드 추가
|
||||
|
||||
---
|
||||
|
||||
## [0.1.5] - 2026-02-08
|
||||
### Added
|
||||
- 알람 스와이프 제스처 기능: **우→좌(알람 정지)**, **좌→우(스누즈)**
|
||||
- 알람 시 화면 자동 켜짐 및 잠금화면 표시 기능 강화
|
||||
- 변경사항(Notice) 화면에 '닫기' 버튼 추가
|
||||
|
||||
### Changed
|
||||
- 논산 공장 야간 맞교대(야맞) 기본 시간을 **20:00**으로 조정 및 라벨 업데이트
|
||||
- 달력 내 '야간 맞교대' 표기를 **'야맞'**으로 축약하여 가독성 개선
|
||||
- 5행 달력의 스크롤을 완전히 고정하여 불필요한 움직임 제거
|
||||
- 달력 제목 형식을 연.월(예: 2026.02)로 변경하여 더 깔끔하게 개선
|
||||
|
||||
### Fixed
|
||||
- 스누즈 버튼 클릭 시 알람이 즉시 다시 울리던 현상 수정
|
||||
- 알람 정지 시 상단 바 알림이 사라지지 않던 문제 해결
|
||||
|
||||
---
|
||||
|
||||
## [0.1.4] - 2026-02-08
|
||||
### Added
|
||||
- 고급스러운 디자인 시스템 적용 (카드 곡률, 현대적 컬러 팔레트)
|
||||
- 알람 테스트 기능 구현 (설정 > 5초 후 테스트 알람)
|
||||
- 사용설명서 앱 내 뷰어 연동 및 최신 내용 반영
|
||||
|
||||
### Changed
|
||||
- 달력 줄 수(5행/6행)에 따른 상하 스크롤 자동 제어
|
||||
- 체인지로그 표시 시 불필요한 마크다운 기호를 제거하여 가독성 향상
|
||||
- 설정 화면의 '깃허브 다운로드' 버튼 제거 (앱 내 업데이트로 통일)
|
||||
|
||||
### Fixed
|
||||
- 음력 날짜가 표시되지 않거나 부정확하던 문제 수정
|
||||
- 메인 화면 상단 알람 정보의 실시간 반영 로직 개선
|
||||
|
||||
---
|
||||
|
||||
## [0.1.3] - 2026-02-08
|
||||
### Added
|
||||
- 설정 내 '변경사항' 메뉴가 실제 CHANGELOG 내용을 표시하도록 연동
|
||||
- '직접 입력' 근무 설정 시 알람 시간을 즉시 입력받도록 개선
|
||||
|
||||
### Changed
|
||||
- '오늘 다른 조' 표시를 모든 팀(전주 4개, 논산 3개)이 보이도록 복원
|
||||
- 일별 근무 변경 시 표준 근무(주간/석간/야간/야맞)는 설정된 시간을 즉시 적용하도록 간소화
|
||||
- 야간 맞교대(야맞) 알람 기본 시간을 공장별로 차별화 (논산: 19:00, 전주: 18:00)
|
||||
|
||||
### Fixed
|
||||
- 야간 맞교대 시간 설정이 저장되지 않던 문제 수정
|
||||
|
||||
---
|
||||
|
||||
## [0.1.2] - 2026-02-08
|
||||
### Added
|
||||
- 앱 내 직접 APK 다운로드 및 설치 기능 (진행률 표시 포함)
|
||||
- 스와이프 제스처로 캘린더 월 탐색 기능
|
||||
|
||||
### Changed
|
||||
- '오늘 다른 조' 표시를 현재 보고 있는 팀 제외한 팀만 표시하도록 간소화
|
||||
- 캘린더 높이를 항상 6줄(42칸)로 고정하여 일관된 UI 제공
|
||||
- 업데이트 다운로드 방식을 웹페이지 이동에서 앱 내 직접 다운로드로 변경
|
||||
|
||||
### Fixed
|
||||
- 팀 표시에서 지역명(전주/논산) 제거하여 UI 정리
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] - 2026-02-08
|
||||
### Changed
|
||||
- 릴리즈 저장소 분리 (dakjaba-releases)
|
||||
- 버전 체크 URL 업데이트
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-02-08
|
||||
### Added
|
||||
- 사용설명서(Manual) 메뉴 추가
|
||||
- 설정 화면 내 수동 업데이트 메뉴 개선
|
||||
- 달력 내 음력 표시 기능 추가
|
||||
- 릴리즈 빌드 서명(Signing) 적용
|
||||
|
||||
### Changed
|
||||
- 알람 설정 단위를 1분으로 세분화
|
||||
- 반 선택 방식을 라디오 버튼에서 드롭다운(Spinner)으로 변경
|
||||
- 전주 D반 복구 및 공장별 맞춤형 반 선택 로직 적용
|
||||
- 하단 '오늘의 근무' 레이아웃 최적화
|
||||
|
||||
### Fixed
|
||||
- 설정 변경 시 달력에 즉시 반영되지 않던 문제 수정
|
||||
- 공휴일 텍스트 잘림 현상 개선
|
||||
|
||||
---
|
||||
|
||||
## [2026-02-01]
|
||||
|
||||
### Added
|
||||
- 기본 알람 스케줄 엔진
|
||||
|
||||
### Fixed
|
||||
- Doze 모드에서 알람 누락 문제 수정
|
||||
47
app/src/main/assets/MANUAL.md
Normal file
47
app/src/main/assets/MANUAL.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 교대링(Shiftring) 상세 사용 가이드
|
||||
|
||||
본 가이드는 **교대링 v1.1.8**의 주요 기능과 설정을 안내합니다. 별도의 복잡한 설정 없이도 **자신의 반(A/B/C/D)**만 선택하면 즉시 모든 일정과 알람이 세팅됩니다.
|
||||
|
||||
## 1. 스마트 달력 사용법
|
||||
- **일정 한눈에 보기**: 달력에 주간(노랑), 석간(연두), 야간(보라), 휴무(빨강) 등 색상별로 근무가 자동 표시됩니다.
|
||||
- **월 이동 제스처**: 화면을 좌우로 가볍게 밀어서(스와이프) 이전 달이나 다음 달로 빠르게 이동할 수 있습니다.
|
||||
- **빠른 년/월 이동**: 상단 중앙의 **'2026년 02월'** 텍스트를 터치하면 휠 다이얼을 돌려 원하는 년도와 월로 즉시 이동할 수 있습니다.
|
||||
- **오늘로 돌아오기**: 상단의 **'오늘'** 버튼을 누르면 언제 어디서든 현재 날짜로 즉시 돌아옵니다.
|
||||
- **타 조 근무 확인**: 하단의 '오늘의 타 조 근무' 섹션에서 다른 조 이름을 터치하면, 해당 조의 달력 뷰로 잠시 전환됩니다.
|
||||
|
||||
## 2. 근무 변경 및 개인 메모
|
||||
기본 스케줄 외의 변경 사항을 달력에 직접 기록하고 관리할 수 있습니다.
|
||||
- **날짜 선택**: 수정하고 싶은 날짜를 터치하면 상세 설정 팝업이 나타납니다.
|
||||
- **근무 상태 수정**: 연차, 교육, 월차, 반차 등 해당일의 상태를 선택하세요. 달력에 즉시 반영되며 관련 알람도 자동 조정됩니다.
|
||||
- **메모장 활용**: 하단 메모란에 내용을 입력하고 저장하면, 달력 날짜 아래에 작은 점(•)이 표시되며 메모 내용을 확인할 수 있습니다.
|
||||
- **설정 초기화**: 수정한 일정을 원래의 기본 순번대로 되돌리려면 **'원래대로'** 버튼을 누르세요.
|
||||
|
||||
## 3. 프리미엄 알람 시스템
|
||||
최신 트렌드를 반영한 아름답고 신뢰할 수 있는 알람 기능을 제공합니다. 교대링은 **3단계 안전장치**를 통해 100% 신뢰도를 지향합니다.
|
||||
- **자동 예약**: 선택된 근무(주/야/석)에 따라 알람 시간이 자동으로 계산되어 예약됩니다. (향후 30일치 사전 예약)
|
||||
- **정밀 알람 엔진**: `AlarmClock` API를 통해 절전 모드에서도 정확하게 작동하며, 상단바에 알람 아이콘이 표시되어 작동 여부를 쉽게 확인할 수 있습니다.
|
||||
- **실시간 동기화**: 근무를 변경하거나 설정에서 알람 시간을 바꾸는 즉시 전체 스케줄이 실시간으로 재구성됩니다.
|
||||
- **시간 커스텀**: 설정(⚙️) → **알람 설정** 탭에서 각 근무별 기본 알람 시간을 본인의 기상 패턴에 맞게 수정할 수 있습니다.
|
||||
- **전체 알람 마스터 스위치**: 알람 설정 페이지 좌측 상단의 **'전체 알람 켜짐/꺼짐'** 버튼으로 모든 예약을 일시 정지하거나 활성화할 수 있습니다.
|
||||
- **럭셔리 디자인**: **글래스모피즘(유리 질감)**과 화려한 그라데이션이 적용된 알람 화면은 가독성과 디자인을 모두 잡았습니다.
|
||||
- **직관적인 버튼 제어**:
|
||||
- **다시 울림**: 상단 유리 질감 버튼을 누르면 설정된 간격만큼 알람을 미룹니다.
|
||||
- **해제**: 중앙의 거대한 원형 버튼을 누르면 알람이 즉시 종료됩니다. (주변에 은은한 오로라 광채 애니메이션이 작동합니다)
|
||||
- **부드러운 스위치**: 알람 항목의 온/오프 스위치는 가볍고 부드러운 애니메이션을 제공하며, 불필요한 배경 요소를 제거하여 시각적 이질감을 없앴습니다.
|
||||
|
||||
## 4. 물때표 및 특수 설정
|
||||
- **물때표 표시**: 설정 → 기타 설정에서 **'물때표 보기'**를 활성화하면 달력 상단에 물때 정보가 나타납니다.
|
||||
- **지역 전환**: 달력 상단의 지역 이름(군산, 변산, 여수, 태안)을 터치하여 간편하게 지역별 물때를 확인할 수 있습니다.
|
||||
- **기본 공장 설정**: 본인이 속한 공장(전주 또는 논산)을 선택하여 공장별 특화된 교대 로직을 적용받으세요.
|
||||
|
||||
## 5. 데이터 백업 및 앱 공유
|
||||
- **안전한 백업**: 설정 → 기타 설정에서 현재의 근무 기록과 메모를 파일로 저장하거나 다시 불러올 수 있습니다.
|
||||
- **설치 파일 직접 전송**: **'앱 공유하기'** 기능을 통해 동료들에게 설치 파일(APK)을 직접 보내주어 간편한 설치를 도울 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
알람이 누락되지 않도록 앱 실행 시 안내되는 **통합 권한 설정**을 반드시 완료해주세요:
|
||||
1. 배터리 사용량을 **'제한 없음'**으로 설정 (배터리 최적화 제외)
|
||||
2. **'정확한 알람 및 리마인더'** 권한 허용 (필수)
|
||||
3. **'전체화면 알림'** 권한 허용 (잠금 화면 즉시 표시)
|
||||
4. 알람 볼륨 및 진동 설정 확인
|
||||
498
app/src/main/java/com/example/shiftalarm/AlarmActivity.kt
Normal file
498
app/src/main/java/com/example/shiftalarm/AlarmActivity.kt
Normal file
@@ -0,0 +1,498 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import com.example.shiftalarm.databinding.ActivityAlarmBinding
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.abs
|
||||
|
||||
class AlarmActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityAlarmBinding
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var startX = 0f
|
||||
|
||||
// 5분 후 자동 스누즈
|
||||
private val autoStopHandler = Handler(Looper.getMainLooper())
|
||||
private val AUTO_STOP_DELAY = 5L * 60 * 1000
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// 중요: 잠금 화면 위 표시 설정을 가장 먼저 적용
|
||||
setupLockScreenFlags()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// ForegroundService가 실행 중이면 먼저 중지
|
||||
stopService(Intent(this, AlarmForegroundService::class.java))
|
||||
|
||||
// Service 중지 후 약간의 지연을 두어 AudioFocus가 완전히 해제되도록 함
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (e: InterruptedException) {
|
||||
// 무시
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
binding = ActivityAlarmBinding.inflate(layoutInflater)
|
||||
binding.root.background = ContextCompat.getDrawable(this, R.drawable.bg_alarm_gradient)
|
||||
setContentView(binding.root)
|
||||
|
||||
// 추가 윈도우 플래그 설정
|
||||
setupWindowFlags()
|
||||
|
||||
val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무"
|
||||
binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람"
|
||||
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val amPm = if (now.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "오전" else "오후"
|
||||
val hour = now.get(java.util.Calendar.HOUR)
|
||||
val hourText = if (hour == 0) 12 else hour
|
||||
val min = now.get(java.util.Calendar.MINUTE)
|
||||
binding.tvCurrentTime.text = String.format("%s %d:%02d", amPm, hourText, min)
|
||||
|
||||
val today = LocalDate.now()
|
||||
val dayOfWeek = today.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.KOREAN)
|
||||
binding.tvDate.text = String.format("%d월 %d일 %s", today.monthValue, today.dayOfMonth, dayOfWeek)
|
||||
|
||||
// 알람 시작 (화면 상태와 무관하게 항상 실행)
|
||||
startAlarm()
|
||||
setupControls()
|
||||
|
||||
// 5분 후 자동 스누즈
|
||||
autoStopHandler.postDelayed({
|
||||
Toast.makeText(this, "알람이 자동으로 다시 울림 설정되었습니다.", Toast.LENGTH_LONG).show()
|
||||
snoozeAlarm()
|
||||
stopAlarm()
|
||||
finish()
|
||||
}, AUTO_STOP_DELAY)
|
||||
|
||||
// 키가드(잠금화면) 상태 변화 리스너 등록
|
||||
registerKeyguardListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠금 화면 관련 플래그를 super.onCreate 이전에 설정
|
||||
*/
|
||||
private fun setupLockScreenFlags() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupWindowFlags() {
|
||||
// ========================================
|
||||
// 알람 화면이 패턴/지문보다 먼저 표시되도록 설정
|
||||
// ========================================
|
||||
// 중요: requestDismissKeyguard()를 호출하면 패턴/지문이 먼저 뜸
|
||||
// 알람 화면을 먼저 띄우려면 FLAG_SHOW_WHEN_LOCKED만 사용해야 함
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
// 1. 가장 먼저: 화면 켜기 + 잠금화면 위에 표시
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
}
|
||||
|
||||
// 2. 화면 켜짐 유지
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
// 3. 하위 호환성: Android 8.0 이하
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Android 14+ 추가 플래그
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_OVERSCAN)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||
}
|
||||
|
||||
// 5. Android 10+ 레이아웃
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
|
||||
}
|
||||
|
||||
// 6. 전체화면 모드 (모든 기기 공통)
|
||||
setFullscreenMode()
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체화면 모드 설정
|
||||
*/
|
||||
private fun isSamsungDevice(): Boolean {
|
||||
val manufacturer = Build.MANUFACTURER?.lowercase() ?: ""
|
||||
val brand = Build.BRAND?.lowercase() ?: ""
|
||||
return manufacturer.contains("samsung") || brand.contains("samsung")
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체화면 모드 설정
|
||||
*/
|
||||
private fun setFullscreenMode() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Android 11+ (API 30+): WindowInsetsController 사용
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
window.insetsController?.let { controller ->
|
||||
controller.hide(android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars())
|
||||
controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
} else {
|
||||
// Android 10 이하: systemUiVisibility 사용
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupControls() {
|
||||
binding.btnSnooze.setOnClickListener {
|
||||
handleSnooze()
|
||||
}
|
||||
|
||||
// Swipe-to-dismiss for Stop Button
|
||||
var startX = 0f
|
||||
val dismissBtn = binding.btnDismiss
|
||||
val maxSwipe = dpToPx(100f).toFloat()
|
||||
|
||||
dismissBtn.setOnTouchListener { v, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
startX = event.rawX
|
||||
v.animate().cancel()
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dx = event.rawX - startX
|
||||
val clampedDx = if (dx > 0) dx.coerceAtMost(maxSwipe) else dx.coerceAtLeast(-maxSwipe)
|
||||
v.translationX = clampedDx
|
||||
|
||||
// Visual feedback: scale up when near trigger
|
||||
val ratio = abs(clampedDx) / maxSwipe
|
||||
v.scaleX = 1f + (ratio * 0.15f)
|
||||
v.scaleY = 1f + (ratio * 0.15f)
|
||||
v.alpha = 1f - (ratio * 0.3f)
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
val dx = event.rawX - startX
|
||||
if (abs(dx) > maxSwipe * 0.8f) {
|
||||
// Trigger Dismiss
|
||||
(getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50)
|
||||
Toast.makeText(this, "알람 해제 완료", Toast.LENGTH_SHORT).show()
|
||||
stopAlarm(); finish()
|
||||
} else {
|
||||
// Reset
|
||||
v.animate()
|
||||
.translationX(0f)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.alpha(1f)
|
||||
.setDuration(300)
|
||||
.start()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// Pulse logic with enhanced glow
|
||||
fun startPulse() {
|
||||
binding.pulseCircle.scaleX = 0.85f; binding.pulseCircle.scaleY = 0.85f; binding.pulseCircle.alpha = 0.5f
|
||||
binding.pulseCircle.animate()
|
||||
.scaleX(1.3f).scaleY(1.3f).alpha(1.0f)
|
||||
.setDuration(1500)
|
||||
.withEndAction {
|
||||
binding.pulseCircle.animate()
|
||||
.scaleX(0.85f).scaleY(0.85f).alpha(0.5f)
|
||||
.setDuration(1500)
|
||||
.withEndAction { if(!isFinishing) startPulse() }
|
||||
.start()
|
||||
}
|
||||
.start()
|
||||
}
|
||||
startPulse()
|
||||
}
|
||||
|
||||
private fun handleSnooze() {
|
||||
(getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50)
|
||||
val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3)
|
||||
val text = if (snoozeRepeat == 99) "다시 울림 설정됨" else "다시 울림 (${snoozeRepeat}회 남음)"
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
snoozeAlarm(); stopAlarm(); finish()
|
||||
}
|
||||
|
||||
private fun dpToPx(dp: Float): Int {
|
||||
return (dp * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
private fun startAlarm() {
|
||||
if (mediaPlayer?.isPlaying == true) {
|
||||
Log.d("AlarmActivity", "MediaPlayer가 이미 실행 중")
|
||||
return
|
||||
}
|
||||
|
||||
val soundUriStr = intent.getStringExtra("EXTRA_SOUND")
|
||||
val alarmUri = if (!soundUriStr.isNullOrEmpty()) {
|
||||
Uri.parse(soundUriStr)
|
||||
} else {
|
||||
val prefs = getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val globalUriStr = prefs.getString("alarm_uri", null)
|
||||
if (globalUriStr != null) Uri.parse(globalUriStr)
|
||||
else android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
|
||||
}
|
||||
|
||||
// AudioAttributes 강화: 화면 켜진 상태에서도 알람음이 울리도록
|
||||
val audioAttrs = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) // 볼륨 강제 적용
|
||||
.build()
|
||||
|
||||
// AudioManager를 통해 알람 볼륨 설정
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_ALARM)
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM)
|
||||
|
||||
// 알람 볼륨을 최대로 설정 (사용자가 나중에 조정 가능)
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_ALARM, maxVolume, 0)
|
||||
} catch (e: Exception) {
|
||||
Log.w("AlarmActivity", "알람 볼륨 설정 실패", e)
|
||||
}
|
||||
|
||||
var mediaPlayerStarted = false
|
||||
|
||||
try {
|
||||
mediaPlayer?.release()
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(audioAttrs)
|
||||
setDataSource(this@AlarmActivity, alarmUri!!)
|
||||
isLooping = true
|
||||
setVolume(1.0f, 1.0f) // 최대 볼륨
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
mediaPlayerStarted = true
|
||||
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (사용자 지정음)")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AlarmActivity", "MediaPlayer 시작 실패 (사용자 지정음), fallback 시도", e)
|
||||
|
||||
// Fallback 1: 시스템 기본 알람음
|
||||
try {
|
||||
val fallback = android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(audioAttrs)
|
||||
setDataSource(this@AlarmActivity, fallback)
|
||||
isLooping = true
|
||||
setVolume(1.0f, 1.0f)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
mediaPlayerStarted = true
|
||||
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 1: 시스템 기본)")
|
||||
} catch (e2: Exception) {
|
||||
Log.e("AlarmActivity", "Fallback 1 실패", e2)
|
||||
|
||||
// Fallback 2: RingtoneManager에서 기본 알람 가져오기
|
||||
try {
|
||||
val ringtoneUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM)
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(audioAttrs)
|
||||
setDataSource(this@AlarmActivity, ringtoneUri)
|
||||
isLooping = true
|
||||
setVolume(1.0f, 1.0f)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
mediaPlayerStarted = true
|
||||
Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 2: RingtoneManager)")
|
||||
} catch (e3: Exception) {
|
||||
Log.e("AlarmActivity", "모든 MediaPlayer 시작 실패", e3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 진동 시작 (알람음과 독립적으로 - 알람음 실패필도 진동은 울림)
|
||||
vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 1000, 500, 1000), 0)
|
||||
vibrator?.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator?.vibrate(longArrayOf(0, 1000, 500, 1000), 0)
|
||||
}
|
||||
Log.d("AlarmActivity", "진동 시작 성공")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AlarmActivity", "진동 시작 실패", e)
|
||||
}
|
||||
|
||||
// 알람음 시작 실패 시 토스트 메시지
|
||||
if (!mediaPlayerStarted) {
|
||||
Toast.makeText(this, "알람음 재생에 실패했습니다. 진동으로 알려드립니다.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snoozeAlarm() {
|
||||
val snoozeMin = intent.getIntExtra("EXTRA_SNOOZE", 5)
|
||||
val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3)
|
||||
val soundUriStr = intent.getStringExtra("EXTRA_SOUND")
|
||||
|
||||
if (snoozeRepeat > 0) {
|
||||
val nextRepeat = if (snoozeRepeat == 99) 99 else snoozeRepeat - 1
|
||||
scheduleSnooze(this, snoozeMin, soundUriStr, nextRepeat)
|
||||
} else {
|
||||
Toast.makeText(this, "다시 울림 횟수를 모두 소모하여 알람을 종료합니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAlarm() {
|
||||
stopService(Intent(this, AlarmForegroundService::class.java))
|
||||
|
||||
try {
|
||||
mediaPlayer?.let {
|
||||
if (it.isPlaying) it.stop()
|
||||
it.release()
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
mediaPlayer = null
|
||||
|
||||
try {
|
||||
vibrator?.cancel()
|
||||
} catch (e: Exception) {}
|
||||
vibrator = null
|
||||
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
nm.cancel(1)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무"
|
||||
binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람"
|
||||
|
||||
stopAlarm()
|
||||
startAlarm()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
// 화면 켜짐 및 잠금 화면 위 표시 재적용
|
||||
setupWindowFlags()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
autoStopHandler.removeCallbacksAndMessages(null)
|
||||
stopAlarm()
|
||||
unregisterKeyguardListener()
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 키가드(잠금화면) 상태 감지 및 알람 해제 처리
|
||||
// ========================================
|
||||
private var keyguardManager: KeyguardManager? = null
|
||||
private var keyguardCallback: KeyguardManager.KeyguardDismissCallback? = null
|
||||
|
||||
/**
|
||||
* 키가드(잠금화면) 상태 변화를 감지하여 알람을 적절히 처리
|
||||
* 안드로이드 버전별로 다른 방식으로 처리
|
||||
*/
|
||||
private fun registerKeyguardListener() {
|
||||
keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Android 8.0+: KeyguardDismissCallback 사용
|
||||
keyguardCallback = object : KeyguardManager.KeyguardDismissCallback() {
|
||||
override fun onDismissError() {
|
||||
Log.e("AlarmActivity", "Keyguard dismiss error")
|
||||
}
|
||||
|
||||
override fun onDismissSucceeded() {
|
||||
Log.d("AlarmActivity", "Keyguard dismissed successfully - 사용자가 패턴/지문으로 해제함")
|
||||
// 패턴/지문 해제 후 알람 계속 울리게 하려면 여기서 아무것도 하지 않음
|
||||
// 알람을 자동으로 멈추려면: stopAlarm(); finish()
|
||||
}
|
||||
|
||||
override fun onDismissCancelled() {
|
||||
Log.d("AlarmActivity", "Keyguard dismiss cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterKeyguardListener() {
|
||||
keyguardCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 키가드(잠금화면)가 잠겨있는지 확인
|
||||
*/
|
||||
private fun isKeyguardLocked(): Boolean {
|
||||
return keyguardManager?.isKeyguardLocked ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 키가드(잠금화면)가 보안 잠금(패턴/PIN/지문)을 사용하는지 확인
|
||||
*/
|
||||
private fun isKeyguardSecure(): Boolean {
|
||||
return keyguardManager?.isKeyguardSecure ?: false
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// 홈 버튼이나 다른 앱으로 전환 시 알람 계속 울리도록 함
|
||||
// 사용자가 의도적으로 알람을 해제하지 않았으므로
|
||||
Log.d("AlarmActivity", "onPause - 알람 계속 유지")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// 알람 화면이 백그라운드로 갔을 때
|
||||
// 잠금화면이 다시 잠기면 알람을 멈추지 않고 계속 유지
|
||||
Log.d("AlarmActivity", "onStop - 알람 계속 유지")
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
// 알람 화면이 다시 포커스를 받으면 전체화면 모드 재적용
|
||||
setFullscreenMode()
|
||||
Log.d("AlarmActivity", "Window focus regained")
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/com/example/shiftalarm/AlarmEventLogger.kt
Normal file
24
app/src/main/java/com/example/shiftalarm/AlarmEventLogger.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
import android.util.Log
|
||||
|
||||
class AlarmEventLogger {
|
||||
companion object {
|
||||
private const val TAG = "AlarmEventLogger"
|
||||
}
|
||||
|
||||
fun logAlarmEvent(event: String) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Alarm Event: $event at $currentTime")
|
||||
}
|
||||
|
||||
fun logAlarmSet(alarmId: Int, time: String) {
|
||||
Log.i(TAG, "Alarm set: ID = $alarmId, Time = $time")
|
||||
}
|
||||
|
||||
fun logAlarmTriggered(alarmId: Int) {
|
||||
Log.w(TAG, "Alarm triggered: ID = $alarmId")
|
||||
}
|
||||
|
||||
fun logAlarmCanceled(alarmId: Int) {
|
||||
Log.e(TAG, "Alarm canceled: ID = $alarmId")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class AlarmForegroundService : Service() {
|
||||
|
||||
private val CHANNEL_ID = "SHIFT_ALARM_CHANNEL_V5"
|
||||
private val NOTIFICATION_ID = 1
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val shiftType = intent?.getStringExtra("EXTRA_SHIFT") ?: "근무"
|
||||
val soundUri = intent?.getStringExtra("EXTRA_SOUND")
|
||||
val snoozeMin = intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5
|
||||
val snoozeRepeat = intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3
|
||||
|
||||
// 1. 알림 채널 생성
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"교대링 알람",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "알람이 울리는 동안 표시되는 알림입니다."
|
||||
setSound(null, null) // 소리는 Activity에서 재생
|
||||
enableVibration(false) // 진동은 Activity에서 재생
|
||||
}
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
// 2. 전체화면 실행을 위한 PendingIntent
|
||||
val fullScreenIntent = Intent(this, AlarmActivity::class.java).apply {
|
||||
putExtra("EXTRA_SHIFT", shiftType)
|
||||
putExtra("EXTRA_SOUND", soundUri)
|
||||
putExtra("EXTRA_SNOOZE", snoozeMin)
|
||||
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
|
||||
val fullScreenPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
100,
|
||||
fullScreenIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// 3. 일원화된 단일 알림 생성
|
||||
val contentText = if (shiftType == "SNOOZE") "다시 울림 알람입니다." else "오늘의 근무는 [$shiftType] 입니다."
|
||||
|
||||
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("교대링 알람 작동 중")
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(R.drawable.ic_alarm_blue)
|
||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
.setContentIntent(fullScreenPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setAutoCancel(false)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
|
||||
// 4. Foreground 시작
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+
|
||||
startForeground(NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
}
|
||||
228
app/src/main/java/com/example/shiftalarm/AlarmPermissionUtil.kt
Normal file
228
app/src/main/java/com/example/shiftalarm/AlarmPermissionUtil.kt
Normal file
@@ -0,0 +1,228 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.AlarmManager
|
||||
import android.app.AlertDialog
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
object AlarmPermissionUtil {
|
||||
|
||||
/**
|
||||
* 전체 권한 상태를 확인하고 필요한 경우 통합 안내 다이얼로그를 표시합니다.
|
||||
*/
|
||||
fun checkAndRequestAllPermissions(activity: ComponentActivity) {
|
||||
val missingPermissions = mutableListOf<String>()
|
||||
|
||||
// 1. 알림 권한 (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
missingPermissions.add("알림 표시 (알람 울림 확인)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 정확한 알람 권한 (Android 12+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
if (!am.canScheduleExactAlarms()) {
|
||||
missingPermissions.add("정확한 알람 (정시에 울림 보장)")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 배터리 최적화 제외 (Android 6+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(activity.packageName)) {
|
||||
missingPermissions.add("배터리 최적화 제외 (절전 모드 무시)")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 전체화면 알림 권한 (Android 14+)
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (!nm.canUseFullScreenIntent()) {
|
||||
missingPermissions.add("전체화면 알림 (잠금 화면에서 즉시 표시)")
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
showIntegratedPermissionDialog(activity, missingPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showIntegratedPermissionDialog(activity: ComponentActivity, missing: List<String>) {
|
||||
val message = StringBuilder("안정적인 알람 작동을 위해 아래 권한들이 필요합니다:\n\n")
|
||||
missing.forEach { message.append("- $it\n") }
|
||||
message.append("\n[확인]을 누르면 설정 화면으로 순차적으로 안내합니다.")
|
||||
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("권한 설정 안내")
|
||||
.setMessage(message.toString())
|
||||
.setPositiveButton("확인") { _, _ ->
|
||||
startPermissionFlow(activity)
|
||||
}
|
||||
.setNegativeButton("나중에", null)
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun startPermissionFlow(activity: ComponentActivity) {
|
||||
// 순차적으로 가장 중요한 것부터 요청
|
||||
|
||||
// 1. 알림 권한 (시스템 팝업)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
|
||||
return // 알림 권한 결과 콜백 이후 다음으로 넘어가도록 유도 (혹은 그냥 연달아 띄움)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 배터리 최적화 (시스템 팝업)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(activity.packageName)) {
|
||||
requestBatteryOptimization(activity)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 정확한 알람 (설정 화면)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = activity.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
if (!am.canScheduleExactAlarms()) {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
||||
data = Uri.parse("package:${activity.packageName}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 전체화면 알림 (설정 화면)
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val nm = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (!nm.canUseFullScreenIntent()) {
|
||||
try {
|
||||
val intent = Intent("android.settings.MANAGE_APP_USE_FULL_SCREEN_INTENT").apply {
|
||||
data = Uri.parse("package:${activity.packageName}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${activity.packageName}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestBatteryOptimization(context: Context) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e2: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestOverlayPermission(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFullScreenIntentPermission(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
try {
|
||||
val intent = Intent("android.settings.MANAGE_APP_USE_FULL_SCREEN_INTENT").apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isAllPermissionsGranted(context: Context): Boolean {
|
||||
var allGranted = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
allGranted = allGranted && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
allGranted = allGranted && am.canScheduleExactAlarms()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
allGranted = allGranted && pm.isIgnoringBatteryOptimizations(context.packageName)
|
||||
allGranted = allGranted && Settings.canDrawOverlays(context)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
allGranted = allGranted && nm.canUseFullScreenIntent()
|
||||
}
|
||||
return allGranted
|
||||
}
|
||||
|
||||
fun getBatteryOptimizationStatus(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun getExactAlarmStatus(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
return am.canScheduleExactAlarms()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun getOverlayStatus(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
return Settings.canDrawOverlays(context)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun getFullScreenIntentStatus(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
return nm.canUseFullScreenIntent()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
103
app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt
Normal file
103
app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
private val TAG = "AlarmReceiver"
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Log.d(TAG, "===== 알람 수신 (Receiver) =====")
|
||||
|
||||
val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1
|
||||
val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false
|
||||
|
||||
// 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결)
|
||||
if (isCustom && alarmId != -1) {
|
||||
val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1)
|
||||
if (customAlarmId != -1) {
|
||||
// 비동기로 DB 확인
|
||||
val scope = CoroutineScope(Dispatchers.IO)
|
||||
scope.launch {
|
||||
val repo = ShiftRepository(context)
|
||||
val alarms = repo.getAllCustomAlarms()
|
||||
val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled }
|
||||
|
||||
if (!alarmExists) {
|
||||
Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)")
|
||||
scope.cancel()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작
|
||||
startAlarm(context, intent)
|
||||
scope.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 알람은 바로 직접 실행
|
||||
startAlarm(context, intent)
|
||||
}
|
||||
|
||||
private fun startAlarm(context: Context, intent: Intent?) {
|
||||
// WakeLock 획득 (화면 켜기 및 Activity 실행 보장)
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
|
||||
"ShiftAlarm::AlarmWakeLock"
|
||||
)
|
||||
wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간
|
||||
|
||||
try {
|
||||
// 1. Foreground Service 시작 (알림 표시 및 시스템에 알람 실행 중 알림)
|
||||
val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply {
|
||||
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
|
||||
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
|
||||
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
|
||||
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(serviceIntent)
|
||||
} else {
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
|
||||
// 2. AlarmActivity 직접 실행 (알람 화면 표시)
|
||||
val activityIntent = Intent(context, AlarmActivity::class.java).apply {
|
||||
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무")
|
||||
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND"))
|
||||
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5)
|
||||
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3)
|
||||
// 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
// 기존 인스턴스 재사용 및 최상위로 가져오기
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
// 잠금 화면 위에 표시
|
||||
addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT)
|
||||
// 화면 켜기
|
||||
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
}
|
||||
|
||||
context.startActivity(activityIntent)
|
||||
Log.d(TAG, "AlarmActivity 실행 완료")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "알람 실행 실패", e)
|
||||
} finally {
|
||||
// WakeLock은 Activity가 화면을 켜고 나서 해제
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
299
app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt
Normal file
299
app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt
Normal file
@@ -0,0 +1,299 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* 알람 동기화 관리자
|
||||
* DB와 AlarmManager 간의 실시간 동기화를 보장합니다.
|
||||
*
|
||||
* 동기화 전략:
|
||||
* 1. DB 작업과 AlarmManager 작업을 원자적으로 처리
|
||||
* 2. 실패 시 롤백 메커니즘 제공
|
||||
* 3. 동기화 상태 추적 및 재시도
|
||||
*/
|
||||
object AlarmSyncManager {
|
||||
|
||||
private const val TAG = "AlarmSyncManager"
|
||||
private const val PREFS_NAME = "AlarmSyncPrefs"
|
||||
|
||||
/**
|
||||
* 알람 추가 동기화
|
||||
* DB에 추가 후 AlarmManager에 즉시 예약
|
||||
*/
|
||||
suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val repo = ShiftRepository(context)
|
||||
|
||||
// 1. DB에 알람 추가
|
||||
val alarmId = repo.addCustomAlarm(alarm)
|
||||
Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId")
|
||||
|
||||
// 2. AlarmManager에 예약
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
val customAlarms = repo.getAllCustomAlarms()
|
||||
val addedAlarm = customAlarms.find { it.id == alarmId.toInt() }
|
||||
|
||||
if (addedAlarm == null) {
|
||||
Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId")
|
||||
return@withContext Result.failure(Exception("알람을 찾을 수 없습니다"))
|
||||
}
|
||||
|
||||
if (addedAlarm.isEnabled) {
|
||||
// 향후 30일치 예약
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
val shift = repo.getShift(targetDate,
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_team", "A") ?: "A",
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
)
|
||||
|
||||
if (addedAlarm.shiftType == "기타" || addedAlarm.shiftType == shift) {
|
||||
scheduleCustomAlarm(
|
||||
context,
|
||||
targetDate,
|
||||
addedAlarm.id,
|
||||
addedAlarm.shiftType,
|
||||
addedAlarm.time,
|
||||
addedAlarm.soundUri,
|
||||
addedAlarm.snoozeInterval,
|
||||
addedAlarm.snoozeRepeat
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "알람 AlarmManager 예약 완료: ID=$alarmId")
|
||||
}
|
||||
|
||||
// 3. 동기화 상태 저장
|
||||
saveSyncStatus(context, "last_add_alarm", System.currentTimeMillis())
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "알람 추가 동기화 실패", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알람 수정 동기화
|
||||
* DB 수정 후 기존 AlarmManager 예약 취소 후 재예약
|
||||
*/
|
||||
suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val repo = ShiftRepository(context)
|
||||
|
||||
// 1. 기존 AlarmManager 예약 취소
|
||||
cancelAllCustomAlarmSchedules(context, alarm.id)
|
||||
Log.d(TAG, "기존 알람 예약 취소 완료: ID=${alarm.id}")
|
||||
|
||||
// 2. DB 업데이트
|
||||
repo.updateCustomAlarm(alarm)
|
||||
Log.d(TAG, "알람 DB 업데이트 완료: ID=${alarm.id}")
|
||||
|
||||
// 3. 활성화된 알람이면 재예약
|
||||
if (alarm.isEnabled) {
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
val shift = repo.getShift(targetDate,
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_team", "A") ?: "A",
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
)
|
||||
|
||||
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
|
||||
scheduleCustomAlarm(
|
||||
context,
|
||||
targetDate,
|
||||
alarm.id,
|
||||
alarm.shiftType,
|
||||
alarm.time,
|
||||
alarm.soundUri,
|
||||
alarm.snoozeInterval,
|
||||
alarm.snoozeRepeat
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}")
|
||||
}
|
||||
|
||||
// 4. 동기화 상태 저장
|
||||
saveSyncStatus(context, "last_update_alarm", System.currentTimeMillis())
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "알람 수정 동기화 실패", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알람 삭제 동기화
|
||||
* AlarmManager 예약 먼저 취소 후 DB에서 삭제
|
||||
*/
|
||||
suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val repo = ShiftRepository(context)
|
||||
|
||||
// 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!)
|
||||
cancelAllCustomAlarmSchedules(context, alarm.id)
|
||||
Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}")
|
||||
|
||||
// 2. DB에서 삭제
|
||||
repo.deleteCustomAlarm(alarm)
|
||||
Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}")
|
||||
|
||||
// 3. 동기화 상태 저장
|
||||
saveSyncStatus(context, "last_delete_alarm", System.currentTimeMillis())
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "알람 삭제 동기화 실패", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알람 토글 동기화 (활성화/비활성화)
|
||||
*/
|
||||
suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val repo = ShiftRepository(context)
|
||||
val updatedAlarm = alarm.copy(isEnabled = enable)
|
||||
|
||||
if (enable) {
|
||||
// 활성화: DB 업데이트 후 예약
|
||||
repo.updateCustomAlarm(updatedAlarm)
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
val shift = repo.getShift(targetDate,
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_team", "A") ?: "A",
|
||||
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
)
|
||||
|
||||
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
|
||||
scheduleCustomAlarm(
|
||||
context,
|
||||
targetDate,
|
||||
alarm.id,
|
||||
alarm.shiftType,
|
||||
alarm.time,
|
||||
alarm.soundUri,
|
||||
alarm.snoozeInterval,
|
||||
alarm.snoozeRepeat
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}")
|
||||
} else {
|
||||
// 비활성화: 예약 취소 후 DB 업데이트
|
||||
cancelAllCustomAlarmSchedules(context, alarm.id)
|
||||
repo.updateCustomAlarm(updatedAlarm)
|
||||
Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}")
|
||||
}
|
||||
|
||||
saveSyncStatus(context, "last_toggle_alarm", System.currentTimeMillis())
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "알람 토글 동기화 실패", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 알람 동기화 (앱 시작 시 호출)
|
||||
*/
|
||||
suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "전체 알람 동기화 시작")
|
||||
|
||||
// 1. 기존 모든 알람 취소
|
||||
val repo = ShiftRepository(context)
|
||||
val allAlarms = repo.getAllCustomAlarms()
|
||||
|
||||
for (alarm in allAlarms) {
|
||||
cancelAllCustomAlarmSchedules(context, alarm.id)
|
||||
}
|
||||
Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}개")
|
||||
|
||||
// 2. 활성화된 알람만 재예약
|
||||
val enabledAlarms = allAlarms.filter { it.isEnabled }
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val team = prefs.getString("selected_team", "A") ?: "A"
|
||||
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
|
||||
var scheduledCount = 0
|
||||
for (alarm in enabledAlarms) {
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
val shift = repo.getShift(targetDate, team, factory)
|
||||
|
||||
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
|
||||
scheduleCustomAlarm(
|
||||
context,
|
||||
targetDate,
|
||||
alarm.id,
|
||||
alarm.shiftType,
|
||||
alarm.time,
|
||||
alarm.soundUri,
|
||||
alarm.snoozeInterval,
|
||||
alarm.snoozeRepeat
|
||||
)
|
||||
scheduledCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람, ${scheduledCount}개 예약")
|
||||
|
||||
// 3. 동기화 상태 저장
|
||||
saveSyncStatus(context, "last_full_sync", System.currentTimeMillis())
|
||||
|
||||
Result.success(SyncResult(
|
||||
totalAlarms = allAlarms.size,
|
||||
enabledAlarms = enabledAlarms.size,
|
||||
scheduledAlarms = scheduledCount
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "전체 알람 동기화 실패", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기화 상태 저장
|
||||
*/
|
||||
private fun saveSyncStatus(context: Context, key: String, timestamp: Long) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(key, timestamp)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 동기화 시간 확인
|
||||
*/
|
||||
fun getLastSyncTime(context: Context, key: String): Long {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getLong(key, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 동기화 결과 데이터 클래스
|
||||
*/
|
||||
data class SyncResult(
|
||||
val totalAlarms: Int,
|
||||
val enabledAlarms: Int,
|
||||
val scheduledAlarms: Int
|
||||
)
|
||||
}
|
||||
350
app/src/main/java/com/example/shiftalarm/AlarmUtils.kt
Normal file
350
app/src/main/java/com/example/shiftalarm/AlarmUtils.kt
Normal file
@@ -0,0 +1,350 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
const val TAG = "ShiftAlarm"
|
||||
|
||||
// ============================================
|
||||
// 알람 ID 생성
|
||||
// ============================================
|
||||
fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int {
|
||||
// Combine date and a unique ID from DB to avoid collisions
|
||||
// Using (uniqueId % 1000) to keep it within a reasonable range
|
||||
return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 사용자 알람 예약
|
||||
// ============================================
|
||||
fun scheduleCustomAlarm(
|
||||
context: Context,
|
||||
date: LocalDate,
|
||||
uniqueId: Int,
|
||||
shiftType: String,
|
||||
time: String,
|
||||
soundUri: String? = null,
|
||||
snoozeMin: Int = 5,
|
||||
snoozeRepeat: Int = 3
|
||||
) {
|
||||
val alarmId = getCustomAlarmId(date, uniqueId)
|
||||
val label = "사용자:$shiftType"
|
||||
|
||||
val parts = time.split(":")
|
||||
if (parts.size != 2) return
|
||||
val hour = parts[0].toIntOrNull() ?: return
|
||||
val min = parts[1].toIntOrNull() ?: return
|
||||
|
||||
cancelAlarmInternal(context, alarmId)
|
||||
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
||||
putExtra("EXTRA_SHIFT", label)
|
||||
putExtra("EXTRA_DATE", date.toString())
|
||||
putExtra("EXTRA_TIME", time)
|
||||
putExtra("EXTRA_ALARM_ID", alarmId)
|
||||
putExtra("EXTRA_IS_CUSTOM", true)
|
||||
putExtra("EXTRA_UNIQUE_ID", uniqueId) // DB 검증용
|
||||
putExtra("EXTRA_SOUND", soundUri)
|
||||
putExtra("EXTRA_SNOOZE", snoozeMin)
|
||||
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, alarmId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min))
|
||||
.withSecond(0).withNano(0)
|
||||
|
||||
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
|
||||
|
||||
if (alarmTime > System.currentTimeMillis()) {
|
||||
setExactAlarm(context, alarmTime, pendingIntent)
|
||||
Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 알람 취소 (전체 범위)
|
||||
// ============================================
|
||||
fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) {
|
||||
val alarmId = getCustomAlarmId(date, uniqueId)
|
||||
cancelAlarmInternal(context, alarmId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 알람의 모든 예약을 완전히 취소합니다.
|
||||
* DB에서 삭제하기 전에 반드시 호출해야 합니다.
|
||||
* 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소
|
||||
*/
|
||||
fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) {
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
|
||||
// 1. 과거 30일치 취소 (혹시 모를 과거 예약)
|
||||
for (i in -30 until 0) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
cancelCustomAlarm(context, targetDate, uniqueId)
|
||||
}
|
||||
|
||||
// 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버)
|
||||
for (i in 0 until 365) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
cancelCustomAlarm(context, targetDate, uniqueId)
|
||||
}
|
||||
|
||||
// 3. 스누즈 알람도 취소 (스누즈는 999999 ID 사용)
|
||||
cancelSnoozeAlarm(context)
|
||||
|
||||
// 4. 테스트 알람도 취소 (테스트는 888888 ID 사용)
|
||||
cancelTestAlarm(context)
|
||||
|
||||
// 5. 해당 uniqueId와 관련된 모든 가능한 PendingIntent 취소 (추가 안전장치)
|
||||
cancelAllPendingIntentsForUniqueId(context, uniqueId)
|
||||
|
||||
Log.d(TAG, "알람 예약 완전 취소 완료 (ID: $uniqueId, 범위: -30일 ~ +365일)")
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 uniqueId에 대한 모든 가능한 PendingIntent를 취소합니다.
|
||||
* 알람 ID 생성 공식의 역연산을 통해 모든 가능성을 커버합니다.
|
||||
*/
|
||||
private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
// uniqueId % 100의 모든 가능한 값에 대해 취소 시도
|
||||
val baseId = uniqueId % 100
|
||||
|
||||
// 현재 연도 기준으로 여러 해에 걸친 가능한 ID들
|
||||
val currentYear = LocalDate.now(SEOUL_ZONE).year % 100
|
||||
val years = listOf(currentYear - 1, currentYear, currentYear + 1)
|
||||
|
||||
for (year in years) {
|
||||
if (year < 0) continue
|
||||
for (month in 1..12) {
|
||||
for (day in 1..31) {
|
||||
try {
|
||||
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId
|
||||
val intent = Intent(context, AlarmReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, alarmId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
} catch (e: Exception) {
|
||||
// 무시 - 유효하지 않은 날짜 조합
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "uniqueId $uniqueId 관련 모든 PendingIntent 취소 완료")
|
||||
}
|
||||
|
||||
/**
|
||||
* 스누즈 알람 취소
|
||||
*/
|
||||
/**
|
||||
* 스누즈 알람 취소 - 모든 가능한 스누즈 ID 취소
|
||||
*/
|
||||
fun cancelSnoozeAlarm(context: Context) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
// 주요 스누즈 ID들 취소
|
||||
val snoozeIds = listOf(999999, 999998, 999997, 999996, 999995)
|
||||
|
||||
for (snoozeId in snoozeIds) {
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
action = "com.example.shiftalarm.SNOOZE"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, snoozeId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
|
||||
Log.d(TAG, "스누즈 알람 취소 완료")
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 알람 취소
|
||||
*/
|
||||
private fun cancelTestAlarm(context: Context) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 888888, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
Log.d(TAG, "테스트 알람 취소 완료")
|
||||
}
|
||||
|
||||
private fun cancelAlarmInternal(context: Context, alarmId: Int) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, AlarmReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, alarmId, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
pendingIntent.cancel()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 정밀 알람 설정 (setAlarmClock 우선)
|
||||
// ============================================
|
||||
private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (!alarmManager.canScheduleExactAlarms()) {
|
||||
Log.e(TAG, "정확한 알람 권한 없음!")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// setAlarmClock은 Doze 모드에서도 정확하게 작동하며 상단바 알람 아이콘을 활성화함 (신뢰도 최고)
|
||||
try {
|
||||
val viewIntent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val viewPendingIntent = PendingIntent.getActivity(
|
||||
context, 0, viewIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent)
|
||||
alarmManager.setAlarmClock(clockInfo, pendingIntent)
|
||||
Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "setAlarmClock 실패, fallback 사용", e)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
|
||||
Log.d(TAG, "setExactAndAllowWhileIdle 예약 성공")
|
||||
} else {
|
||||
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
|
||||
Log.d(TAG, "setExact 예약 성공")
|
||||
}
|
||||
} catch (e2: Exception) {
|
||||
Log.e(TAG, "모든 알람 예약 방법 실패", e2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 스누즈
|
||||
// ============================================
|
||||
fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) {
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
action = "com.example.shiftalarm.SNOOZE"
|
||||
putExtra("EXTRA_SHIFT", "SNOOZE")
|
||||
putExtra("EXTRA_SOUND", soundUri)
|
||||
putExtra("EXTRA_SNOOZE", snoozeMin)
|
||||
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 999999, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000)
|
||||
setExactAlarm(context, triggerTime, pendingIntent)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 테스트 알람 (5초 후)
|
||||
// ============================================
|
||||
fun scheduleTestAlarm(context: Context) {
|
||||
val intent = Intent(context, AlarmReceiver::class.java).apply {
|
||||
action = "com.example.shiftalarm.ALARM_TRIGGER"
|
||||
putExtra("EXTRA_SHIFT", "테스트")
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 888888, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val triggerTime = System.currentTimeMillis() + 5000
|
||||
setExactAlarm(context, triggerTime, pendingIntent)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 전체 동기화 (30일치 예약)
|
||||
// ============================================
|
||||
suspend fun syncAllAlarms(context: Context) {
|
||||
Log.d(TAG, "===== 전체 알람 동기화 시작 (30일) =====")
|
||||
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val repo = ShiftRepository(context)
|
||||
|
||||
val today = LocalDate.now(SEOUL_ZONE)
|
||||
val team = prefs.getString("selected_team", "A") ?: "A"
|
||||
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
|
||||
// 1. 기존 알람 모두 취소 (안전장치)
|
||||
// Custom 알람의 경우 ID가 uniqueId 기반이므로 모든 가능성 있는 ID를 취소하기는 어려움.
|
||||
// 대신 AlarmManager에서 해당 PendingIntent를 정확히 취소해야 함.
|
||||
// 하지만 uniqueId를 알 수 없으므로, 모든 날짜 루프에서 취소 시도.
|
||||
|
||||
val customAlarms = repo.getAllCustomAlarms()
|
||||
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
// 기본 알람 ID 취소 (이제 안 쓰지만 하위 호환/청소용)
|
||||
val legacyId = 100000000 + (targetDate.year % 100) * 1000000 + targetDate.monthValue * 10000 + targetDate.dayOfMonth * 100
|
||||
cancelAlarmInternal(context, legacyId)
|
||||
|
||||
// 커스텀 알람 취소
|
||||
customAlarms.forEach { alarm ->
|
||||
cancelCustomAlarm(context, targetDate, alarm.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
|
||||
Log.d(TAG, "마스터 알람이 꺼져 있어 예약을 중단합니다.")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 새로운 스케줄 생성
|
||||
for (i in 0 until 30) {
|
||||
val targetDate = today.plusDays(i.toLong())
|
||||
val shift = repo.getShift(targetDate, team, factory)
|
||||
|
||||
for (alarm in customAlarms) {
|
||||
if (!alarm.isEnabled) continue
|
||||
|
||||
// 근무 연동 조건 확인
|
||||
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
|
||||
scheduleCustomAlarm(
|
||||
context,
|
||||
targetDate,
|
||||
alarm.id,
|
||||
alarm.shiftType,
|
||||
alarm.time,
|
||||
alarm.soundUri,
|
||||
alarm.snoozeInterval,
|
||||
alarm.snoozeRepeat
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "===== 전체 알람 동기화 완료 =====")
|
||||
}
|
||||
28
app/src/main/java/com/example/shiftalarm/AlarmWorker.kt
Normal file
28
app/src/main/java/com/example/shiftalarm/AlarmWorker.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
|
||||
|
||||
class AlarmWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
syncAllAlarms(applicationContext)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/src/main/java/com/example/shiftalarm/AppDatabase.kt
Normal file
28
app/src/main/java/com/example/shiftalarm/AppDatabase.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@Database(entities = [ShiftOverride::class, DailyMemo::class, CustomAlarm::class], version = 3, exportSchema = false)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun shiftDao(): ShiftDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"shift_database"
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Simple for now
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
app/src/main/java/com/example/shiftalarm/AppUpdateManager.kt
Normal file
187
app/src/main/java/com/example/shiftalarm/AppUpdateManager.kt
Normal file
@@ -0,0 +1,187 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.FileProvider
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
object AppUpdateManager {
|
||||
|
||||
private const val VERSION_URL = "https://git.webpluss.net/sanjeok77/ShiftRing/raw/branch/main/version.json"
|
||||
|
||||
fun checkUpdate(activity: Activity, silent: Boolean = false) {
|
||||
val ctx = activity.applicationContext
|
||||
val versionCheckUrl = "$VERSION_URL?t=${System.currentTimeMillis()}"
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val url = URL(versionCheckUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.requestMethod = "GET"
|
||||
connection.useCaches = false
|
||||
|
||||
if (connection.responseCode == 200) {
|
||||
val reader = connection.inputStream.bufferedReader()
|
||||
val result = reader.readText()
|
||||
reader.close()
|
||||
|
||||
val json = JSONObject(result)
|
||||
val serverVersionName = json.getString("versionName")
|
||||
val apkUrl = json.getString("apkUrl")
|
||||
val changelog = json.optString("changelog", "버그 수정 및 성능 향상")
|
||||
|
||||
val pInfo = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
||||
val currentVersionName = pInfo.versionName ?: "0.0.0"
|
||||
|
||||
if (isNewerVersion(serverVersionName, currentVersionName)) {
|
||||
activity.runOnUiThread {
|
||||
showUpdateDialog(activity, serverVersionName, changelog, apkUrl)
|
||||
}
|
||||
} else if (!silent) {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(ctx, "현재 최신 버전을 사용 중입니다. ($currentVersionName)", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else if (!silent) {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(ctx, "서버 연결 실패", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if (!silent) {
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(ctx, "업데이트 확인 중 오류 발생", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun isNewerVersion(server: String, current: String): Boolean {
|
||||
try {
|
||||
// Clean version strings (remove non-numeric suffixes if any)
|
||||
val sClean = server.split("-")[0].split(" ")[0]
|
||||
val cClean = current.split("-")[0].split(" ")[0]
|
||||
|
||||
val sParts = sClean.split(".").map { it.filter { char -> char.isDigit() }.let { p -> if (p.isEmpty()) 0 else p.toInt() } }
|
||||
val cParts = cClean.split(".").map { it.filter { char -> char.isDigit() }.let { p -> if (p.isEmpty()) 0 else p.toInt() } }
|
||||
|
||||
val length = Math.max(sParts.size, cParts.size)
|
||||
for (i in 0 until length) {
|
||||
val s = if (i < sParts.size) sParts[i] else 0
|
||||
val c = if (i < cParts.size) cParts[i] else 0
|
||||
if (s > c) return true
|
||||
if (s < c) return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("AppUpdateManager", "Version comparison failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(activity: Activity, version: String, changelog: String, apkUrl: String) {
|
||||
com.google.android.material.dialog.MaterialAlertDialogBuilder(activity)
|
||||
.setTitle("새로운 업데이트 발견 (v$version)")
|
||||
.setMessage("업데이트 내용:\n$changelog\n\n지금 다운로드하시겠습니까?")
|
||||
.setPositiveButton("다운로드") { _, _ ->
|
||||
downloadAndInstallApk(activity, apkUrl, version)
|
||||
}
|
||||
.setNegativeButton("나중에", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun downloadAndInstallApk(activity: Activity, apkUrl: String, version: String) {
|
||||
val progressDialog = ProgressDialog(activity).apply {
|
||||
setTitle("업데이트 다운로드 중")
|
||||
setMessage("v$version 다운로드 중...")
|
||||
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||
setCancelable(false)
|
||||
max = 100
|
||||
show()
|
||||
}
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val url = URL(apkUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 15000
|
||||
connection.requestMethod = "GET"
|
||||
connection.connect()
|
||||
|
||||
val fileLength = connection.contentLength
|
||||
val inputStream = BufferedInputStream(connection.inputStream)
|
||||
|
||||
val apkFile = File(activity.cacheDir, "update.apk")
|
||||
val outputStream = FileOutputStream(apkFile)
|
||||
|
||||
val buffer = ByteArray(8192)
|
||||
var total: Long = 0
|
||||
var count: Int
|
||||
|
||||
while (inputStream.read(buffer).also { count = it } != -1) {
|
||||
total += count
|
||||
outputStream.write(buffer, 0, count)
|
||||
|
||||
if (fileLength > 0) {
|
||||
val progress = (total * 100 / fileLength).toInt()
|
||||
activity.runOnUiThread {
|
||||
progressDialog.progress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
connection.disconnect()
|
||||
|
||||
activity.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
installApk(activity, apkFile)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
activity.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
Toast.makeText(activity, "다운로드 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun installApk(activity: Activity, apkFile: File) {
|
||||
try {
|
||||
val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(activity, "${activity.packageName}.provider", apkFile)
|
||||
} else {
|
||||
Uri.fromFile(apkFile)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Toast.makeText(activity, "설치 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
178
app/src/main/java/com/example/shiftalarm/BackupManager.kt
Normal file
178
app/src/main/java/com/example/shiftalarm/BackupManager.kt
Normal file
@@ -0,0 +1,178 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Handles data backup and restoration (Database + SharedPreferences).
|
||||
* Format: JSON
|
||||
*/
|
||||
object BackupManager {
|
||||
|
||||
suspend fun backupData(context: Context, uri: Uri, dao: ShiftDao) = withContext(Dispatchers.IO) {
|
||||
val overrides = dao.getAllOverrides()
|
||||
val memos = dao.getAllMemos()
|
||||
|
||||
val json = JSONObject()
|
||||
|
||||
// 1. Backup Overrides
|
||||
val overrideArray = JSONArray()
|
||||
overrides.forEach {
|
||||
overrideArray.put(JSONObject().apply {
|
||||
put("date", it.date)
|
||||
put("shift", it.shift)
|
||||
put("team", it.team)
|
||||
put("factory", it.factory)
|
||||
})
|
||||
}
|
||||
json.put("overrides", overrideArray)
|
||||
|
||||
// 1.5 Backup Custom Alarms
|
||||
val customAlarms = dao.getAllCustomAlarms()
|
||||
val customAlarmArray = JSONArray()
|
||||
customAlarms.forEach {
|
||||
customAlarmArray.put(JSONObject().apply {
|
||||
put("time", it.time)
|
||||
put("shiftType", it.shiftType)
|
||||
put("isEnabled", it.isEnabled)
|
||||
put("soundUri", it.soundUri)
|
||||
put("snoozeInterval", it.snoozeInterval)
|
||||
put("snoozeRepeat", it.snoozeRepeat)
|
||||
})
|
||||
}
|
||||
json.put("custom_alarms_v2", customAlarmArray)
|
||||
|
||||
// 2. Backup Memos
|
||||
val memoArray = JSONArray()
|
||||
memos.forEach {
|
||||
memoArray.put(JSONObject().apply {
|
||||
put("date", it.date)
|
||||
put("content", it.content)
|
||||
})
|
||||
}
|
||||
json.put("memos", memoArray)
|
||||
|
||||
// 3. Backup Settings (SharedPreferences)
|
||||
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val settings = JSONObject()
|
||||
prefs.all.forEach { (key, value) ->
|
||||
if (value is String) settings.put(key, value)
|
||||
else if (value is Boolean) settings.put(key, value)
|
||||
else if (value is Int) settings.put(key, value)
|
||||
else if (value is Float) settings.put(key, value.toDouble())
|
||||
else if (value is Long) settings.put(key, value)
|
||||
else if (value is Double) settings.put(key, value)
|
||||
}
|
||||
json.put("settings", settings)
|
||||
|
||||
json.put("magic", "SHIFTRING_BACKUP_V3")
|
||||
json.put("timestamp", System.currentTimeMillis())
|
||||
|
||||
val finalString = json.toString()
|
||||
val encodedBytes = android.util.Base64.encode(finalString.toByteArray(), android.util.Base64.DEFAULT)
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { os ->
|
||||
os.write(encodedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreData(context: Context, uri: Uri, dao: ShiftDao) = withContext(Dispatchers.IO) {
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use {
|
||||
it.readBytes()
|
||||
} ?: throw Exception("Failed to read file")
|
||||
|
||||
var content = ""
|
||||
try {
|
||||
// Try Base64 first (V3)
|
||||
val decodedBytes = android.util.Base64.decode(bytes, android.util.Base64.DEFAULT)
|
||||
content = String(decodedBytes)
|
||||
} catch (e: Exception) {
|
||||
// Fallback to plain text (V1/V2)
|
||||
content = String(bytes)
|
||||
}
|
||||
|
||||
val json = JSONObject(content)
|
||||
|
||||
val magic = json.optString("magic", "")
|
||||
if (magic != "SHIFTRING_BACKUP_V1" && magic != "SHIFTRING_BACKUP_V2" && magic != "SHIFTRING_BACKUP_V3") {
|
||||
throw Exception("올바르지 않은 백업 파일 형식입니다.")
|
||||
}
|
||||
|
||||
// 1. Restore Settings FIRST
|
||||
if (json.has("settings")) {
|
||||
val settings = json.getJSONObject("settings")
|
||||
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE).edit()
|
||||
prefs.clear()
|
||||
val keys = settings.keys()
|
||||
while(keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
if (settings.isNull(key)) continue
|
||||
|
||||
val value = settings.get(key)
|
||||
when(value) {
|
||||
is Boolean -> prefs.putBoolean(key, value)
|
||||
is Int -> prefs.putInt(key, value)
|
||||
is String -> prefs.putString(key, value)
|
||||
is Double -> prefs.putFloat(key, value.toFloat())
|
||||
is Long -> prefs.putLong(key, value)
|
||||
}
|
||||
}
|
||||
prefs.apply()
|
||||
}
|
||||
|
||||
val restoredPrefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val fallbackFactory = restoredPrefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
val fallbackTeam = restoredPrefs.getString("selected_team", "A") ?: "A"
|
||||
|
||||
// 2. Restore Overrides
|
||||
if (json.has("overrides")) {
|
||||
dao.clearOverrides()
|
||||
val arr = json.getJSONArray("overrides")
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
dao.insertOverride(ShiftOverride(
|
||||
factory = obj.optString("factory", fallbackFactory),
|
||||
team = obj.optString("team", fallbackTeam),
|
||||
date = obj.getString("date"),
|
||||
shift = obj.getString("shift")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 Restore Custom Alarms
|
||||
if (json.has("custom_alarms_v2")) {
|
||||
dao.clearCustomAlarms()
|
||||
val arr = json.getJSONArray("custom_alarms_v2")
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
dao.insertCustomAlarm(CustomAlarm(
|
||||
time = obj.getString("time"),
|
||||
shiftType = obj.getString("shiftType"),
|
||||
isEnabled = obj.optBoolean("isEnabled", true),
|
||||
soundUri = obj.optString("soundUri", null),
|
||||
snoozeInterval = obj.optInt("snoozeInterval", 5),
|
||||
snoozeRepeat = obj.optInt("snoozeRepeat", 3)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Restore Memos
|
||||
if (json.has("memos")) {
|
||||
dao.clearMemos()
|
||||
val arr = json.getJSONArray("memos")
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
dao.insertMemo(DailyMemo(
|
||||
obj.getString("date"),
|
||||
obj.getString("content")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/com/example/shiftalarm/BootReceiver.kt
Normal file
37
app/src/main/java/com/example/shiftalarm/BootReceiver.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작")
|
||||
|
||||
// 1) 즉시 1회 실행 → 당일 알람을 바로 복구
|
||||
val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
"BootAlarmRestore",
|
||||
androidx.work.ExistingWorkPolicy.REPLACE,
|
||||
immediateWork
|
||||
)
|
||||
|
||||
// 2) 24시간 주기 반복 워커 등록
|
||||
val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
"DailyShiftCheck",
|
||||
androidx.work.ExistingPeriodicWorkPolicy.KEEP,
|
||||
periodicWork
|
||||
)
|
||||
|
||||
android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료")
|
||||
}
|
||||
}
|
||||
}
|
||||
238
app/src/main/java/com/example/shiftalarm/CalendarAdapter.kt
Normal file
238
app/src/main/java/com/example/shiftalarm/CalendarAdapter.kt
Normal file
@@ -0,0 +1,238 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.time.LocalDate
|
||||
|
||||
data class DayShift(
|
||||
val date: LocalDate?,
|
||||
val shift: String?,
|
||||
val hasMemo: Boolean = false,
|
||||
val memoContent: String? = null
|
||||
)
|
||||
|
||||
class CalendarAdapter(
|
||||
var days: List<DayShift>,
|
||||
private val listener: OnDayClickListener,
|
||||
var showHolidays: Boolean = true
|
||||
) : RecyclerView.Adapter<CalendarAdapter.ViewHolder>() {
|
||||
|
||||
interface OnDayClickListener {
|
||||
fun onDayClick(date: LocalDate, currentShift: String)
|
||||
}
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val root: View = view.findViewById(R.id.dayRoot)
|
||||
val dayNumber: TextView = view.findViewById(R.id.dayNumber)
|
||||
val shiftChar: TextView = view.findViewById(R.id.shiftChar)
|
||||
val holidayNameSmall: TextView = view.findViewById(R.id.holidayNameSmall)
|
||||
val memoIndicator: ImageView = view.findViewById(R.id.memoIndicator)
|
||||
val tvTide: TextView = view.findViewById(R.id.tvTide)
|
||||
val memoContent: TextView = view.findViewById(R.id.memoContent)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_day, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
private fun dpToPx(context: Context, dp: Float): Int {
|
||||
return (dp * context.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = days[position]
|
||||
val context = holder.itemView.context
|
||||
|
||||
if (item.date == null) {
|
||||
holder.itemView.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
holder.itemView.visibility = View.VISIBLE
|
||||
|
||||
// Day Number
|
||||
holder.dayNumber.text = item.date.dayOfMonth.toString()
|
||||
|
||||
// Holiday / Weekend logic
|
||||
val isSunday = item.date.dayOfWeek == java.time.DayOfWeek.SUNDAY
|
||||
val isSaturday = item.date.dayOfWeek == java.time.DayOfWeek.SATURDAY
|
||||
val fullHolidayName = HolidayManager.getHolidayName(item.date)
|
||||
val isToday = item.date == LocalDate.now()
|
||||
|
||||
// Day Number Color
|
||||
if (fullHolidayName != null || isSunday) {
|
||||
holder.dayNumber.setTextColor(Color.parseColor("#FF5252"))
|
||||
} else if (isSaturday) {
|
||||
holder.dayNumber.setTextColor(Color.parseColor("#448AFF"))
|
||||
} else {
|
||||
holder.dayNumber.setTextColor(ContextCompat.getColor(context, R.color.text_primary))
|
||||
}
|
||||
|
||||
// Tide Display
|
||||
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
|
||||
val showTide = prefs.getBoolean("show_tide", false)
|
||||
val tideLocation = prefs.getString("selected_tide_location", "군산") ?: "군산"
|
||||
|
||||
if (showTide) {
|
||||
val tide = HolidayManager.getTide(item.date, tideLocation)
|
||||
if (tide.isNotEmpty()) {
|
||||
holder.tvTide.visibility = View.VISIBLE
|
||||
holder.tvTide.text = tide
|
||||
} else {
|
||||
holder.tvTide.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
holder.tvTide.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
// --- Shift & Holiday Display Logic ---
|
||||
holder.shiftChar.background = null
|
||||
holder.shiftChar.text = ""
|
||||
holder.holidayNameSmall.visibility = View.GONE
|
||||
holder.shiftChar.textSize = 13f
|
||||
|
||||
// "반월", "반년" (Half-Monthly, Half-Yearly) Special Logic
|
||||
// These are overrides or specific shifts that user sets.
|
||||
// User requested: "월", "년" text. Half-filled background (Red + Transparent).
|
||||
// Check exact string or "startswith" if logic changed?
|
||||
// Logic in adapter `getShift` might return "반월", "반년".
|
||||
|
||||
if (showHolidays && fullHolidayName != null) {
|
||||
// Holiday Mode (Priority): Show full holiday name, no circle
|
||||
holder.shiftChar.text = fullHolidayName
|
||||
holder.shiftChar.setTextColor(Color.parseColor("#FF5252"))
|
||||
holder.shiftChar.textSize = 10f
|
||||
holder.shiftChar.background = null
|
||||
} else if (item.shift != null && item.shift != "비번") {
|
||||
// Shift Mode
|
||||
|
||||
// Handle specific "Half" cases first
|
||||
if (item.shift == "반월" || item.shift == "반년") {
|
||||
holder.shiftChar.text = if (item.shift == "반월") "월" else "년"
|
||||
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.black)) // Black for contrast on Half Red/Transparent
|
||||
holder.shiftChar.textSize = 13f
|
||||
holder.shiftChar.background = ContextCompat.getDrawable(context, R.drawable.bg_shift_half_red)
|
||||
} else {
|
||||
// Standard Logic
|
||||
val shiftAbbreviation = when (item.shift) {
|
||||
"주간" -> "주"
|
||||
"석간" -> "석"
|
||||
"야간" -> "야"
|
||||
"주간 맞교대" -> "주맞"
|
||||
"야간 맞교대" -> "야맞"
|
||||
"휴무", "휴가" -> "휴"
|
||||
"월차" -> "월"
|
||||
"연차" -> "연"
|
||||
"교육" -> "교"
|
||||
else -> item.shift.take(1)
|
||||
}
|
||||
holder.shiftChar.text = shiftAbbreviation
|
||||
holder.shiftChar.textSize = 15f
|
||||
holder.shiftChar.setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
|
||||
val shiftColorRes = when (item.shift) {
|
||||
"주간" -> R.color.shift_lemon
|
||||
"석간" -> R.color.shift_seok
|
||||
"야간" -> R.color.shift_ya
|
||||
"주간 맞교대" -> R.color.shift_jumat
|
||||
"야간 맞교대" -> R.color.shift_yamat
|
||||
"휴무", "휴가", "월차", "연차" -> R.color.shift_red
|
||||
"교육" -> R.color.primary
|
||||
else -> R.color.text_secondary
|
||||
}
|
||||
val shiftColor = ContextCompat.getColor(context, shiftColorRes)
|
||||
|
||||
if (isToday) {
|
||||
// Today: Solid Circle
|
||||
val background = ContextCompat.getDrawable(context, R.drawable.bg_shift_solid_v4) as? android.graphics.drawable.GradientDrawable
|
||||
background?.setColor(shiftColor)
|
||||
holder.shiftChar.background = background
|
||||
holder.shiftChar.backgroundTintList = null
|
||||
|
||||
if (item.shift == "주간" || item.shift == "석간") {
|
||||
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.black))
|
||||
} else {
|
||||
holder.shiftChar.setTextColor(Color.WHITE)
|
||||
}
|
||||
} else {
|
||||
// Not Today: Stroke Circle
|
||||
val background = ContextCompat.getDrawable(context, R.drawable.bg_shift_stroke_v4) as? android.graphics.drawable.GradientDrawable
|
||||
background?.setStroke(dpToPx(context, 1.5f), shiftColor)
|
||||
background?.setColor(Color.TRANSPARENT)
|
||||
|
||||
holder.shiftChar.background = background
|
||||
holder.shiftChar.backgroundTintList = null
|
||||
|
||||
holder.shiftChar.setTextColor(shiftColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lunar date small display if requested or just default
|
||||
if (!showHolidays && fullHolidayName != null) {
|
||||
holder.holidayNameSmall.visibility = View.VISIBLE
|
||||
holder.holidayNameSmall.text = fullHolidayName
|
||||
} else {
|
||||
// Ensure visibility GONE if not needed (e.g. standard day)
|
||||
holder.holidayNameSmall.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Double check: if showHolidays=true (Holiday mode), we handled it at top block.
|
||||
// But if showHolidays=true and NO holiday, we show lunar date?
|
||||
// User asked: "Overlap date and holiday text".
|
||||
// My item_day.xml has holidayNameSmall at bottom now.
|
||||
// If showHolidays=true, CalendarAdapter usually HIDES shiftChar and shows Holiday Name?
|
||||
// Wait, standard logic (lines 84-91 above):
|
||||
// If showHolidays && fullHolidayName != null -> shiftChar shows Name.
|
||||
// If showHolidays && fullHolidayName == null -> shiftChar shows LUNAR DATE? (Old logic had this).
|
||||
|
||||
if (showHolidays && fullHolidayName == null) {
|
||||
// Show Lunar Date in shiftChar instead of empty?
|
||||
// Or shiftChar is empty, show small text?
|
||||
// Previous code:
|
||||
// holder.shiftChar.text = ""
|
||||
// holder.holidayNameSmall.visibility = View.VISIBLE
|
||||
// holder.holidayNameSmall.text = HolidayManager.getLunarDateString(item.date)
|
||||
|
||||
holder.shiftChar.text = HolidayManager.getLunarDateString(item.date)
|
||||
holder.shiftChar.textSize = 10f
|
||||
holder.shiftChar.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
|
||||
holder.shiftChar.background = null
|
||||
}
|
||||
|
||||
|
||||
// Memo Indicator
|
||||
holder.memoIndicator.visibility = View.GONE // Hide indicator, showing text instead
|
||||
if (item.hasMemo && !item.memoContent.isNullOrEmpty()) {
|
||||
holder.memoContent.visibility = View.VISIBLE
|
||||
holder.memoContent.text = item.memoContent
|
||||
} else {
|
||||
holder.memoContent.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Today Border or Highlight
|
||||
if (isToday) {
|
||||
holder.root.setBackgroundResource(R.drawable.bg_grid_cell_today_v4)
|
||||
} else {
|
||||
holder.root.setBackgroundResource(R.drawable.bg_grid_cell_v4)
|
||||
}
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
if (item.date != null && item.shift != null) {
|
||||
listener.onDayClick(item.date, item.shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = days.size
|
||||
}
|
||||
30
app/src/main/java/com/example/shiftalarm/Entities.kt
Normal file
30
app/src/main/java/com/example/shiftalarm/Entities.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Entity(tableName = "shift_overrides", primaryKeys = ["factory", "team", "date"])
|
||||
data class ShiftOverride(
|
||||
val factory: String,
|
||||
val team: String,
|
||||
val date: String, // YYYY-MM-DD
|
||||
val shift: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "daily_memos")
|
||||
data class DailyMemo(
|
||||
@PrimaryKey
|
||||
val date: String, // YYYY-MM-DD
|
||||
val content: String
|
||||
)
|
||||
|
||||
@Entity(tableName = "custom_alarms")
|
||||
data class CustomAlarm(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int = 0,
|
||||
val time: String, // HH:MM
|
||||
val shiftType: String, // 주간, 석간, 야간 ... 기타
|
||||
val isEnabled: Boolean = true,
|
||||
val soundUri: String? = null,
|
||||
val snoozeInterval: Int = 5,
|
||||
val snoozeRepeat: Int = 3
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.shiftalarm.databinding.FragmentSettingsAdditionalBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class FragmentSettingsAdditional : Fragment() {
|
||||
private var _binding: FragmentSettingsAdditionalBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val PREFS_NAME = "ShiftAlarmPrefs"
|
||||
private var isUserInteraction = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsAdditionalBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private val backupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
|
||||
uri?.let {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val db = AppDatabase.getDatabase(requireContext())
|
||||
BackupManager.backupData(requireContext(), it, db.shiftDao())
|
||||
Toast.makeText(requireContext(), "백업이 완료되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "백업 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val restoreLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val db = AppDatabase.getDatabase(requireContext())
|
||||
BackupManager.restoreData(requireContext(), it, db.shiftDao())
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("복구 완료")
|
||||
.setMessage("데이터 복구가 완료되었습니다. 변경사항을 적용하기 위해 앱을 재시작해야 합니다.")
|
||||
.setPositiveButton("앱 재시작") { _, _ ->
|
||||
val intent = requireContext().packageManager.getLaunchIntentForPackage(requireContext().packageName)
|
||||
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
requireActivity().finish()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
loadSettings()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "복구 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Theme Spinner
|
||||
val themeOptions = resources.getStringArray(R.array.theme_array)
|
||||
val themeAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, themeOptions)
|
||||
themeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.themeSpinner.adapter = themeAdapter
|
||||
|
||||
val themeMode = prefs.getInt("theme_mode", androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
val themeIndex = when(themeMode) {
|
||||
androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO -> 1
|
||||
androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES -> 2
|
||||
else -> 0
|
||||
}
|
||||
binding.themeSpinner.setSelection(themeIndex)
|
||||
|
||||
// Tide Switch
|
||||
binding.switchTide.isChecked = prefs.getBoolean("show_tide", false)
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
binding.themeSpinner.setOnTouchListener { _, _ ->
|
||||
isUserInteraction = true
|
||||
false
|
||||
}
|
||||
|
||||
binding.themeSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (!isUserInteraction) return
|
||||
|
||||
val themeMode = when(position) {
|
||||
1 -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
|
||||
2 -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
|
||||
// Save and Apply
|
||||
val currentMode = prefs.getInt("theme_mode", -1)
|
||||
if (currentMode != themeMode) {
|
||||
prefs.edit().putInt("theme_mode", themeMode).apply()
|
||||
|
||||
// Critical Guard: Only apply if it actually changes the global state
|
||||
if (androidx.appcompat.app.AppCompatDelegate.getDefaultNightMode() != themeMode) {
|
||||
androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode(themeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
}
|
||||
|
||||
// Tide Switch Listener (Fixed: properly saving now)
|
||||
binding.switchTide.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("show_tide", isChecked).apply()
|
||||
}
|
||||
|
||||
// Backup/Restore buttons
|
||||
binding.btnBackup.setOnClickListener {
|
||||
val dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmm"))
|
||||
backupLauncher.launch("shiftring_backup_$dateStr.json")
|
||||
}
|
||||
|
||||
binding.btnRestore.setOnClickListener {
|
||||
restoreLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
|
||||
binding.btnManual.setOnClickListener {
|
||||
startActivity(Intent(requireContext(), ManualActivity::class.java))
|
||||
}
|
||||
|
||||
binding.btnNotice.setOnClickListener {
|
||||
startActivity(Intent(requireContext(), NoticeActivity::class.java))
|
||||
}
|
||||
|
||||
binding.btnShareApp.setOnClickListener {
|
||||
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
try {
|
||||
val context = requireContext()
|
||||
val pm = context.packageManager
|
||||
val appInfo = pm.getApplicationInfo(context.packageName, 0)
|
||||
val apkFile = java.io.File(appInfo.sourceDir)
|
||||
|
||||
val cachePath = java.io.File(context.cacheDir, "apks")
|
||||
cachePath.mkdirs()
|
||||
val newFile = java.io.File(cachePath, "ShiftRing_Installer.apk")
|
||||
|
||||
apkFile.copyTo(newFile, overwrite = true)
|
||||
|
||||
val contentUri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
newFile
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.type = "application/vnd.android.package-archive"
|
||||
shareIntent.putExtra(Intent.EXTRA_STREAM, contentUri)
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
||||
startActivity(Intent.createChooser(shareIntent, "앱 설치 파일 공유하기"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
|
||||
Toast.makeText(requireContext(), "공유 실패: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnResetOverrides.setOnClickListener {
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle("데이터 초기화")
|
||||
.setMessage("달력에서 개별적으로 바꾼 모든 근무와 알람 설정이 삭제됩니다. 계속하시겠습니까?")
|
||||
.setPositiveButton("초기화") { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val db = AppDatabase.getDatabase(requireContext())
|
||||
val dao = db.shiftDao()
|
||||
dao.clearOverrides()
|
||||
|
||||
// Immediately re-sync all alarms
|
||||
syncAllAlarms(requireContext())
|
||||
|
||||
Toast.makeText(requireContext(), "모든 개별 설정이 삭제되고 알람이 재설정되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(requireContext(), "초기화 실패: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.TimePicker
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.materialswitch.MaterialSwitch
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.shiftalarm.databinding.FragmentSettingsAlarmBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDate
|
||||
|
||||
class FragmentSettingsAlarm : Fragment(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private var _binding: FragmentSettingsAlarmBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val PREFS_NAME = "ShiftAlarmPrefs"
|
||||
private lateinit var repository: ShiftRepository
|
||||
private var customAlarms: MutableList<CustomAlarm> = mutableListOf()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSettingsAlarmBinding.inflate(inflater, container, false)
|
||||
repository = ShiftRepository(requireContext())
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
setupListeners()
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshAlarmList()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||
_binding = null
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == "master_alarm_enabled") {
|
||||
sharedPreferences?.let {
|
||||
updateMasterToggleUI(ShiftAlarmDefaults.isMasterAlarmEnabled(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Master Toggle Button State
|
||||
updateMasterToggleUI(ShiftAlarmDefaults.isMasterAlarmEnabled(prefs))
|
||||
|
||||
// Migrate and Refresh
|
||||
lifecycleScope.launch {
|
||||
migrateFromPrefsIfNecessary()
|
||||
refreshAlarmList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun migrateFromPrefsIfNecessary() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val legacyJson = prefs.getString("custom_alarms", null)
|
||||
if (legacyJson != null) {
|
||||
try {
|
||||
val arr = JSONArray(legacyJson)
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
val alarm = CustomAlarm(
|
||||
time = obj.getString("time"),
|
||||
shiftType = obj.getString("shiftType"),
|
||||
isEnabled = obj.optBoolean("enabled", true),
|
||||
soundUri = obj.optString("soundUri", null),
|
||||
snoozeInterval = obj.optInt("snoozeInterval", 5),
|
||||
snoozeRepeat = obj.optInt("snoozeRepeat", 3)
|
||||
)
|
||||
repository.addCustomAlarm(alarm)
|
||||
}
|
||||
// Clear legacy data
|
||||
prefs.edit().remove("custom_alarms").apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAlarmList() {
|
||||
lifecycleScope.launch {
|
||||
customAlarms = repository.getAllCustomAlarms().toMutableList()
|
||||
refreshUI()
|
||||
}
|
||||
}
|
||||
|
||||
private val soundTitleCache = mutableMapOf<String?, String>()
|
||||
|
||||
private fun updateMasterToggleUI(isEnabled: Boolean) {
|
||||
if (isEnabled) {
|
||||
binding.tvMasterStatus.text = "전체 알람 켜짐"
|
||||
binding.tvMasterStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.primary))
|
||||
binding.tvMasterStatus.backgroundTintList = android.content.res.ColorStateList.valueOf(Color.parseColor("#E3F2FD"))
|
||||
} else {
|
||||
binding.tvMasterStatus.text = "전체 알람 꺼짐"
|
||||
binding.tvMasterStatus.setTextColor(ContextCompat.getColor(requireContext(), R.color.shift_red))
|
||||
binding.tvMasterStatus.backgroundTintList = android.content.res.ColorStateList.valueOf(Color.parseColor("#FFEBEE"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
binding.tvMasterStatus.setOnClickListener {
|
||||
val isEnabled = !ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)
|
||||
prefs.edit().putBoolean("master_alarm_enabled", isEnabled).apply()
|
||||
updateMasterToggleUI(isEnabled)
|
||||
|
||||
val message = if (isEnabled) "전체 알람이 켜졌습니다." else "전체 알람이 꺼졌습니다."
|
||||
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
|
||||
|
||||
// Resync immediately
|
||||
lifecycleScope.launch { syncAllAlarms(requireContext()) }
|
||||
}
|
||||
|
||||
binding.btnAddCustomAlarm.setOnClickListener {
|
||||
showEditDialog(
|
||||
title = "새 알람 추가",
|
||||
currentTime = "07:00",
|
||||
shiftType = "주간",
|
||||
existingAlarm = null,
|
||||
isNew = true
|
||||
)
|
||||
}
|
||||
|
||||
binding.btnTestAlarm.setOnClickListener {
|
||||
scheduleTestAlarm(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshUI() {
|
||||
val container = binding.alarmListContainer
|
||||
container.removeAllViews()
|
||||
|
||||
for (alarm in customAlarms) {
|
||||
val item = createAlarmRow(alarm.shiftType, alarm.time, alarm.isEnabled, isCustom = true, snoozeMin = alarm.snoozeInterval, snoozeRepeat = alarm.snoozeRepeat, soundUri = alarm.soundUri) { isToggle, isLongOrShort ->
|
||||
if (isToggle) {
|
||||
// AlarmSyncManager를 사용하여 토글 동기화
|
||||
lifecycleScope.launch {
|
||||
val enable = !alarm.isEnabled
|
||||
val result = AlarmSyncManager.toggleAlarm(requireContext(), alarm, enable)
|
||||
if (result.isSuccess) {
|
||||
Log.d("ShiftAlarm", "알람 토글 동기화 성공: ID=${alarm.id}, enabled=$enable")
|
||||
} else {
|
||||
Log.e("ShiftAlarm", "알람 토글 동기화 실패", result.exceptionOrNull())
|
||||
Toast.makeText(requireContext(), "알람 상태 변경 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
refreshAlarmList()
|
||||
}
|
||||
} else {
|
||||
showEditDialog("사용자 알람", alarm.time, alarm.shiftType, existingAlarm = alarm, isNew = false)
|
||||
}
|
||||
}
|
||||
container.addView(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAlarmRow(
|
||||
shiftName: String,
|
||||
time: String,
|
||||
isEnabled: Boolean,
|
||||
isCustom: Boolean,
|
||||
snoozeMin: Int,
|
||||
snoozeRepeat: Int,
|
||||
soundUri: String?,
|
||||
onAction: (isToggle: Boolean, isLongClick: Boolean) -> Unit
|
||||
): View {
|
||||
val view = layoutInflater.inflate(R.layout.item_alarm_unified, binding.alarmListContainer, false)
|
||||
view.isFocusable = true
|
||||
|
||||
val shiftIndicator = view.findViewById<TextView>(R.id.shiftIndicator)
|
||||
val tvTime = view.findViewById<TextView>(R.id.tvTime)
|
||||
val tvAmPm = view.findViewById<TextView>(R.id.tvAmPm)
|
||||
val tvSummary = view.findViewById<TextView>(R.id.tvSummary)
|
||||
val alarmSwitch = view.findViewById<MaterialSwitch>(R.id.alarmSwitch)
|
||||
val layoutAlarmSwitch = view.findViewById<View>(R.id.layoutAlarmSwitch)
|
||||
|
||||
val shortName = when(shiftName) {
|
||||
"주간" -> "주"
|
||||
"석간" -> "석"
|
||||
"야간" -> "야"
|
||||
"주간 맞교대" -> "주맞"
|
||||
"야간 맞교대" -> "야맞"
|
||||
"기타" -> "기타"
|
||||
else -> shiftName.take(1)
|
||||
}
|
||||
shiftIndicator.text = shortName
|
||||
|
||||
val colorRes = when(shiftName) {
|
||||
"주간" -> R.color.shift_lemon
|
||||
"석간" -> R.color.shift_seok
|
||||
"야간" -> R.color.shift_ya
|
||||
"주간 맞교대" -> R.color.shift_jumat
|
||||
"야간 맞교대" -> R.color.shift_yamat
|
||||
else -> R.color.shift_gray
|
||||
}
|
||||
|
||||
val context = requireContext()
|
||||
val color = ContextCompat.getColor(context, colorRes)
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.bg_shift_stroke_v4) as android.graphics.drawable.GradientDrawable
|
||||
drawable.mutate()
|
||||
drawable.setStroke(dpToPx(2.5f), color)
|
||||
shiftIndicator.background = drawable
|
||||
shiftIndicator.setTextColor(color)
|
||||
|
||||
try {
|
||||
val parts = time.split(":")
|
||||
val h24 = parts[0].toInt()
|
||||
val m = parts[1].toInt()
|
||||
val h12 = if (h24 % 12 == 0) 12 else h24 % 12
|
||||
tvTime.text = String.format("%02d:%02d", h12, m)
|
||||
tvAmPm.text = if (h24 < 12) "오전" else "오후"
|
||||
|
||||
if (!isEnabled) {
|
||||
tvTime.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
|
||||
tvAmPm.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
|
||||
tvSummary.setTextColor(ContextCompat.getColor(context, R.color.text_tertiary))
|
||||
shiftIndicator.alpha = 0.4f
|
||||
} else {
|
||||
tvTime.setTextColor(ContextCompat.getColor(context, R.color.text_primary))
|
||||
tvAmPm.setTextColor(ContextCompat.getColor(context, R.color.text_secondary))
|
||||
tvSummary.setTextColor(ContextCompat.getColor(context, R.color.primary))
|
||||
shiftIndicator.alpha = 1.0f
|
||||
}
|
||||
} catch (e: Exception) { tvTime.text = time }
|
||||
|
||||
val tvSoundNameView = view.findViewById<TextView>(R.id.tvSoundName)
|
||||
val soundName = getSoundTitle(context, soundUri)
|
||||
tvSummary.text = "${snoozeMin}분 간격, ${if(snoozeRepeat == 99) "계속" else snoozeRepeat.toString() + "회"}"
|
||||
tvSoundNameView.text = soundName
|
||||
|
||||
val rowContents = view.findViewById<View>(R.id.rowContents)
|
||||
rowContents.setOnClickListener { onAction(false, false) }
|
||||
rowContents.setOnLongClickListener { onAction(false, true); true }
|
||||
|
||||
alarmSwitch.isChecked = isEnabled
|
||||
layoutAlarmSwitch.setOnClickListener {
|
||||
// onAction will handle the data update and re-sync
|
||||
onAction(true, false)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private var currentDialogSoundUri: String? = null
|
||||
private var tvSoundNameReference: android.widget.TextView? = null
|
||||
|
||||
/**
|
||||
* 새 알람 추가 시 기본음으로 시스템 알람음 설정
|
||||
* 무음 문제 해결을 위해 반드시 시스템 기본 알람음을 반환
|
||||
*/
|
||||
private fun getDefaultAlarmUri(context: Context): String {
|
||||
// 1. 시스템 기본 알람음 (가장 우선)
|
||||
val defaultUri = android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI
|
||||
if (defaultUri != null) {
|
||||
Log.d("ShiftAlarm", "시스템 기본 알람음 URI: $defaultUri")
|
||||
return defaultUri.toString()
|
||||
}
|
||||
|
||||
// 2. RingtoneManager에서 알람 타입 기본값 가져오기
|
||||
val fallbackUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM)
|
||||
if (fallbackUri != null) {
|
||||
Log.d("ShiftAlarm", "Fallback 알람음 URI: $fallbackUri")
|
||||
return fallbackUri.toString()
|
||||
}
|
||||
|
||||
// 3. 마지막 fallback: 알림음이라도 사용
|
||||
val notificationUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION)
|
||||
if (notificationUri != null) {
|
||||
Log.w("ShiftAlarm", "알람음 없음, 알림음 사용: $notificationUri")
|
||||
return notificationUri.toString()
|
||||
}
|
||||
|
||||
// 4. 최후의 수단: 벨소리
|
||||
val ringtoneUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_RINGTONE)
|
||||
if (ringtoneUri != null) {
|
||||
Log.w("ShiftAlarm", "알림음 없음, 벨소리 사용: $ringtoneUri")
|
||||
return ringtoneUri.toString()
|
||||
}
|
||||
|
||||
// 이 경우는 거의 없지만, 안전장치
|
||||
Log.e("ShiftAlarm", "어떤 기본 소리도 찾을 수 없음")
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun showEditDialog(
|
||||
title: String, currentTime: String, shiftType: String, existingAlarm: CustomAlarm?, isNew: Boolean
|
||||
) {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_alarm_edit_spinner, null)
|
||||
val dialog = AlertDialog.Builder(requireContext(), android.R.style.Theme_DeviceDefault_Light_NoActionBar_Fullscreen).setView(dialogView).create()
|
||||
dialog.window?.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
|
||||
val tvTitle = dialogView.findViewById<TextView>(R.id.dialogTitle)
|
||||
val timePicker = dialogView.findViewById<TimePicker>(R.id.timePicker)
|
||||
val tvSoundName = dialogView.findViewById<TextView>(R.id.tvSoundName)
|
||||
tvSoundNameReference = tvSoundName
|
||||
|
||||
val btnSelectSound = dialogView.findViewById<View>(R.id.btnSelectSound)
|
||||
val btnDelete = dialogView.findViewById<Button>(R.id.btnDelete)
|
||||
val btnCancel = dialogView.findViewById<View>(R.id.btnCancel)
|
||||
val btnSave = dialogView.findViewById<View>(R.id.btnSave)
|
||||
|
||||
// Initialize Values
|
||||
var selectedSnooze = existingAlarm?.snoozeInterval ?: 5
|
||||
var selectedRepeat = existingAlarm?.snoozeRepeat ?: 3
|
||||
|
||||
// 새 알람 생성 시 기본적으로 시스템 알람음 설정 (무음 문제 해결)
|
||||
// 기존 알람 수정 시에도 soundUri가 비어있거나 null이면 기본값으로 설정
|
||||
val existingUri = existingAlarm?.soundUri
|
||||
val isExistingUriEmpty = existingUri.isNullOrEmpty() || existingUri == "null"
|
||||
|
||||
currentDialogSoundUri = if (isNew || isExistingUriEmpty) {
|
||||
// 새 알람 또는 기존 알람의 소리가 설정되지 않은 경우: 반드시 기본 알람음으로 설정
|
||||
val defaultUri = getDefaultAlarmUri(requireContext())
|
||||
Log.d("ShiftAlarm", "기본 알람음 설정: $defaultUri (isNew=$isNew, isExistingUriEmpty=$isExistingUriEmpty)")
|
||||
defaultUri
|
||||
} else {
|
||||
// 기존 알람 수정: 기존 값 유지
|
||||
existingUri
|
||||
}
|
||||
|
||||
// soundUri가 비어있는 경우 최종 안전장치
|
||||
if (currentDialogSoundUri.isNullOrEmpty()) {
|
||||
currentDialogSoundUri = getDefaultAlarmUri(requireContext())
|
||||
Log.w("ShiftAlarm", "soundUri가 비어있어 기본값으로 재설정: $currentDialogSoundUri")
|
||||
}
|
||||
|
||||
Log.d("ShiftAlarm", "알람 ${if (isNew) "생성" else "수정"} - 최종 soundUri: $currentDialogSoundUri")
|
||||
|
||||
fun updateSoundName(uriStr: String?) {
|
||||
if (uriStr.isNullOrEmpty() || uriStr == "null") {
|
||||
tvSoundName.text = "기본 알람음"
|
||||
} else {
|
||||
try {
|
||||
val uri = android.net.Uri.parse(uriStr)
|
||||
val ringtone = android.media.RingtoneManager.getRingtone(requireContext(), uri)
|
||||
val title = ringtone?.getTitle(requireContext()) ?: "알람음"
|
||||
tvSoundName.text = title
|
||||
} catch (e: Exception) {
|
||||
tvSoundName.text = "알람음"
|
||||
}
|
||||
}
|
||||
}
|
||||
updateSoundName(currentDialogSoundUri)
|
||||
|
||||
// Snooze Interval Buttons
|
||||
val snoozeButtons = listOf(
|
||||
dialogView.findViewById<TextView>(R.id.snooze5),
|
||||
dialogView.findViewById<TextView>(R.id.snooze10),
|
||||
dialogView.findViewById<TextView>(R.id.snooze15),
|
||||
dialogView.findViewById<TextView>(R.id.snooze30)
|
||||
)
|
||||
val snoozeValues = listOf(5, 10, 15, 30)
|
||||
fun updateSnoozeUI() {
|
||||
snoozeButtons.forEachIndexed { i, btn ->
|
||||
val isSelected = snoozeValues[i] == selectedSnooze
|
||||
btn.setBackgroundResource(if (isSelected) R.drawable.bg_pill_rect_selected else R.drawable.bg_pill_rect_unselected)
|
||||
btn.setTextColor(if (isSelected) ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.text_secondary))
|
||||
}
|
||||
}
|
||||
updateSnoozeUI()
|
||||
snoozeButtons.forEachIndexed { i, btn -> btn.setOnClickListener { selectedSnooze = snoozeValues[i]; updateSnoozeUI() } }
|
||||
|
||||
// Repeat Count Buttons
|
||||
val repeatButtons = listOf(
|
||||
dialogView.findViewById<TextView>(R.id.repeat3),
|
||||
dialogView.findViewById<TextView>(R.id.repeat5),
|
||||
dialogView.findViewById<TextView>(R.id.repeatForever)
|
||||
)
|
||||
val repeatValues = listOf(3, 5, 99)
|
||||
fun updateRepeatUI() {
|
||||
repeatButtons.forEachIndexed { i, btn ->
|
||||
val isSelected = repeatValues[i] == selectedRepeat
|
||||
btn.setBackgroundResource(if (isSelected) R.drawable.bg_pill_rect_selected else R.drawable.bg_pill_rect_unselected)
|
||||
btn.setTextColor(if (isSelected) ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.text_secondary))
|
||||
}
|
||||
}
|
||||
updateRepeatUI()
|
||||
repeatButtons.forEachIndexed { i, btn -> btn.setOnClickListener { selectedRepeat = repeatValues[i]; updateRepeatUI() } }
|
||||
|
||||
val cardShift = dialogView.findViewById<View>(R.id.cardShiftSelector)
|
||||
var currentShift = shiftType
|
||||
cardShift.visibility = View.VISIBLE
|
||||
val shiftBtns = listOf(
|
||||
dialogView.findViewById<TextView>(R.id.btnShiftJu),
|
||||
dialogView.findViewById<TextView>(R.id.btnShiftSeok),
|
||||
dialogView.findViewById<TextView>(R.id.btnShiftYa),
|
||||
dialogView.findViewById<TextView>(R.id.btnShiftYaMat),
|
||||
dialogView.findViewById<TextView>(R.id.btnShiftEtc)
|
||||
)
|
||||
val shiftTypes = listOf("주간", "석간", "야간", "야간 맞교대", "기타")
|
||||
fun updateShiftUI() {
|
||||
shiftBtns.forEachIndexed { i, btn ->
|
||||
val isSelected = shiftTypes[i] == currentShift
|
||||
val colorRes = when(shiftTypes[i]) {
|
||||
"주간" -> R.color.shift_lemon; "석간" -> R.color.shift_seok; "야간" -> R.color.shift_ya
|
||||
"야간 맞교대" -> R.color.shift_yamat; else -> R.color.shift_gray
|
||||
}
|
||||
val color = ContextCompat.getColor(requireContext(), colorRes)
|
||||
if (isSelected) {
|
||||
val d = ContextCompat.getDrawable(requireContext(), R.drawable.bg_shift_circle_v4) as android.graphics.drawable.GradientDrawable
|
||||
d.mutate(); d.setColor(color); btn.background = d
|
||||
btn.setTextColor(if (shiftTypes[i] == "야간") ContextCompat.getColor(requireContext(), R.color.white) else ContextCompat.getColor(requireContext(), R.color.black))
|
||||
} else {
|
||||
val d = ContextCompat.getDrawable(requireContext(), R.drawable.bg_shift_stroke_v4) as android.graphics.drawable.GradientDrawable
|
||||
d.mutate(); d.setStroke(dpToPx(1.5f), color); btn.background = d; btn.setTextColor(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
updateShiftUI()
|
||||
shiftBtns.forEachIndexed { i, btn -> btn.setOnClickListener { currentShift = shiftTypes[i]; updateShiftUI() } }
|
||||
|
||||
tvTitle.text = if (isNew) "새 알람 추가" else "$shiftType 알람 수정"
|
||||
timePicker.setIs24HourView(false)
|
||||
val parts = currentTime.split(":")
|
||||
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||
timePicker.hour = parts[0].toInt(); timePicker.minute = parts[1].toInt()
|
||||
} else {
|
||||
timePicker.currentHour = parts[0].toInt(); timePicker.currentMinute = parts[1].toInt()
|
||||
}
|
||||
|
||||
btnSelectSound.setOnClickListener {
|
||||
val intent = Intent(android.media.RingtoneManager.ACTION_RINGTONE_PICKER).apply {
|
||||
putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_TYPE, android.media.RingtoneManager.TYPE_ALARM)
|
||||
putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, if (currentDialogSoundUri != null) android.net.Uri.parse(currentDialogSoundUri) else null as android.net.Uri?)
|
||||
}
|
||||
startActivityForResult(intent, 100)
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
btnDelete.visibility = View.VISIBLE
|
||||
btnDelete.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
existingAlarm?.let {
|
||||
// AlarmSyncManager를 사용하여 동기화된 삭제 수행
|
||||
// DB 삭제 전 AlarmManager 취소가 보장됨
|
||||
val result = AlarmSyncManager.deleteAlarm(requireContext(), it)
|
||||
if (result.isSuccess) {
|
||||
Log.d("ShiftAlarm", "알람 삭제 동기화 성공: ID=${it.id}")
|
||||
} else {
|
||||
Log.e("ShiftAlarm", "알람 삭제 동기화 실패", result.exceptionOrNull())
|
||||
Toast.makeText(requireContext(), "알람 삭제 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
refreshAlarmList()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
btnCancel.setOnClickListener { dialog.dismiss() }
|
||||
btnSave.setOnClickListener {
|
||||
val h = if (android.os.Build.VERSION.SDK_INT >= 23) timePicker.hour else timePicker.currentHour
|
||||
val m = if (android.os.Build.VERSION.SDK_INT >= 23) timePicker.minute else timePicker.currentMinute
|
||||
val time = String.format("%02d:%02d", h, m)
|
||||
|
||||
lifecycleScope.launch {
|
||||
if (isNew) {
|
||||
val newAlarm = CustomAlarm(
|
||||
time = time,
|
||||
shiftType = currentShift,
|
||||
isEnabled = true,
|
||||
soundUri = currentDialogSoundUri,
|
||||
snoozeInterval = selectedSnooze,
|
||||
snoozeRepeat = selectedRepeat
|
||||
)
|
||||
// AlarmSyncManager를 사용하여 동기화된 추가 수행
|
||||
val result = AlarmSyncManager.addAlarm(requireContext(), newAlarm)
|
||||
if (result.isSuccess) {
|
||||
Log.d("ShiftAlarm", "새 알람 추가 동기화 성공")
|
||||
Toast.makeText(requireContext(), "알람이 추가되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Log.e("ShiftAlarm", "새 알람 추가 동기화 실패", result.exceptionOrNull())
|
||||
Toast.makeText(requireContext(), "알람 추가 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
val updated = existingAlarm!!.copy(
|
||||
time = time,
|
||||
shiftType = currentShift,
|
||||
soundUri = currentDialogSoundUri,
|
||||
snoozeInterval = selectedSnooze,
|
||||
snoozeRepeat = selectedRepeat
|
||||
)
|
||||
// AlarmSyncManager를 사용하여 동기화된 수정 수행
|
||||
val result = AlarmSyncManager.updateAlarm(requireContext(), updated)
|
||||
if (result.isSuccess) {
|
||||
Log.d("ShiftAlarm", "알람 수정 동기화 성공: ID=${updated.id}")
|
||||
Toast.makeText(requireContext(), "알람이 수정되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Log.e("ShiftAlarm", "알람 수정 동기화 실패", result.exceptionOrNull())
|
||||
Toast.makeText(requireContext(), "알람 수정 중 오류가 발생했습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
refreshAlarmList()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
dialog.setOnDismissListener { tvSoundNameReference = null }
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun dpToPx(dp: Float): Int {
|
||||
return (dp * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (resultCode == androidx.appcompat.app.AppCompatActivity.RESULT_OK) {
|
||||
val uri = data?.getParcelableExtra<android.net.Uri>(android.media.RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
|
||||
if (uri != null) {
|
||||
currentDialogSoundUri = uri.toString()
|
||||
try {
|
||||
val ringtone = android.media.RingtoneManager.getRingtone(requireContext(), uri)
|
||||
tvSoundNameReference?.text = ringtone.getTitle(requireContext())
|
||||
} catch(e: Exception) {
|
||||
tvSoundNameReference?.text = "사용자 지정음"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun getSoundTitle(context: Context, uriStr: String?): String {
|
||||
if (soundTitleCache.containsKey(uriStr)) return soundTitleCache[uriStr]!!
|
||||
|
||||
// uriStr이 null이거나 비어있거나 "null" 문자열인 경우 기본음으로 처리
|
||||
val title = if (uriStr.isNullOrEmpty() || uriStr == "null") {
|
||||
"기본 알람음"
|
||||
} else {
|
||||
try {
|
||||
val uri = android.net.Uri.parse(uriStr)
|
||||
val ringtone = android.media.RingtoneManager.getRingtone(context, uri)
|
||||
ringtone?.getTitle(context) ?: "알람음"
|
||||
} catch (e: Exception) {
|
||||
"알람음"
|
||||
}
|
||||
}
|
||||
soundTitleCache[uriStr] = title
|
||||
return title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.example.shiftalarm.databinding.FragmentSettingsBasicBinding
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import android.app.ProgressDialog
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class FragmentSettingsBasic : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsBasicBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val PREFS_NAME = "ShiftAlarmPrefs"
|
||||
private var isUserInteraction = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSettingsBasicBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// Factory Spinner
|
||||
setupFactorySpinner(prefs)
|
||||
|
||||
// Team Spinner
|
||||
setupTeamSpinner(prefs)
|
||||
|
||||
// Version Info
|
||||
try {
|
||||
val pInfo = requireContext().packageManager.getPackageInfo(requireContext().packageName, 0)
|
||||
binding.versionInfo.text = "Ver. ${pInfo.versionName} | 제작자: 산적이얌"
|
||||
} catch (e: Exception) {
|
||||
binding.versionInfo.text = "Ver. Unknown | 제작자: 산적이얌"
|
||||
}
|
||||
|
||||
// Show/Hide Exact Alarm based on version
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
binding.btnExactAlarm.visibility = View.VISIBLE
|
||||
binding.dividerExact.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.btnExactAlarm.visibility = View.GONE
|
||||
binding.dividerExact.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupFactorySpinner(prefs: android.content.SharedPreferences) {
|
||||
val savedFactory = prefs.getString("selected_factory", "Jeonju")
|
||||
val factoryIndex = if (savedFactory == "Nonsan") 1 else 0
|
||||
binding.factorySpinner.setSelection(factoryIndex)
|
||||
|
||||
binding.factorySpinner.setOnTouchListener { _, _ ->
|
||||
isUserInteraction = true
|
||||
false
|
||||
}
|
||||
|
||||
binding.factorySpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (!isUserInteraction) return
|
||||
|
||||
val isNonsan = position == 1
|
||||
val factory = if (isNonsan) "Nonsan" else "Jeonju"
|
||||
|
||||
val currentFactory = prefs.getString("selected_factory", "Jeonju")
|
||||
if (factory == currentFactory) {
|
||||
// Just update team spinner without resetting times if same factory
|
||||
updateTeamSpinner(isNonsan)
|
||||
return
|
||||
}
|
||||
|
||||
// Save immediately
|
||||
val editor = prefs.edit()
|
||||
editor.putString("selected_factory", factory)
|
||||
editor.apply()
|
||||
|
||||
// CRUCIAL: Re-sync all alarms for the new factory
|
||||
lifecycleScope.launch {
|
||||
syncAllAlarms(requireContext())
|
||||
Toast.makeText(requireContext(), "공장 설정이 변경되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// Update Team Spinner logic
|
||||
updateTeamSpinner(isNonsan)
|
||||
}
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTeamSpinner(isNonsan: Boolean) {
|
||||
val currentSelection = binding.teamSpinner.selectedItemPosition
|
||||
val teamOptions = if (isNonsan) {
|
||||
arrayOf("A반", "B반", "C반")
|
||||
} else {
|
||||
arrayOf("A반", "B반", "C반", "D반")
|
||||
}
|
||||
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, teamOptions)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.teamSpinner.adapter = adapter
|
||||
|
||||
if (currentSelection < teamOptions.size) {
|
||||
binding.teamSpinner.setSelection(currentSelection)
|
||||
} else {
|
||||
binding.teamSpinner.setSelection(0)
|
||||
if (isUserInteraction) {
|
||||
Toast.makeText(requireContext(), "논산 회사는 D반이 없습니다. A반으로 설정됩니다.", Toast.LENGTH_SHORT).show()
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString("selected_team", "A").apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTeamSpinner(prefs: android.content.SharedPreferences) {
|
||||
val savedFactory = prefs.getString("selected_factory", "Jeonju")
|
||||
val isNonsan = savedFactory == "Nonsan"
|
||||
updateTeamSpinner(isNonsan)
|
||||
|
||||
val savedTeam = prefs.getString("selected_team", "A")
|
||||
val teamIndex = when (savedTeam) {
|
||||
"A" -> 0
|
||||
"B" -> 1
|
||||
"C" -> 2
|
||||
"D" -> if (isNonsan) 0 else 3
|
||||
else -> 0
|
||||
}
|
||||
binding.teamSpinner.setSelection(teamIndex)
|
||||
|
||||
binding.teamSpinner.setOnTouchListener { _, _ ->
|
||||
isUserInteraction = true
|
||||
false
|
||||
}
|
||||
|
||||
binding.teamSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (!isUserInteraction) return
|
||||
|
||||
val selectedTeam = when(position) {
|
||||
0 -> "A"
|
||||
1 -> "B"
|
||||
2 -> "C"
|
||||
3 -> "D"
|
||||
else -> "A"
|
||||
}
|
||||
prefs.edit().putString("selected_team", selectedTeam).apply()
|
||||
|
||||
// CRUCIAL: Re-sync all alarms for the new team
|
||||
lifecycleScope.launch {
|
||||
syncAllAlarms(requireContext())
|
||||
Toast.makeText(requireContext(), "${selectedTeam}반으로 알람이 재설정되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updatePermissionStatuses()
|
||||
}
|
||||
|
||||
private fun updatePermissionStatuses() {
|
||||
val context = requireContext()
|
||||
|
||||
// 1. 배터리 (Battery)
|
||||
val isBatteryIgnored = AlarmPermissionUtil.getBatteryOptimizationStatus(context)
|
||||
binding.tvBatteryStatus.text = if (isBatteryIgnored) "[설정 완료: 절전 예외]" else "클릭하여 '제한 없음'으로 설정하세요"
|
||||
binding.tvBatteryStatus.setTextColor(ContextCompat.getColor(context, if (isBatteryIgnored) R.color.primary else R.color.shift_red))
|
||||
|
||||
// 2. 정확한 알람 (Exact Alarm) - Android 12+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val isExactGranted = AlarmPermissionUtil.getExactAlarmStatus(context)
|
||||
binding.tvExactStatus.text = if (isExactGranted) "[설정 완료: 정밀 알람]" else "필수: 클릭하여 권한을 허용하세요"
|
||||
binding.tvExactStatus.setTextColor(ContextCompat.getColor(context, if (isExactGranted) R.color.primary else R.color.shift_red))
|
||||
} else {
|
||||
binding.btnExactAlarm.visibility = View.GONE
|
||||
binding.dividerExact.visibility = View.GONE
|
||||
}
|
||||
|
||||
// 3. 다른 앱 위에 표시 (Overlay)
|
||||
val isOverlayGranted = AlarmPermissionUtil.getOverlayStatus(context)
|
||||
binding.tvOverlayStatus.text = if (isOverlayGranted) "[설정 완료: 화면 우위]" else "필수: 알람창 노출을 위해 허용하세요"
|
||||
binding.tvOverlayStatus.setTextColor(ContextCompat.getColor(context, if (isOverlayGranted) R.color.primary else R.color.shift_red))
|
||||
|
||||
// 4. 전체화면 알림 (Full Screen Intent) - Android 14+
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val isFullScreenGranted = AlarmPermissionUtil.getFullScreenIntentStatus(context)
|
||||
binding.tvFullScreenStatus.text = if (isFullScreenGranted) "[설정 완료: 전체화면]" else "필수: 안드로이드 14 이상 필수 설정"
|
||||
binding.tvFullScreenStatus.setTextColor(ContextCompat.getColor(context, if (isFullScreenGranted) R.color.primary else R.color.shift_red))
|
||||
binding.btnFullScreenIntent.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.btnFullScreenIntent.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
val prefs = requireContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
binding.btnBatteryOptimize.setOnClickListener {
|
||||
AlarmPermissionUtil.requestBatteryOptimization(requireContext())
|
||||
}
|
||||
|
||||
binding.btnExactAlarm.setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
||||
data = Uri.parse("package:${requireContext().packageName}")
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnOverlayPermission.setOnClickListener {
|
||||
AlarmPermissionUtil.requestOverlayPermission(requireContext())
|
||||
}
|
||||
|
||||
binding.btnFullScreenIntent.setOnClickListener {
|
||||
AlarmPermissionUtil.requestFullScreenIntentPermission(requireContext())
|
||||
}
|
||||
|
||||
binding.btnPermissionSettings.setOnClickListener {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${requireContext().packageName}")
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
binding.btnCheckUpdate.setOnClickListener {
|
||||
checkUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkUpdate() {
|
||||
AppUpdateManager.checkUpdate(requireActivity(), silent = false)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.example.shiftalarm.databinding.FragmentSettingsLabBinding
|
||||
|
||||
class FragmentSettingsLab : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsLabBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSettingsLabBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
194
app/src/main/java/com/example/shiftalarm/HolidayManager.kt
Normal file
194
app/src/main/java/com/example/shiftalarm/HolidayManager.kt
Normal file
@@ -0,0 +1,194 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import android.icu.util.ChineseCalendar
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* 대한민국 공휴일 관리자 + 물때(Tide) 계산기.
|
||||
* - 양력 고정 공휴일
|
||||
* - 음력 공휴일 (ICU ChineseCalendar)
|
||||
* - 대체공휴일
|
||||
* - 물때 (7물때식: 서해안/남해서부 기준)
|
||||
*/
|
||||
object HolidayManager {
|
||||
|
||||
private val cache = mutableMapOf<Int, Map<LocalDate, String>>()
|
||||
|
||||
fun getHolidayName(date: LocalDate): String? {
|
||||
return getHolidaysForYear(date.year)[date]
|
||||
}
|
||||
|
||||
fun isHoliday(date: LocalDate): Boolean {
|
||||
return getHolidaysForYear(date.year).containsKey(date)
|
||||
}
|
||||
|
||||
fun getHolidaysForYear(year: Int): Map<LocalDate, String> {
|
||||
return cache.getOrPut(year) { generateHolidays(year) }
|
||||
}
|
||||
|
||||
private fun generateHolidays(year: Int): Map<LocalDate, String> {
|
||||
val holidays = mutableMapOf<LocalDate, String>()
|
||||
addFixedHolidays(year, holidays)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
addLunarHolidays(year, holidays)
|
||||
}
|
||||
addSubstituteHolidays(holidays)
|
||||
return holidays
|
||||
}
|
||||
|
||||
// ── 양력 고정 공휴일 ──
|
||||
private fun addFixedHolidays(year: Int, h: MutableMap<LocalDate, String>) {
|
||||
h[LocalDate.of(year, 1, 1)] = "신정"
|
||||
h[LocalDate.of(year, 3, 1)] = "삼일절"
|
||||
h[LocalDate.of(year, 5, 5)] = "어린이날"
|
||||
h[LocalDate.of(year, 6, 6)] = "현충일"
|
||||
h[LocalDate.of(year, 8, 15)] = "광복절"
|
||||
h[LocalDate.of(year, 10, 3)] = "개천절"
|
||||
h[LocalDate.of(year, 10, 9)] = "한글날"
|
||||
h[LocalDate.of(year, 12, 25)] = "성탄절"
|
||||
}
|
||||
|
||||
// ── 음력 공휴일 ──
|
||||
private fun addLunarHolidays(year: Int, h: MutableMap<LocalDate, String>) {
|
||||
lunarToSolar(year, 1, 1)?.let { seolnal ->
|
||||
h[seolnal.minusDays(1)] = "설날 연휴"
|
||||
h[seolnal] = "설날"
|
||||
h[seolnal.plusDays(1)] = "설날 연휴"
|
||||
}
|
||||
lunarToSolar(year, 4, 8)?.let { buddha ->
|
||||
h[buddha] = "부처님오신날"
|
||||
}
|
||||
lunarToSolar(year, 8, 15)?.let { chuseok ->
|
||||
h[chuseok.minusDays(1)] = "추석 연휴"
|
||||
h[chuseok] = "추석"
|
||||
h[chuseok.plusDays(1)] = "추석 연휴"
|
||||
}
|
||||
}
|
||||
|
||||
// ── 대체공휴일 (2025~ 기준) ──
|
||||
private val SUBSTITUTE_ELIGIBLE = setOf(
|
||||
"삼일절", "어린이날", "부처님오신날", "현충일", "광복절",
|
||||
"개천절", "한글날", "성탄절",
|
||||
"설날", "설날 연휴", "추석", "추석 연휴"
|
||||
)
|
||||
|
||||
private fun addSubstituteHolidays(holidays: MutableMap<LocalDate, String>) {
|
||||
val occupied = holidays.keys.toMutableSet()
|
||||
val substitutes = mutableListOf<Pair<LocalDate, String>>()
|
||||
|
||||
for ((date, name) in holidays.entries.sortedBy { it.key }) {
|
||||
if (name !in SUBSTITUTE_ELIGIBLE) continue
|
||||
val dow = date.dayOfWeek
|
||||
if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
|
||||
var sub = date.plusDays(1)
|
||||
while (sub.dayOfWeek == DayOfWeek.SATURDAY ||
|
||||
sub.dayOfWeek == DayOfWeek.SUNDAY ||
|
||||
sub in occupied
|
||||
) {
|
||||
sub = sub.plusDays(1)
|
||||
}
|
||||
substitutes.add(sub to "대체공휴일($name)")
|
||||
occupied.add(sub)
|
||||
}
|
||||
}
|
||||
for ((d, n) in substitutes) holidays[d] = n
|
||||
}
|
||||
|
||||
// ── 음력 → 양력 변환 (ICU ChineseCalendar) ──
|
||||
private fun lunarToSolar(gregorianYear: Int, lunarMonth: Int, lunarDay: Int): LocalDate? {
|
||||
try {
|
||||
val cc = ChineseCalendar()
|
||||
val cal = java.util.GregorianCalendar(gregorianYear, 6, 1)
|
||||
cc.timeInMillis = cal.timeInMillis
|
||||
val chineseYear = cc.get(ChineseCalendar.EXTENDED_YEAR)
|
||||
|
||||
cc.set(ChineseCalendar.EXTENDED_YEAR, chineseYear)
|
||||
cc.set(ChineseCalendar.MONTH, lunarMonth - 1)
|
||||
cc.set(ChineseCalendar.DAY_OF_MONTH, lunarDay)
|
||||
cc.set(ChineseCalendar.IS_LEAP_MONTH, 0)
|
||||
|
||||
val result = java.time.Instant.ofEpochMilli(cc.timeInMillis)
|
||||
.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()
|
||||
|
||||
if (result.year == gregorianYear) return result
|
||||
|
||||
cc.set(ChineseCalendar.EXTENDED_YEAR, chineseYear + 1)
|
||||
cc.set(ChineseCalendar.MONTH, lunarMonth - 1)
|
||||
cc.set(ChineseCalendar.DAY_OF_MONTH, lunarDay)
|
||||
cc.set(ChineseCalendar.IS_LEAP_MONTH, 0)
|
||||
|
||||
val result2 = java.time.Instant.ofEpochMilli(cc.timeInMillis)
|
||||
.atZone(ZoneId.of("Asia/Seoul")).toLocalDate()
|
||||
|
||||
return if (result2.year == gregorianYear) result2 else result
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 음력 날짜 문자열 (달력 표시용) ──
|
||||
fun getLunarDateString(date: LocalDate): String {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
val cc = ChineseCalendar()
|
||||
cc.timeInMillis = date.atStartOfDay(ZoneId.of("Asia/Seoul"))
|
||||
.toInstant().toEpochMilli()
|
||||
val m = cc.get(ChineseCalendar.MONTH) + 1
|
||||
val d = cc.get(ChineseCalendar.DAY_OF_MONTH)
|
||||
return "$m.$d"
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── 물때 계산 (7물때 및 8물때 고도화) ──
|
||||
fun getTide(date: LocalDate, location: String = "군산"): String {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
try {
|
||||
val cc = ChineseCalendar()
|
||||
cc.timeInMillis = date.atStartOfDay(ZoneId.of("Asia/Seoul"))
|
||||
.toInstant().toEpochMilli()
|
||||
val d = cc.get(ChineseCalendar.DAY_OF_MONTH)
|
||||
|
||||
val is8Tide = location == "여수" // 여수 등 남해 일부는 8물때식 선호 경향
|
||||
|
||||
return if (is8Tide) {
|
||||
// 8물때식 (남해/동해 기준)
|
||||
when(d) {
|
||||
in 1..7 -> "${d + 7}물"
|
||||
8 -> "조금"
|
||||
9 -> "무시"
|
||||
in 10..22 -> "${d - 9}물"
|
||||
23 -> "사리"
|
||||
24 -> "조금"
|
||||
25 -> "무시"
|
||||
in 26..30 -> "${d - 25}물"
|
||||
else -> ""
|
||||
}
|
||||
} else {
|
||||
// 7물때식 (서해/남해서부 기준: 군산, 변산, 태안 등)
|
||||
when(d) {
|
||||
in 1..6 -> "${d + 6}물"
|
||||
7 -> "13물"
|
||||
8 -> "사리"
|
||||
9 -> "조금"
|
||||
10 -> "무시"
|
||||
in 11..21 -> "${d - 10}물"
|
||||
22 -> "12물"
|
||||
23 -> "13물"
|
||||
24 -> "사리"
|
||||
25 -> "조금"
|
||||
26 -> "무시"
|
||||
in 27..30 -> "${d - 26}물"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
691
app/src/main/java/com/example/shiftalarm/MainActivity.kt
Normal file
691
app/src/main/java/com/example/shiftalarm/MainActivity.kt
Normal file
@@ -0,0 +1,691 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.example.shiftalarm.databinding.ActivityMainBinding
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.TimeUnit
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import kotlin.math.abs
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private val PREFS_NAME = "ShiftAlarmPrefs"
|
||||
private val KEY_TEAM = "selected_team"
|
||||
private var currentViewMonth: YearMonth = YearMonth.now(ShiftCalculator.SEOUL_ZONE)
|
||||
private var currentViewTeam: String = "A"
|
||||
private lateinit var gestureDetector: GestureDetector
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
// Smooth transition for theme change
|
||||
finish()
|
||||
startActivity(Intent(this, javaClass))
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val density = resources.displayMetrics.density
|
||||
val p = (8 * density).toInt()
|
||||
v.setPadding(systemBars.left + p, systemBars.top + p, systemBars.right + p, systemBars.bottom + p)
|
||||
insets
|
||||
}
|
||||
|
||||
setupCalendar()
|
||||
setupWorker()
|
||||
setupSwipeGesture()
|
||||
AppUpdateManager.checkUpdate(this, silent = true)
|
||||
checkRoot()
|
||||
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A"
|
||||
|
||||
// Default to Shift Calendar mode (checkbox unchecked)
|
||||
binding.cbShowHolidays.isChecked = false
|
||||
|
||||
binding.btnSettings.setOnClickListener {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
|
||||
binding.prevMonth.setOnClickListener {
|
||||
currentViewMonth = currentViewMonth.minusMonths(1)
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
binding.monthTitle.setOnClickListener {
|
||||
showMonthYearPicker()
|
||||
}
|
||||
|
||||
binding.nextMonth.setOnClickListener {
|
||||
currentViewMonth = currentViewMonth.plusMonths(1)
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
binding.btnToday.setOnClickListener {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A"
|
||||
currentViewMonth = YearMonth.now(ShiftCalculator.SEOUL_ZONE)
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
binding.cbShowHolidays.setOnCheckedChangeListener { _, _ ->
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
binding.alarmInfoBar.setOnClickListener {
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
intent.putExtra("TARGET_TAB", 1) // 1 is Alarm tab
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
// Tide Location Cycle Logic
|
||||
val tideLocations = listOf("군산", "변산", "여수", "태안")
|
||||
binding.btnTideLocation.setOnClickListener {
|
||||
val currentLoc = prefs.getString("selected_tide_location", "군산") ?: "군산"
|
||||
val nextIndex = (tideLocations.indexOf(currentLoc) + 1) % tideLocations.size
|
||||
val nextLoc = tideLocations[nextIndex]
|
||||
|
||||
prefs.edit().putString("selected_tide_location", nextLoc).apply()
|
||||
binding.btnTideLocation.text = nextLoc
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
// setupWorker(), checkRoot() 등은 이미 호출됨
|
||||
}
|
||||
|
||||
private fun updateTideButtonVisibility() {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val showTide = prefs.getBoolean("show_tide", false)
|
||||
val currentLoc = prefs.getString("selected_tide_location", "군산") ?: "군산"
|
||||
|
||||
if (showTide) {
|
||||
binding.btnTideLocation.visibility = android.view.View.VISIBLE
|
||||
binding.btnTideLocation.text = currentLoc
|
||||
} else {
|
||||
binding.btnTideLocation.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSwipeGesture() {
|
||||
gestureDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() {
|
||||
private val SWIPE_THRESHOLD = 100
|
||||
private val SWIPE_VELOCITY_THRESHOLD = 100
|
||||
|
||||
override fun onFling(
|
||||
e1: MotionEvent?,
|
||||
e2: MotionEvent,
|
||||
velocityX: Float,
|
||||
velocityY: Float
|
||||
): Boolean {
|
||||
if (e1 == null) return false
|
||||
|
||||
val diffX = e2.x - e1.x
|
||||
val diffY = e2.y - e1.y
|
||||
|
||||
if (abs(diffX) > abs(diffY)) {
|
||||
if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
|
||||
if (diffX > 0) {
|
||||
// Swipe Right -> Previous Month
|
||||
currentViewMonth = currentViewMonth.minusMonths(1)
|
||||
updateCalendar()
|
||||
} else {
|
||||
// Swipe Left -> Next Month
|
||||
currentViewMonth = currentViewMonth.plusMonths(1)
|
||||
updateCalendar()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
binding.calendarGrid.addOnItemTouchListener(object : androidx.recyclerview.widget.RecyclerView.OnItemTouchListener {
|
||||
override fun onInterceptTouchEvent(rv: androidx.recyclerview.widget.RecyclerView, e: MotionEvent): Boolean {
|
||||
gestureDetector.onTouchEvent(e)
|
||||
return false
|
||||
}
|
||||
override fun onTouchEvent(rv: androidx.recyclerview.widget.RecyclerView, e: MotionEvent) {}
|
||||
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
|
||||
})
|
||||
|
||||
binding.calendarContainer.setOnTouchListener { _, event ->
|
||||
gestureDetector.onTouchEvent(event)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
currentViewTeam = prefs.getString(KEY_TEAM, "A") ?: "A"
|
||||
|
||||
updateTideButtonVisibility()
|
||||
updateCalendar()
|
||||
|
||||
// 일원화된 통합 권한 체크 실행 (신뢰도 100% 보장)
|
||||
AlarmPermissionUtil.checkAndRequestAllPermissions(this)
|
||||
|
||||
// 설정 변경 시 즉시 반영을 위한 강제 동기화 (30일 스케줄링)
|
||||
lifecycleScope.launch {
|
||||
syncAllAlarms(this@MainActivity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMonthYearPicker() {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_month_year_picker, null)
|
||||
val yearPicker = dialogView.findViewById<android.widget.NumberPicker>(R.id.yearPicker)
|
||||
val monthPicker = dialogView.findViewById<android.widget.NumberPicker>(R.id.monthPicker)
|
||||
|
||||
val currentYear = currentViewMonth.year
|
||||
val currentMonth = currentViewMonth.monthValue
|
||||
|
||||
yearPicker.minValue = 2010
|
||||
yearPicker.maxValue = 2050
|
||||
yearPicker.value = currentYear
|
||||
yearPicker.wrapSelectorWheel = false
|
||||
|
||||
monthPicker.minValue = 1
|
||||
monthPicker.maxValue = 12
|
||||
monthPicker.value = currentMonth
|
||||
monthPicker.displayedValues = arrayOf("1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월")
|
||||
|
||||
val dialog = androidx.appcompat.app.AlertDialog.Builder(this, R.style.OneUI8_Dialog)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton("이동") { _, _ ->
|
||||
currentViewMonth = YearMonth.of(yearPicker.value, monthPicker.value)
|
||||
updateCalendar()
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.create()
|
||||
|
||||
dialog.show()
|
||||
|
||||
// 90% Screen Width for One UI 8.0 feel
|
||||
val width = (resources.displayMetrics.widthPixels * 0.9).toInt()
|
||||
dialog.window?.setLayout(width, android.view.ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
dialog.window?.setDimAmount(0.6f) // Darker dim to focus on popup
|
||||
|
||||
// Style buttons to look like One UI 8.0
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).apply {
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.primary))
|
||||
textSize = 17f
|
||||
setPadding(dpToPx(32f), dpToPx(16f), dpToPx(32f), dpToPx(16f))
|
||||
}
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE).apply {
|
||||
setTextColor(ContextCompat.getColor(this@MainActivity, R.color.text_secondary))
|
||||
textSize = 17f
|
||||
setPadding(dpToPx(32f), dpToPx(16f), dpToPx(32f), dpToPx(16f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun dpToPx(dp: Float): Int {
|
||||
return (dp * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
private fun setupCalendar() {
|
||||
binding.calendarGrid.layoutManager = GridLayoutManager(this, 7)
|
||||
updateCalendar()
|
||||
}
|
||||
|
||||
private fun updateCalendar() {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val selectedTeam = prefs.getString(KEY_TEAM, "A") ?: "A"
|
||||
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
|
||||
// Today's Shift
|
||||
val today = LocalDate.now(ShiftCalculator.SEOUL_ZONE)
|
||||
|
||||
lifecycleScope.launch {
|
||||
val db = AppDatabase.getDatabase(this@MainActivity)
|
||||
val dao = db.shiftDao()
|
||||
val repo = ShiftRepository(this@MainActivity)
|
||||
|
||||
// 전체 사용자 알람 로드 (일원화된 Room DB 사용)
|
||||
val allCustomAlarms = repo.getAllCustomAlarms()
|
||||
|
||||
// 디스플레이 업데이트
|
||||
if (currentViewTeam == selectedTeam) {
|
||||
val shiftForMyTeam = withContext(Dispatchers.IO) { repo.getShift(today, selectedTeam, factory) }
|
||||
updateAlarmTimeDisplay(today, shiftForMyTeam, factory, allCustomAlarms)
|
||||
binding.alarmTimeText.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
binding.alarmTimeText.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
// Load overrides and memos for the month
|
||||
val monthStr = currentViewMonth.toString()
|
||||
val overrides = withContext(Dispatchers.IO) {
|
||||
dao.getOverridesForMonth(factory, currentViewTeam, monthStr).associateBy { overrideItem -> overrideItem.date }
|
||||
}
|
||||
val memos = withContext(Dispatchers.IO) {
|
||||
dao.getMemosForMonth(monthStr).associateBy { memoItem -> memoItem.date }
|
||||
}
|
||||
|
||||
val days = generateDaysForMonthWithData(currentViewMonth, currentViewTeam, factory, overrides, memos)
|
||||
|
||||
val adapter = CalendarAdapter(days, object : CalendarAdapter.OnDayClickListener {
|
||||
override fun onDayClick(date: LocalDate, currentShift: String) {
|
||||
showDaySettingsDialog(date, currentShift)
|
||||
}
|
||||
}, binding.cbShowHolidays.isChecked)
|
||||
|
||||
binding.calendarGrid.adapter = adapter
|
||||
binding.monthTitle.text = currentViewMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월"))
|
||||
|
||||
// Update Header Status Text with Permission Warning if needed
|
||||
val shiftForViewingTeam = withContext(Dispatchers.IO) { repo.getShift(today, currentViewTeam, factory) }
|
||||
val teamSuffix = if (currentViewTeam == selectedTeam) " (내 반)" else " (${currentViewTeam}반)"
|
||||
|
||||
if (currentViewTeam == selectedTeam && !AlarmPermissionUtil.getExactAlarmStatus(this@MainActivity)) {
|
||||
binding.todayStatusText.text = "⚠️ 정확한 알람 권한이 필요합니다 (설정 필요)"
|
||||
binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.warning_red))
|
||||
} else {
|
||||
binding.todayStatusText.text = "오늘의 근무: $shiftForViewingTeam$teamSuffix"
|
||||
binding.todayStatusText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_secondary))
|
||||
}
|
||||
}
|
||||
|
||||
updateOtherTeamsLayout(today, factory, prefs)
|
||||
}
|
||||
|
||||
private fun updateOtherTeamsLayout(today: LocalDate, factory: String, prefs: android.content.SharedPreferences) {
|
||||
val teamColors = mapOf(
|
||||
"A" to R.color.team_a_color,
|
||||
"B" to R.color.team_b_color,
|
||||
"C" to R.color.team_c_color,
|
||||
"D" to R.color.team_d_color
|
||||
)
|
||||
val container = binding.otherTeamsContainer
|
||||
container.removeAllViews()
|
||||
|
||||
val rowLayout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.HORIZONTAL
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
val allTeams = if (factory == "Nonsan") listOf("A", "B", "C") else listOf("A", "B", "C", "D")
|
||||
|
||||
lifecycleScope.launch {
|
||||
for (t in allTeams) {
|
||||
val shift = ShiftCalculator.getShift(today, t, factory)
|
||||
val shortShift = when(shift) {
|
||||
"주간" -> "주"
|
||||
"석간" -> "석"
|
||||
"야간" -> "야"
|
||||
"주간 맞교대" -> "주맞"
|
||||
"야간 맞교대" -> "야맞"
|
||||
"휴무", "휴가" -> "휴"
|
||||
else -> shift.take(1)
|
||||
}
|
||||
|
||||
val textView = android.widget.TextView(this@MainActivity).apply {
|
||||
text = "${t}반 ($shortShift)"
|
||||
setPadding(0, 24, 0, 24)
|
||||
textSize = 12f
|
||||
gravity = android.view.Gravity.CENTER
|
||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
|
||||
if (currentViewTeam == t) {
|
||||
setTextColor(android.graphics.Color.WHITE)
|
||||
setBackgroundResource(R.drawable.bg_pill_rect_selected)
|
||||
} else {
|
||||
setTextColor(androidx.core.content.ContextCompat.getColor(context, teamColors[t]!!))
|
||||
setBackgroundResource(R.drawable.bg_pill_rect_unselected)
|
||||
}
|
||||
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
setMargins(4, 0, 4, 0)
|
||||
}
|
||||
|
||||
setOnClickListener {
|
||||
if (currentViewTeam != t) {
|
||||
currentViewTeam = t
|
||||
updateCalendar()
|
||||
Toast.makeText(context, "${t}반 근무표를 표시합니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
rowLayout.addView(textView)
|
||||
}
|
||||
container.addView(rowLayout)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateDaysForMonthWithData(
|
||||
month: YearMonth,
|
||||
team: String,
|
||||
factory: String,
|
||||
overrides: Map<String, ShiftOverride>,
|
||||
memos: Map<String, DailyMemo>
|
||||
): List<DayShift> {
|
||||
val daysInMonth = month.lengthOfMonth()
|
||||
val firstDayOfMonth = month.atDay(1).dayOfWeek.value % 7
|
||||
val actualDayCount = firstDayOfMonth + daysInMonth
|
||||
val targetCells = if (actualDayCount <= 35) 35 else 42
|
||||
|
||||
val dayList = mutableListOf<DayShift>()
|
||||
|
||||
for (i in 0 until firstDayOfMonth) {
|
||||
dayList.add(DayShift(null, null))
|
||||
}
|
||||
|
||||
for (day in 1..daysInMonth) {
|
||||
val date = month.atDay(day)
|
||||
val dateStr = date.toString()
|
||||
|
||||
val shift = overrides[dateStr]?.shift ?: ShiftCalculator.getShift(date, team, factory)
|
||||
val memo = memos[dateStr]
|
||||
val hasMemo = memo != null
|
||||
val memoContent = memo?.content
|
||||
|
||||
dayList.add(DayShift(date, shift, hasMemo, memoContent))
|
||||
}
|
||||
|
||||
while (dayList.size < targetCells) {
|
||||
dayList.add(DayShift(null, null))
|
||||
}
|
||||
return dayList
|
||||
}
|
||||
|
||||
private fun getAlarmsStrForDate(date: LocalDate, shift: String, allAlarms: List<CustomAlarm>): List<String> {
|
||||
val alarmTimes = mutableListOf<String>()
|
||||
val isOff = shift == "휴무" || shift == "휴가"
|
||||
|
||||
for (alarm in allAlarms) {
|
||||
if (alarm.isEnabled && (alarm.shiftType == "기타" || (!isOff && alarm.shiftType == shift))) {
|
||||
if (!alarmTimes.contains(alarm.time)) {
|
||||
alarmTimes.add(alarm.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alarmTimes.sort()
|
||||
return alarmTimes
|
||||
}
|
||||
|
||||
private fun updateAlarmTimeDisplay(date: LocalDate, shift: String, factory: String, allAlarms: List<CustomAlarm>) {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
// 0. Check Master Switch
|
||||
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
|
||||
binding.alarmTimeText.text = "전체 알람 꺼짐"
|
||||
binding.alarmTimeText.visibility = android.view.View.VISIBLE
|
||||
binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this, R.color.shift_red))
|
||||
binding.alarmTimeText.setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
return
|
||||
}
|
||||
|
||||
val todayStr = getAlarmsStrForDate(date, shift, allAlarms).firstOrNull() ?: "없음"
|
||||
val tomorrowDate = date.plusDays(1)
|
||||
val repo = ShiftRepository(this)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val tomorrowShiftFull = repo.getShift(tomorrowDate, currentViewTeam, factory)
|
||||
val tomorrowAlarms = getAlarmsStrForDate(tomorrowDate, tomorrowShiftFull, allAlarms)
|
||||
val tomorrowStr = tomorrowAlarms.firstOrNull() ?: "없음"
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
|
||||
binding.alarmTimeText.text = "전체 알람 꺼짐"
|
||||
binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.shift_red))
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val todayLabel = if (todayStr == "없음") "오늘: 없음" else "오늘: $todayStr"
|
||||
val tomorrowLabel = if (tomorrowStr == "없음") "내일: 없음" else "내일: $tomorrowStr"
|
||||
|
||||
binding.alarmTimeText.text = "$todayLabel | $tomorrowLabel"
|
||||
binding.alarmTimeText.visibility = android.view.View.VISIBLE
|
||||
binding.alarmTimeText.setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
binding.alarmTimeText.setTextColor(androidx.core.content.ContextCompat.getColor(this@MainActivity, R.color.text_primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDaySettingsDialog(date: LocalDate, currentShift: String) {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_day_settings, null)
|
||||
val dialog = androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setView(dialogView)
|
||||
.create()
|
||||
dialog.window?.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
|
||||
val team = prefs.getString(KEY_TEAM, "A") ?: "A"
|
||||
|
||||
// Set Title - One UI Style Header
|
||||
val titleText = dialogView.findViewById<android.widget.TextView>(R.id.dialogTitle)
|
||||
val subtitleText = dialogView.findViewById<android.widget.TextView>(R.id.dialogSubtitle)
|
||||
|
||||
subtitleText.text = if (date == LocalDate.now()) "오늘의 근무" else "${date.monthValue}월 ${date.dayOfMonth}일 근무"
|
||||
titleText.text = "$currentShift"
|
||||
|
||||
// Button Handlers
|
||||
val actionMap = mapOf(
|
||||
R.id.btnJu to "주간",
|
||||
R.id.btnJuMat to "주간 맞교대",
|
||||
R.id.btnSeok to "석간",
|
||||
R.id.btnYa to "야간",
|
||||
R.id.btnYaMat to "야간 맞교대",
|
||||
R.id.btnOff to "휴무",
|
||||
R.id.btnWolcha to "월차",
|
||||
R.id.btnYeoncha to "연차",
|
||||
R.id.btnBanwol to "반월",
|
||||
R.id.btnBannyeon to "반년",
|
||||
R.id.btnEdu to "교육",
|
||||
R.id.btnManual to "직접 입력",
|
||||
R.id.btnReset to "원래대로"
|
||||
)
|
||||
|
||||
fun applyStrokeStyle(viewId: Int, shiftType: String) {
|
||||
val view = dialogView.findViewById<android.widget.TextView>(viewId)
|
||||
val colorRes = when(shiftType) {
|
||||
"주간" -> R.color.shift_ju
|
||||
"석간" -> R.color.shift_seok
|
||||
"야간" -> R.color.shift_ya
|
||||
"주간 맞교대" -> R.color.shift_jumat
|
||||
"야간 맞교대" -> R.color.shift_yamat
|
||||
"휴무", "휴가" -> R.color.shift_off
|
||||
"월차", "연차" -> R.color.secondary
|
||||
"반월", "반년" -> R.color.shift_red
|
||||
"교육" -> R.color.primary
|
||||
"원래대로" -> R.color.text_secondary
|
||||
else -> R.color.shift_gray
|
||||
}
|
||||
val color = androidx.core.content.ContextCompat.getColor(this, colorRes)
|
||||
|
||||
view.setTextColor(color)
|
||||
|
||||
// Create Stroke Drawable Programmatically
|
||||
val drawable = android.graphics.drawable.GradientDrawable()
|
||||
drawable.shape = android.graphics.drawable.GradientDrawable.OVAL
|
||||
drawable.setColor(android.graphics.Color.TRANSPARENT)
|
||||
val density = resources.displayMetrics.density
|
||||
drawable.setStroke((1.5 * density).toInt(), color)
|
||||
|
||||
view.background = drawable
|
||||
}
|
||||
|
||||
actionMap.forEach { (id, type) -> applyStrokeStyle(id, type) }
|
||||
|
||||
// Memo Handling
|
||||
val etMemo = dialogView.findViewById<android.widget.EditText>(R.id.etMemo)
|
||||
val repo = ShiftRepository(this)
|
||||
lifecycleScope.launch {
|
||||
val existingMemo = repo.getMemo(date)
|
||||
etMemo.setText(existingMemo ?: "")
|
||||
}
|
||||
|
||||
for ((id, action) in actionMap) {
|
||||
dialogView.findViewById<android.view.View>(id).setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val content = etMemo.text.toString().trim()
|
||||
repo.setMemo(date, content) // Save memo even when shift button clicked
|
||||
handleDaySettingAction(date, action, team, factory)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogView.findViewById<android.view.View>(R.id.btnClearMemo).setOnClickListener {
|
||||
etMemo.setText("")
|
||||
lifecycleScope.launch {
|
||||
repo.setMemo(date, "")
|
||||
updateCalendar()
|
||||
Toast.makeText(this@MainActivity, "메모가 삭제되었습니다.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
dialogView.findViewById<android.view.View>(R.id.btnClose).setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
val content = etMemo.text.toString().trim()
|
||||
repo.setMemo(date, content)
|
||||
updateCalendar()
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private suspend fun handleDaySettingAction(date: LocalDate, selected: String, team: String, factory: String) {
|
||||
val repo = ShiftRepository(this)
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
when (selected) {
|
||||
"원래대로" -> {
|
||||
repo.clearOverride(date, team, factory)
|
||||
repo.setMemo(date, "") // Clear memo too on reset
|
||||
android.widget.Toast.makeText(this, "원래 근무로 복구되었습니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
syncAllAlarms(this)
|
||||
updateCalendar()
|
||||
}
|
||||
"직접 입력" -> {
|
||||
showCustomInputDialog(date, repo, team, factory)
|
||||
}
|
||||
"주간", "석간", "야간", "주간 맞교대", "야간 맞교대" -> {
|
||||
// Standard Shifts -> Override Shift WITHOUT manual time
|
||||
repo.setOverride(date, selected, team, factory)
|
||||
updateCalendar()
|
||||
// Alarms are handled by syncAllAlarms/CustomAlarms during updateCalendar
|
||||
syncAllAlarms(this)
|
||||
android.widget.Toast.makeText(this, "${date} [$selected]로 설정되었습니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
"휴무" -> {
|
||||
// Standard Off
|
||||
repo.setOverride(date, selected, team, factory)
|
||||
updateCalendar()
|
||||
syncAllAlarms(this)
|
||||
android.widget.Toast.makeText(this, "${selected}로 설정되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
else -> {
|
||||
// New Types: 월차, 연차, 반월, 반년, 교육 -> Saved as Override with no time
|
||||
repo.setOverride(date, selected, team, factory)
|
||||
updateCalendar()
|
||||
syncAllAlarms(this)
|
||||
android.widget.Toast.makeText(this, "${selected}(으)로 기록되었습니다. 알람이 해제됩니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomInputDialog(date: LocalDate, repo: ShiftRepository, team: String, factory: String) {
|
||||
val layout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(50, 40, 50, 10)
|
||||
}
|
||||
|
||||
val etMemoInput = android.widget.EditText(this).apply {
|
||||
hint = "메모 내용 (예: 회식, 교육, 출장)"
|
||||
maxLines = 1
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(20))
|
||||
}
|
||||
|
||||
layout.addView(etMemoInput)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle("${date.monthValue}월 ${date.dayOfMonth}일 메모 입력")
|
||||
.setView(layout)
|
||||
.setPositiveButton("확인") { _, _ ->
|
||||
val content = etMemoInput.text.toString().trim()
|
||||
|
||||
if (content.isNotEmpty()) {
|
||||
lifecycleScope.launch {
|
||||
// Direct Input -> Set as Memo.
|
||||
// We do NOT change the shift (keep default or existing override).
|
||||
// If user wants to change shift, they should use the specific buttons.
|
||||
repo.setMemo(date, content)
|
||||
updateCalendar()
|
||||
android.widget.Toast.makeText(this@MainActivity, "메모가 저장되었습니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
android.widget.Toast.makeText(this, "입력이 취소되었습니다.", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton("취소", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
|
||||
private fun setupWorker() {
|
||||
val workRequest = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS)
|
||||
.setInitialDelay(calculateDelayToMidnight(), TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"DailyShiftCheck",
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDelayToMidnight(): Long {
|
||||
val seoulZone = java.time.ZoneId.of("Asia/Seoul")
|
||||
val now = java.time.LocalDateTime.now(seoulZone)
|
||||
val midnight = now.plusDays(1).withHour(0).withMinute(0).withSecond(1)
|
||||
return java.time.Duration.between(now, midnight).toMillis()
|
||||
}
|
||||
|
||||
private fun checkRoot() {
|
||||
if (RootUtil.isDeviceRooted()) {
|
||||
Toast.makeText(this, "⚠️ 루팅된 기기에서 시각적 오류나 알람 불안정이 발생할 수 있습니다.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
60
app/src/main/java/com/example/shiftalarm/ManualActivity.kt
Normal file
60
app/src/main/java/com/example/shiftalarm/ManualActivity.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.example.shiftalarm.databinding.ActivityManualBinding
|
||||
|
||||
class ManualActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityManualBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityManualBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
setupManual()
|
||||
|
||||
binding.btnCloseManual.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupManual() {
|
||||
try {
|
||||
val versionName = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
} catch (e: Exception) { "0.7.1" }
|
||||
|
||||
binding.manualVersionText.text = "교대링(Shiftring) v$versionName"
|
||||
|
||||
val rawContent = assets.open("MANUAL.md").bufferedReader().use { it.readText() }
|
||||
|
||||
// Premium Styling logic
|
||||
val styledContent = rawContent
|
||||
.replace(Regex("^# (.*)", RegexOption.MULTILINE), "<br><big><big><b>$1</b></big></big><br>")
|
||||
.replace(Regex("^## (.*)", RegexOption.MULTILINE), "<br><br><font color='#00897B'><b>$1</b></font><br>")
|
||||
.replace(Regex("^### (.*)", RegexOption.MULTILINE), "<br><br><b>$1</b><br>")
|
||||
.replace(Regex("^- (.*)", RegexOption.MULTILINE), " • $1")
|
||||
.replace("\n", "<br>")
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
|
||||
binding.manualContent.text = android.text.Html.fromHtml(styledContent, android.text.Html.FROM_HTML_MODE_LEGACY)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
binding.manualContent.text = android.text.Html.fromHtml(styledContent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
binding.manualContent.text = "설명서를 불러오지 못했습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
137
app/src/main/java/com/example/shiftalarm/NoticeActivity.kt
Normal file
137
app/src/main/java/com/example/shiftalarm/NoticeActivity.kt
Normal file
@@ -0,0 +1,137 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.example.shiftalarm.databinding.ActivityNoticeBinding
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class NoticeActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityNoticeBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityNoticeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
supportActionBar?.hide()
|
||||
|
||||
binding.btnCloseNotice.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
binding.noticeRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
fetchChangelog()
|
||||
}
|
||||
|
||||
private fun fetchChangelog() {
|
||||
// GitHub Raw URL with cache busting
|
||||
val baseUrl = "https://raw.githubusercontent.com/sanjeok77-tech/dakjaba-releases/main/CHANGELOG.md"
|
||||
val urlString = "$baseUrl?t=${System.currentTimeMillis()}"
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val url = URL(urlString)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.requestMethod = "GET"
|
||||
connection.useCaches = false
|
||||
|
||||
if (connection.responseCode == 200) {
|
||||
val reader = BufferedReader(InputStreamReader(connection.inputStream))
|
||||
val content = reader.use { it.readText() }
|
||||
|
||||
runOnUiThread {
|
||||
val notices = parseChangelog(content)
|
||||
binding.noticeRecyclerView.adapter = NoticeAdapter(notices)
|
||||
}
|
||||
} else {
|
||||
throw Exception("Server returned ${connection.responseCode}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
runOnUiThread {
|
||||
// Fallback to local asset
|
||||
loadLocalChangelog()
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun loadLocalChangelog() {
|
||||
try {
|
||||
val content = assets.open("CHANGELOG.md").bufferedReader().use { it.readText() }
|
||||
val notices = parseChangelog(content)
|
||||
binding.noticeRecyclerView.adapter = NoticeAdapter(notices)
|
||||
} catch (e: Exception) {
|
||||
val empty = listOf(NoticeItem("데이터 로드 실패", "", "변경사항을 불러올 수 없습니다."))
|
||||
binding.noticeRecyclerView.adapter = NoticeAdapter(empty)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChangelog(content: String): List<NoticeItem> {
|
||||
val notices = mutableListOf<NoticeItem>()
|
||||
val lines = content.lines()
|
||||
|
||||
var currentVersion = ""
|
||||
var currentDate = ""
|
||||
var currentBody = StringBuilder()
|
||||
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
// Skip empty lines or horizontal rules (any amount of dashes)
|
||||
if (trimmed.isEmpty() || trimmed.matches(Regex("-{2,}"))) continue
|
||||
|
||||
// Handle version headers like "## v0.7.3" or "## [0.7.3]"
|
||||
if (trimmed.startsWith("## v") || trimmed.startsWith("## [")) {
|
||||
// Save previous version if exists
|
||||
if (currentVersion.isNotEmpty() && currentBody.isNotBlank()) {
|
||||
notices.add(NoticeItem("v$currentVersion 업데이트 정보", currentDate, currentBody.toString().trim()))
|
||||
}
|
||||
|
||||
// Parse new version (matches v0.7.3 or [0.7.3])
|
||||
val versionMatch = Regex("v?([\\d.]+)").find(trimmed)
|
||||
currentVersion = versionMatch?.groupValues?.getOrNull(1) ?: ""
|
||||
|
||||
val dateMatch = Regex("(\\d{4}-\\d{2}-\\d{2})").find(trimmed)
|
||||
currentDate = dateMatch?.groupValues?.getOrNull(1) ?: ""
|
||||
|
||||
currentBody = StringBuilder()
|
||||
} else if (trimmed.startsWith("- **") || trimmed.startsWith("* **")) {
|
||||
// Content line with bold key
|
||||
val cleaned = trimmed
|
||||
.replace(Regex("^[-*]\\s*\\*\\*(.+?)\\*\\*:?\\s*"), "▸ $1: ")
|
||||
.replace("**", "")
|
||||
currentBody.appendLine(cleaned)
|
||||
} else if (trimmed.startsWith("-") || trimmed.startsWith("*")) {
|
||||
// Regular bullet point
|
||||
val cleaned = trimmed.replace(Regex("^[-*]\\s*"), "• ")
|
||||
if (cleaned.length > 2) currentBody.appendLine(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// Add last version
|
||||
if (currentVersion.isNotEmpty() && currentBody.isNotBlank()) {
|
||||
notices.add(NoticeItem("v$currentVersion 업데이트 정보", currentDate, currentBody.toString().trim()))
|
||||
}
|
||||
|
||||
return notices.take(7)
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/example/shiftalarm/NoticeAdapter.kt
Normal file
30
app/src/main/java/com/example/shiftalarm/NoticeAdapter.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class NoticeAdapter(private val notices: List<NoticeItem>) : RecyclerView.Adapter<NoticeAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val title: TextView = view.findViewById(R.id.noticeTitle)
|
||||
val date: TextView = view.findViewById(R.id.noticeDate)
|
||||
val content: TextView = view.findViewById(R.id.noticeContent)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_notice, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = notices[position]
|
||||
holder.title.text = item.title
|
||||
holder.date.text = item.date
|
||||
holder.content.text = item.content
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = notices.size
|
||||
}
|
||||
7
app/src/main/java/com/example/shiftalarm/NoticeItem.kt
Normal file
7
app/src/main/java/com/example/shiftalarm/NoticeItem.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
data class NoticeItem(
|
||||
val title: String,
|
||||
val date: String,
|
||||
val content: String
|
||||
)
|
||||
46
app/src/main/java/com/example/shiftalarm/RootUtil.kt
Normal file
46
app/src/main/java/com/example/shiftalarm/RootUtil.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import java.io.File
|
||||
|
||||
object RootUtil {
|
||||
fun isDeviceRooted(): Boolean {
|
||||
return checkRootMethod1() || checkRootMethod2() || checkRootMethod3()
|
||||
}
|
||||
|
||||
private fun checkRootMethod1(): Boolean {
|
||||
val buildTags = android.os.Build.TAGS
|
||||
return buildTags != null && buildTags.contains("test-keys")
|
||||
}
|
||||
|
||||
private fun checkRootMethod2(): Boolean {
|
||||
val paths = arrayOf(
|
||||
"/system/app/Superuser.apk",
|
||||
"/sbin/su",
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/data/local/xbin/su",
|
||||
"/data/local/bin/su",
|
||||
"/system/sd/xbin/su",
|
||||
"/system/bin/failsafe/su",
|
||||
"/data/local/su",
|
||||
"/su/bin/su"
|
||||
)
|
||||
for (path in paths) {
|
||||
if (File(path).exists()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun checkRootMethod3(): Boolean {
|
||||
var process: Process? = null
|
||||
return try {
|
||||
process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
reader.readLine() != null
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
} finally {
|
||||
process?.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/example/shiftalarm/SettingsActivity.kt
Normal file
57
app/src/main/java/com/example/shiftalarm/SettingsActivity.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.example.shiftalarm.databinding.ActivitySettingsBinding
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivitySettingsBinding
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
// Refresh UI smoothly
|
||||
finish()
|
||||
startActivity(intent)
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val adapter = SettingsPagerAdapter(this)
|
||||
binding.viewPager.adapter = adapter
|
||||
|
||||
TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
|
||||
tab.text = when (position) {
|
||||
0 -> getString(R.string.tab_basic)
|
||||
1 -> getString(R.string.tab_alarm)
|
||||
2 -> getString(R.string.tab_additional)
|
||||
3 -> getString(R.string.tab_lab)
|
||||
else -> "설정"
|
||||
}
|
||||
}.attach()
|
||||
|
||||
// Jump to specific tab if requested
|
||||
val targetTab = intent.getIntExtra("TARGET_TAB", 0)
|
||||
binding.viewPager.setCurrentItem(targetTab, false)
|
||||
|
||||
binding.btnSave.text = "닫기"
|
||||
binding.btnSave.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class SettingsPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
|
||||
override fun getItemCount(): Int = 4
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
0 -> FragmentSettingsBasic()
|
||||
1 -> FragmentSettingsAlarm()
|
||||
2 -> FragmentSettingsAdditional()
|
||||
3 -> FragmentSettingsLab()
|
||||
else -> FragmentSettingsBasic()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* 알람 관련 전역 설정 및 유틸리티.
|
||||
*/
|
||||
object ShiftAlarmDefaults {
|
||||
|
||||
/**
|
||||
* 마스터 알람 스위치 상태 확인.
|
||||
*/
|
||||
fun isMasterAlarmEnabled(prefs: SharedPreferences): Boolean {
|
||||
// 기본값 TRUE
|
||||
return prefs.getBoolean("master_alarm_enabled", true)
|
||||
}
|
||||
}
|
||||
89
app/src/main/java/com/example/shiftalarm/ShiftCalculator.kt
Normal file
89
app/src/main/java/com/example/shiftalarm/ShiftCalculator.kt
Normal file
@@ -0,0 +1,89 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
object ShiftCalculator {
|
||||
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
val BASE_DATE: LocalDate = LocalDate.of(2026, 2, 1)
|
||||
|
||||
// Provided list has 20 items.
|
||||
// "석간 석간 석간 휴 휴" (5)
|
||||
// "주간 주간 주간 주간 주간 휴 휴" (7)
|
||||
// "야간 야간 야간 야간 야간 휴" (6)
|
||||
// "석간 석간" (2)
|
||||
// Total 20.
|
||||
val cycle = listOf(
|
||||
"석간", "석간", "석간", "휴무", "휴무",
|
||||
"주간", "주간", "주간", "주간", "주간", "휴무", "휴무",
|
||||
"야간", "야간", "야간", "야간", "야간", "휴무",
|
||||
"석간", "석간"
|
||||
)
|
||||
|
||||
val CYCLE_LENGTH = cycle.size
|
||||
|
||||
val TEAM_OFFSETS = mapOf(
|
||||
"A" to 0,
|
||||
"B" to 15,
|
||||
"C" to 10,
|
||||
"D" to 5
|
||||
)
|
||||
|
||||
fun getShift(date: LocalDate, team: String, factory: String = "Jeonju"): String {
|
||||
return when (factory) {
|
||||
"Nonsan" -> calculateNonsanShift(date, team)
|
||||
else -> calculateJeonjuShift(date, team)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateJeonjuShift(date: LocalDate, team: String): String {
|
||||
val teamOffset = TEAM_OFFSETS[team] ?: 0
|
||||
val days = ChronoUnit.DAYS.between(BASE_DATE, date).toInt()
|
||||
val index = Math.floorMod(days + teamOffset, CYCLE_LENGTH)
|
||||
return cycle[index]
|
||||
}
|
||||
|
||||
private fun calculateNonsanShift(date: LocalDate, team: String): String {
|
||||
// Nonsan Factory Logic
|
||||
// Mon-Fri: Work, Sat-Sun: Rest (Off) -> "휴무"
|
||||
// Base Date: 2026-02-09 (Monday)
|
||||
// Groups: A, B, C
|
||||
// User Requirement (Step 145):
|
||||
// Feb 9 week: Day (주간)
|
||||
// Feb 16 week: Night (야간)
|
||||
// Feb 23 week: Evening (석간)
|
||||
// Cycle: Day -> Night -> Evening
|
||||
|
||||
val dayOfWeek = date.dayOfWeek.value // 1=Mon, ..., 7=Sun
|
||||
if (dayOfWeek >= 6) return "휴무" // Sat, Sun is OFF
|
||||
|
||||
// Base Date: 2026-02-09 (Monday)
|
||||
val baseDateNonsan = LocalDate.of(2026, 2, 9)
|
||||
|
||||
// Calculate days between Monday of the target date and base date
|
||||
// To be safe for "Any date before", we align target date to its Monday
|
||||
val targetMonday = date.minusDays((dayOfWeek - 1).toLong()) // Align to Monday
|
||||
val daysDiff = ChronoUnit.DAYS.between(baseDateNonsan, targetMonday).toInt()
|
||||
val weeksPassed = daysDiff / 7
|
||||
|
||||
// Rotation Pattern: 주간 -> 야간 -> 석간
|
||||
// Index: 0=주간, 1=야간, 2=석간
|
||||
val rotation = listOf("주간", "야간", "석간")
|
||||
|
||||
// Start indices for 2026-02-09 (Week 0)
|
||||
// A: 주간 (0) -> Matches User Specification
|
||||
// B, C: Distributed to other shifts.
|
||||
// Assuming A=0(Day), B=1(Night), C=2(Evening)
|
||||
|
||||
val startOffset = when (team) {
|
||||
"A" -> 0 // 주간
|
||||
"B" -> 1 // 야간
|
||||
"C" -> 2 // 석간
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val currentIndex = Math.floorMod(startOffset + weeksPassed, 3)
|
||||
return rotation[currentIndex]
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/com/example/shiftalarm/ShiftDao.kt
Normal file
60
app/src/main/java/com/example/shiftalarm/ShiftDao.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface ShiftDao {
|
||||
// Override Queries
|
||||
@Query("SELECT * FROM shift_overrides WHERE factory = :factory AND team = :team AND date = :date")
|
||||
suspend fun getOverride(factory: String, team: String, date: String): ShiftOverride?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOverride(override: ShiftOverride)
|
||||
|
||||
@Query("DELETE FROM shift_overrides WHERE factory = :factory AND team = :team AND date = :date")
|
||||
suspend fun deleteOverride(factory: String, team: String, date: String)
|
||||
|
||||
@Query("SELECT * FROM shift_overrides WHERE factory = :factory AND team = :team AND date LIKE :month || '%'")
|
||||
suspend fun getOverridesForMonth(factory: String, team: String, month: String): List<ShiftOverride>
|
||||
|
||||
@Query("SELECT * FROM shift_overrides")
|
||||
suspend fun getAllOverrides(): List<ShiftOverride>
|
||||
|
||||
@Query("DELETE FROM shift_overrides")
|
||||
suspend fun clearOverrides()
|
||||
|
||||
// Memo Queries
|
||||
@Query("SELECT * FROM daily_memos WHERE date = :date")
|
||||
suspend fun getMemo(date: String): DailyMemo?
|
||||
|
||||
@Query("SELECT * FROM daily_memos WHERE date LIKE :month || '%'")
|
||||
suspend fun getMemosForMonth(month: String): List<DailyMemo>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMemo(memo: DailyMemo)
|
||||
|
||||
@Query("DELETE FROM daily_memos WHERE date = :date")
|
||||
suspend fun deleteMemo(date: String)
|
||||
|
||||
@Query("SELECT * FROM daily_memos")
|
||||
suspend fun getAllMemos(): List<DailyMemo>
|
||||
|
||||
@Query("DELETE FROM daily_memos")
|
||||
suspend fun clearMemos()
|
||||
|
||||
// Custom Alarm Queries
|
||||
@Query("SELECT * FROM custom_alarms ORDER BY time ASC")
|
||||
suspend fun getAllCustomAlarms(): List<CustomAlarm>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCustomAlarm(alarm: CustomAlarm): Long
|
||||
|
||||
@Update
|
||||
suspend fun updateCustomAlarm(alarm: CustomAlarm)
|
||||
|
||||
@Delete
|
||||
suspend fun deleteCustomAlarm(alarm: CustomAlarm)
|
||||
|
||||
@Query("DELETE FROM custom_alarms")
|
||||
suspend fun clearCustomAlarms()
|
||||
}
|
||||
60
app/src/main/java/com/example/shiftalarm/ShiftRepository.kt
Normal file
60
app/src/main/java/com/example/shiftalarm/ShiftRepository.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.example.shiftalarm
|
||||
|
||||
import android.content.Context
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ShiftRepository(private val context: Context) {
|
||||
private val db = AppDatabase.getDatabase(context)
|
||||
private val dao = db.shiftDao()
|
||||
|
||||
suspend fun getShift(date: LocalDate, team: String, factory: String): String = withContext(Dispatchers.IO) {
|
||||
val override = dao.getOverride(factory, team, date.toString())
|
||||
if (override != null) {
|
||||
return@withContext override.shift
|
||||
}
|
||||
ShiftCalculator.getShift(date, team, factory)
|
||||
}
|
||||
|
||||
suspend fun setOverride(date: LocalDate, shift: String, team: String, factory: String) {
|
||||
dao.insertOverride(ShiftOverride(factory, team, date.toString(), shift))
|
||||
}
|
||||
|
||||
suspend fun clearOverride(date: LocalDate, team: String, factory: String) {
|
||||
dao.deleteOverride(factory, team, date.toString())
|
||||
}
|
||||
|
||||
suspend fun getMemo(date: LocalDate): String? {
|
||||
return dao.getMemo(date.toString())?.content
|
||||
}
|
||||
|
||||
suspend fun setMemo(date: LocalDate, content: String) {
|
||||
if (content.isEmpty()) {
|
||||
dao.deleteMemo(date.toString())
|
||||
} else {
|
||||
dao.insertMemo(DailyMemo(date.toString(), content))
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Alarms
|
||||
suspend fun getAllCustomAlarms(): List<CustomAlarm> = withContext(Dispatchers.IO) {
|
||||
dao.getAllCustomAlarms()
|
||||
}
|
||||
|
||||
suspend fun addCustomAlarm(alarm: CustomAlarm): Long = withContext(Dispatchers.IO) {
|
||||
dao.insertCustomAlarm(alarm)
|
||||
}
|
||||
|
||||
suspend fun updateCustomAlarm(alarm: CustomAlarm) = withContext(Dispatchers.IO) {
|
||||
dao.updateCustomAlarm(alarm)
|
||||
}
|
||||
|
||||
suspend fun deleteCustomAlarm(alarm: CustomAlarm) = withContext(Dispatchers.IO) {
|
||||
dao.deleteCustomAlarm(alarm)
|
||||
}
|
||||
|
||||
suspend fun clearAllCustomAlarms() = withContext(Dispatchers.IO) {
|
||||
dao.clearCustomAlarms()
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/color/sl_switch_track.xml
Normal file
5
app/src/main/res/color/sl_switch_track.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true" android:color="@color/primary" />
|
||||
<item android:color="#20000000" /> <!-- Semi-transparent black instead of opaque gray -->
|
||||
</selector>
|
||||
12
app/src/main/res/drawable/bg_alarm_gradient.xml
Normal file
12
app/src/main/res/drawable/bg_alarm_gradient.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="30%"
|
||||
android:centerY="20%"
|
||||
android:gradientRadius="800dp"
|
||||
android:startColor="#6c4bb5"
|
||||
android:centerColor="#120b2d"
|
||||
android:endColor="#000000" />
|
||||
</shape>
|
||||
13
app/src/main/res/drawable/bg_btn_today_themed.xml
Normal file
13
app/src/main/res/drawable/bg_btn_today_themed.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#20000000">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="0.8dp"
|
||||
android:color="@color/btn_today_text" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
14
app/src/main/res/drawable/bg_button_one_ui.xml
Normal file
14
app/src/main/res/drawable/bg_button_one_ui.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#20007AFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#0D007AFF" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#1A007AFF" />
|
||||
<corners android:radius="32dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
|
||||
6
app/src/main/res/drawable/bg_card.xml
Normal file
6
app/src/main/res/drawable/bg_card.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<corners android:radius="24dp"/>
|
||||
<stroke android:width="0dp" android:color="#00000000"/>
|
||||
</shape>
|
||||
8
app/src/main/res/drawable/bg_circle_shift.xml
Normal file
8
app/src/main/res/drawable/bg_circle_shift.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/white" />
|
||||
<size
|
||||
android:width="44dp"
|
||||
android:height="44dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_circle_snooze.xml
Normal file
5
app/src/main/res/drawable/bg_circle_snooze.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#007AFF"/>
|
||||
</shape>
|
||||
4
app/src/main/res/drawable/bg_circle_snooze_v2.xml
Normal file
4
app/src/main/res/drawable/bg_circle_snooze_v2.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#007AFF"/>
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_circle_stop.xml
Normal file
5
app/src/main/res/drawable/bg_circle_stop.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FF3B30"/>
|
||||
</shape>
|
||||
4
app/src/main/res/drawable/bg_circle_stop_v2.xml
Normal file
4
app/src/main/res/drawable/bg_circle_stop_v2.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FF3B30"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_day_cell.xml
Normal file
6
app/src/main/res/drawable/bg_day_cell.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/glass_panel_bg"/>
|
||||
<corners android:radius="8dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_day_cell_today.xml
Normal file
6
app/src/main/res/drawable/bg_day_cell_today.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#E7F1FF"/>
|
||||
<corners android:radius="8dp"/>
|
||||
<stroke android:width="2dp" android:color="#0D6EFD"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_day_glass.xml
Normal file
7
app/src/main/res/drawable/bg_day_glass.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/glass_panel_bg"/>
|
||||
<corners android:radius="12dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
<gradient android:startColor="#10FFFFFF" android:endColor="#05FFFFFF" android:angle="45" />
|
||||
</shape>
|
||||
17
app/src/main/res/drawable/bg_day_today.xml
Normal file
17
app/src/main/res/drawable/bg_day_today.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<!-- Today's date highlight: Primary color tint with stronger glass effect -->
|
||||
<gradient
|
||||
android:startColor="#400D6EFD"
|
||||
android:endColor="#200D6EFD"
|
||||
android:angle="135"/>
|
||||
|
||||
<corners android:radius="10dp"/>
|
||||
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="#800D6EFD"/>
|
||||
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_dialog_glass.xml
Normal file
7
app/src/main/res/drawable/bg_dialog_glass.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Dialog background with more rounded corners for One UI 8 -->
|
||||
<solid android:color="@color/dialog_bg"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_dialog_header.xml
Normal file
9
app/src/main/res/drawable/bg_dialog_header.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="135"
|
||||
android:startColor="#800381FE"
|
||||
android:endColor="#805856D6"
|
||||
android:type="linear"/>
|
||||
<corners android:topLeftRadius="28dp" android:topRightRadius="28dp"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_dialog_solid_v4.xml
Normal file
6
app/src/main/res/drawable/bg_dialog_solid_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/dialog_bg"/>
|
||||
<corners android:radius="32dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/grid_divider"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_dismiss_circle_v4.xml
Normal file
6
app/src/main/res/drawable/bg_dismiss_circle_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#0c091c" />
|
||||
<stroke android:width="6dp" android:color="#998C6EFF" />
|
||||
</shape>
|
||||
19
app/src/main/res/drawable/bg_ghost_button.xml
Normal file
19
app/src/main/res/drawable/bg_ghost_button.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 눌렸을 때 -->
|
||||
<item android:state_pressed="true">
|
||||
<shape>
|
||||
<solid android:color="#30FFFFFF"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="1dp" android:color="#50FFFFFF"/>
|
||||
</shape>
|
||||
</item>
|
||||
<!-- 기본 -->
|
||||
<item>
|
||||
<shape>
|
||||
<solid android:color="#00FFFFFF"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="0.5dp" android:color="#20FFFFFF"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
6
app/src/main/res/drawable/bg_glass_button.xml
Normal file
6
app/src/main/res/drawable/bg_glass_button.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#40FFFFFF"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="1dp" android:color="#30FFFFFF"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_button_accent.xml
Normal file
6
app/src/main/res/drawable/bg_glass_button_accent.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#605C6BC0"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="1dp" android:color="#40FFFFFF"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_button_light.xml
Normal file
6
app/src/main/res/drawable/bg_glass_button_light.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/button_glass_bg"/>
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_card.xml
Normal file
6
app/src/main/res/drawable/bg_glass_card.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#E6FFFFFF"/>
|
||||
<corners android:radius="20dp"/>
|
||||
<stroke android:width="1dp" android:color="#20000000"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_card_dark.xml
Normal file
6
app/src/main/res/drawable/bg_glass_card_dark.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#30FFFFFF"/>
|
||||
<corners android:radius="24dp"/>
|
||||
<stroke android:width="1dp" android:color="#25FFFFFF"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_circle.xml
Normal file
6
app/src/main/res/drawable/bg_glass_circle.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#33FFFFFF"/>
|
||||
<stroke android:width="1dp" android:color="#4DFFFFFF"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_panel_v2.xml
Normal file
6
app/src/main/res/drawable/bg_glass_panel_v2.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/glass_panel_bg"/>
|
||||
<corners android:radius="16dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_glass_panel_v3.xml
Normal file
7
app/src/main/res/drawable/bg_glass_panel_v3.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/glass_panel_bg"/>
|
||||
<corners android:radius="32dp"/>
|
||||
<stroke android:width="0.8dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
|
||||
14
app/src/main/res/drawable/bg_glass_pill.xml
Normal file
14
app/src/main/res/drawable/bg_glass_pill.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<!-- Glass Element: 20% Translucent White -->
|
||||
<solid android:color="#33FFFFFF" />
|
||||
|
||||
<!-- Full Pill Shape -->
|
||||
<corners android:radius="100dp" />
|
||||
|
||||
<!-- Subtle Border: 30% Translucent White -->
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#4DFFFFFF" />
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_glass_pill_v4.xml
Normal file
7
app/src/main/res/drawable/bg_glass_pill_v4.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="44dp" />
|
||||
<solid android:color="#26FFFFFF" />
|
||||
<stroke android:width="1.8dp" android:color="#4DFFFFFF" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_glass_save_button.xml
Normal file
9
app/src/main/res/drawable/bg_glass_save_button.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#7C4DFF"
|
||||
android:endColor="#5C6BC0"
|
||||
android:angle="135"/>
|
||||
<corners android:radius="16dp"/>
|
||||
<stroke android:width="1dp" android:color="#40FFFFFF"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_settings_button.xml
Normal file
6
app/src/main/res/drawable/bg_glass_settings_button.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#20000000"/>
|
||||
<corners android:radius="12dp"/>
|
||||
<stroke android:width="1dp" android:color="#15000000"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_slider_snooze.xml
Normal file
6
app/src/main/res/drawable/bg_glass_slider_snooze.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#40FFFFFF"/>
|
||||
<corners android:radius="40dp"/>
|
||||
<stroke android:width="1.5dp" android:color="#501976D2"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_glass_slider_stop.xml
Normal file
6
app/src/main/res/drawable/bg_glass_slider_stop.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#40FFFFFF"/>
|
||||
<corners android:radius="40dp"/>
|
||||
<stroke android:width="1.5dp" android:color="#50E53935"/>
|
||||
</shape>
|
||||
19
app/src/main/res/drawable/bg_glass_thumb_snooze.xml
Normal file
19
app/src/main/res/drawable/bg_glass_thumb_snooze.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#B0FFFFFF"/>
|
||||
<size android:width="64dp" android:height="64dp"/>
|
||||
<stroke android:width="2dp" android:color="#801976D2"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp">
|
||||
<shape android:shape="oval">
|
||||
<gradient
|
||||
android:startColor="#1976D2"
|
||||
android:endColor="#42A5F5"
|
||||
android:angle="135"/>
|
||||
<size android:width="32dp" android:height="32dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
19
app/src/main/res/drawable/bg_glass_thumb_stop.xml
Normal file
19
app/src/main/res/drawable/bg_glass_thumb_stop.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#B0FFFFFF"/>
|
||||
<size android:width="64dp" android:height="64dp"/>
|
||||
<stroke android:width="2dp" android:color="#80E53935"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp">
|
||||
<shape android:shape="oval">
|
||||
<gradient
|
||||
android:startColor="#E53935"
|
||||
android:endColor="#EF5350"
|
||||
android:angle="135"/>
|
||||
<size android:width="32dp" android:height="32dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
6
app/src/main/res/drawable/bg_glass_time_button.xml
Normal file
6
app/src/main/res/drawable/bg_glass_time_button.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#C5E8F8"/>
|
||||
<corners android:radius="12dp"/>
|
||||
<stroke android:width="1dp" android:color="#60A5C8D8"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_grid_cell.xml
Normal file
7
app/src/main/res/drawable/bg_grid_cell.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
<stroke
|
||||
android:width="0.5dp"
|
||||
android:color="@color/grid_divider" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_grid_cell_today_v4.xml
Normal file
5
app/src/main/res/drawable/bg_grid_cell_today_v4.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/bg_grid_cell_today"/>
|
||||
<stroke android:width="1dp" android:color="@color/primary" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_grid_cell_v4.xml
Normal file
9
app/src/main/res/drawable/bg_grid_cell_v4.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/bg_grid_cell_default" />
|
||||
<stroke android:width="0.5dp" android:color="@color/grid_divider" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
27
app/src/main/res/drawable/bg_item_ripple_oneui.xml
Normal file
27
app/src/main/res/drawable/bg_item_ripple_oneui.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#1A000000"> <!-- Subtle dark gray for light theme ripple -->
|
||||
<item android:id="@android:id/mask">
|
||||
<shape>
|
||||
<solid android:color="@color/white" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<selector>
|
||||
<item android:state_pressed="true">
|
||||
<shape>
|
||||
<solid android:color="#0D000000" /> <!-- 5% gray overlay -->
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape>
|
||||
<solid android:color="@color/glass_panel_bg" />
|
||||
<corners android:radius="28dp" />
|
||||
<stroke android:width="0.8dp" android:color="@color/glass_panel_stroke" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
</item>
|
||||
</ripple>
|
||||
10
app/src/main/res/drawable/bg_mesh_gradient.xml
Normal file
10
app/src/main/res/drawable/bg_mesh_gradient.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="@color/background_mesh_start"
|
||||
android:centerColor="@color/background_mesh_center"
|
||||
android:endColor="@color/background_mesh_end"
|
||||
android:type="linear"
|
||||
android:angle="135"/>
|
||||
<corners android:radius="0dp"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_opaque_glass_dialog.xml
Normal file
6
app/src/main/res/drawable/bg_opaque_glass_dialog.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFFFF"/> <!-- Fully Opaque to hide everything behind -->
|
||||
<corners android:radius="32dp"/>
|
||||
<stroke android:width="1.2dp" android:color="#1A000000"/>
|
||||
</shape>
|
||||
10
app/src/main/res/drawable/bg_pill_button_primary.xml
Normal file
10
app/src/main/res/drawable/bg_pill_button_primary.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="#4DFFFFFF">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/primary" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
5
app/src/main/res/drawable/bg_pill_rect_selected.xml
Normal file
5
app/src/main/res/drawable/bg_pill_rect_selected.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/primary"/>
|
||||
<corners android:radius="12dp"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_pill_rect_unselected.xml
Normal file
6
app/src/main/res/drawable/bg_pill_rect_unselected.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/surface_variant"/>
|
||||
<stroke android:width="1dp" android:color="@color/outline"/>
|
||||
<corners android:radius="12dp"/>
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_pill_selected.xml
Normal file
5
app/src/main/res/drawable/bg_pill_selected.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/primary"/>
|
||||
<corners android:radius="20dp"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_pill_unselected.xml
Normal file
6
app/src/main/res/drawable/bg_pill_unselected.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/button_glass_bg"/>
|
||||
<corners android:radius="20dp"/>
|
||||
<stroke android:width="1dp" android:color="@color/glass_panel_stroke"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_popup_solid_v4.xml
Normal file
6
app/src/main/res/drawable/bg_popup_solid_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#F2FFFFFF"/> <!-- 95% White -->
|
||||
<corners android:radius="28dp"/>
|
||||
<stroke android:width="0dp" android:color="@android:color/transparent"/>
|
||||
</shape>
|
||||
12
app/src/main/res/drawable/bg_pulse_gradient.xml
Normal file
12
app/src/main/res/drawable/bg_pulse_gradient.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:gradientRadius="140dp"
|
||||
android:centerX="50%"
|
||||
android:centerY="50%"
|
||||
android:startColor="#CC8C6EFF"
|
||||
android:centerColor="#40785AFF"
|
||||
android:endColor="#00000000" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_settings_card.xml
Normal file
6
app/src/main/res/drawable/bg_settings_card.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<corners android:radius="16dp"/>
|
||||
<padding android:left="16dp" android:top="16dp" android:right="16dp" android:bottom="16dp"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_settings_gradient.xml
Normal file
7
app/src/main/res/drawable/bg_settings_gradient.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#E8F0FE"
|
||||
android:endColor="#F5F7FF"
|
||||
android:angle="135"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_shift_circle_v4.xml
Normal file
6
app/src/main/res/drawable/bg_shift_circle_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/white" />
|
||||
<size android:width="32dp" android:height="32dp" />
|
||||
</shape>
|
||||
14
app/src/main/res/drawable/bg_shift_half_red.xml
Normal file
14
app/src/main/res/drawable/bg_shift_half_red.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Left Half Vector -->
|
||||
<item android:drawable="@drawable/ic_shift_half_red_vector" />
|
||||
|
||||
<!-- Full Circle Stroke Overlay -->
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<stroke android:width="1.5dp" android:color="@color/shift_red"/>
|
||||
<solid android:color="@android:color/transparent"/>
|
||||
<size android:width="44dp" android:height="44dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
6
app/src/main/res/drawable/bg_shift_solid_v4.xml
Normal file
6
app/src/main/res/drawable/bg_shift_solid_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/primary" />
|
||||
<size android:width="44dp" android:height="44dp" />
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_shift_stroke_v4.xml
Normal file
6
app/src/main/res/drawable/bg_shift_stroke_v4.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<stroke android:width="1.5dp" android:color="@color/primary" />
|
||||
<size android:width="44dp" android:height="44dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_slider_fill_snooze.xml
Normal file
5
app/src/main/res/drawable/bg_slider_fill_snooze.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#401976D2" /> <!-- 25% Opacity Blue -->
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_slider_fill_stop.xml
Normal file
5
app/src/main/res/drawable/bg_slider_fill_stop.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#40D32F2F" /> <!-- 25% Opacity Red -->
|
||||
<corners android:radius="36dp" />
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_slider_snooze.xml
Normal file
9
app/src/main/res/drawable/bg_slider_snooze.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#1565C0"
|
||||
android:centerColor="#1976D2"
|
||||
android:endColor="#42A5F5"
|
||||
android:angle="135"/>
|
||||
<corners android:radius="40dp"/>
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/bg_slider_stop.xml
Normal file
9
app/src/main/res/drawable/bg_slider_stop.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#C62828"
|
||||
android:centerColor="#E53935"
|
||||
android:endColor="#EF5350"
|
||||
android:angle="135"/>
|
||||
<corners android:radius="40dp"/>
|
||||
</shape>
|
||||
6
app/src/main/res/drawable/bg_slider_thumb.xml
Normal file
6
app/src/main/res/drawable/bg_slider_thumb.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<corners android:radius="30dp"/>
|
||||
<size android:width="60dp" android:height="60dp"/>
|
||||
</shape>
|
||||
7
app/src/main/res/drawable/bg_slider_track.xml
Normal file
7
app/src/main/res/drawable/bg_slider_track.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="40dp" /> <!-- Fully rounded corners for pill shape -->
|
||||
<solid android:color="#00000000" /> <!-- Transparent fill -->
|
||||
<stroke android:width="1.5dp" android:color="#40FFFFFF" /> <!-- Glass border -->
|
||||
</shape>
|
||||
15
app/src/main/res/drawable/bg_thumb_snooze.xml
Normal file
15
app/src/main/res/drawable/bg_thumb_snooze.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<size android:width="64dp" android:height="64dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:left="18dp" android:top="18dp" android:right="18dp" android:bottom="18dp">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#1976D2"/>
|
||||
<size android:width="28dp" android:height="28dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
15
app/src/main/res/drawable/bg_thumb_stop.xml
Normal file
15
app/src/main/res/drawable/bg_thumb_stop.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#FFFFFF"/>
|
||||
<size android:width="64dp" android:height="64dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
<item android:left="18dp" android:top="18dp" android:right="18dp" android:bottom="18dp">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#E53935"/>
|
||||
<size android:width="28dp" android:height="28dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
30
app/src/main/res/drawable/ic_alarm.xml
Normal file
30
app/src/main/res/drawable/ic_alarm.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,13m-8,0a8,8 0,1 1 16,0a8,8 0,1 1 -16,0"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M12,9v4l2,2"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M5,3L2,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M19,3L22,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
35
app/src/main/res/drawable/ic_alarm_blue.xml
Normal file
35
app/src/main/res/drawable/ic_alarm_blue.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M0,0h108v108h-108z"
|
||||
android:fillColor="#2196F3"/>
|
||||
<group android:translateX="30" android:translateY="30" android:scaleX="2" android:scaleY="2">
|
||||
<path
|
||||
android:pathData="M12,13m-8,0a8,8 0,1 1 16,0a8,8 0,1 1 -16,0"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M12,9v4l2,2"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M5,3L2,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M19,3L22,6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</group>
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_bell.xml
Normal file
18
app/src/main/res/drawable/ic_bell.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,8a6,6 0,0 1 12,0c0,7 3,9 3,9H3s3,-2 3,-9"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:pathData="M10.3,21a1.94,1.94 0,0 0 3.4,0"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user