diff --git a/README.md b/README.md index 0c2ae35..5c8f4c1 100644 --- a/README.md +++ b/README.md @@ -1 +1,404 @@ # RunCombi_Android + +## πŸ“± ν”„λ‘œμ νŠΈ κ°œμš” + +**RunCombi**λŠ” λ°˜λ €λ™λ¬Όκ³Ό ν•¨κ»˜ν•˜λŠ” μš΄λ™μ„ κΈ°λ‘ν•˜κ³  κ΄€λ¦¬ν•˜λŠ” Android μ• ν”Œλ¦¬μΌ€μ΄μ…˜μž…λ‹ˆλ‹€. μ‚¬μš©μžμ™€ λ°˜λ €λ™λ¬Όμ˜ μš΄λ™ 데이터λ₯Ό μ‹€μ‹œκ°„μœΌλ‘œ μΆ”μ ν•˜κ³ , κ±΄κ°•ν•œ λΌμ΄ν”„μŠ€νƒ€μΌμ„ μ§€μ›ν•©λ‹ˆλ‹€. + +### μ£Όμš” κΈ°λŠ₯ +- πŸšΆβ€β™‚οΈ μ‹€μ‹œκ°„ μš΄λ™ 좔적 (κ±·κΈ°, λ›°κΈ°) +- πŸ• λ°˜λ €λ™λ¬Όκ³Ό ν•¨κ»˜ν•˜λŠ” μš΄λ™ 기둝 +- πŸ“Š μš΄λ™ 톡계 및 칼둜리 계산 +- πŸ—ΊοΈ GPS 기반 경둜 좔적 +- πŸ”„ λ°±κ·ΈλΌμš΄λ“œ μš΄λ™ 기둝 (ForegroundService) +- πŸ‘₯ μ‚¬μš©μž ν”„λ‘œν•„ 및 λ°˜λ €λ™λ¬Ό 관리 + +## πŸ—οΈ ν”„λ‘œμ νŠΈ μ•„ν‚€ν…μ²˜ + +### Clean Architecture + MVVM νŒ¨ν„΄ + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Presentation Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Screens β”‚ β”‚ ViewModels β”‚ β”‚ Composablesβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Domain Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ UseCases β”‚ β”‚ Entities β”‚ β”‚ Repositoriesβ”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Data Layer β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Repositoriesβ”‚ β”‚ DataSources β”‚ β”‚ Models β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### μ•„ν‚€ν…μ²˜ νŠΉμ§• +- **Clean Architecture**: 관심사 뢄리와 μ˜μ‘΄μ„± μ—­μ „ 원칙 적용 +- **MVVM**: ViewModelκ³Ό StateFlowλ₯Ό ν†΅ν•œ λ°˜μ‘ν˜• UI +- **Repository Pattern**: 데이터 μ ‘κ·Ό 좔상화 +- **UseCase Pattern**: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μΊ‘μŠν™” +- **Dependency Injection**: Hiltλ₯Ό ν†΅ν•œ μ˜μ‘΄μ„± 관리 + +## πŸ“ ν”„λ‘œμ νŠΈ λͺ¨λ“ˆ 트리 + +``` +RunCombi_Android/ +β”œβ”€β”€ app/ # 메인 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ λͺ¨λ“ˆ +β”œβ”€β”€ build-logic/ # λΉŒλ“œ 둜직 λͺ¨λ“ˆ +β”œβ”€β”€ core/ # 핡심 곡톡 λͺ¨λ“ˆ +β”‚ β”œβ”€β”€ analytics/ # 뢄석 도ꡬ +β”‚ β”œβ”€β”€ data/ # 데이터 계측 +β”‚ β”‚ β”œβ”€β”€ auth/ # 인증 데이터 +β”‚ β”‚ β”œβ”€β”€ common/ # 곡톡 데이터 +β”‚ β”‚ β”œβ”€β”€ history/ # νžˆμŠ€ν† λ¦¬ 데이터 +β”‚ β”‚ β”œβ”€β”€ setting/ # μ„€μ • 데이터 +β”‚ β”‚ β”œβ”€β”€ user/ # μ‚¬μš©μž 데이터 +β”‚ β”‚ β”œβ”€β”€ walk/ # μš΄λ™ 데이터 +β”‚ β”‚ β”œβ”€β”€ datastore/ # 둜컬 데이터 μ €μž₯μ†Œ +β”‚ β”‚ └── network/ # λ„€νŠΈμ›Œν¬ 톡신 +β”‚ β”œβ”€β”€ designsystem/ # λ””μžμΈ μ‹œμŠ€ν…œ +β”‚ β”œβ”€β”€ domain/ # 도메인 계측 +β”‚ β”‚ β”œβ”€β”€ auth/ # 인증 도메인 +β”‚ β”‚ β”œβ”€β”€ common/ # 곡톡 도메인 +β”‚ β”‚ β”œβ”€β”€ history/ # νžˆμŠ€ν† λ¦¬ 도메인 +β”‚ β”‚ β”œβ”€β”€ setting/ # μ„€μ • 도메인 +β”‚ β”‚ β”œβ”€β”€ user/ # μ‚¬μš©μž 도메인 +β”‚ β”‚ └── walk/ # μš΄λ™ 도메인 +β”‚ β”œβ”€β”€ navigation/ # λ„€λΉ„κ²Œμ΄μ…˜ +β”‚ └── ui/ # 곡톡 UI μ»΄ν¬λ„ŒνŠΈ +└── feature/ # κΈ°λŠ₯별 λͺ¨λ“ˆ + β”œβ”€β”€ history/ # μš΄λ™ νžˆμŠ€ν† λ¦¬ + β”œβ”€β”€ login/ # 둜그인/인증 + β”œβ”€β”€ main/ # 메인 ν™”λ©΄ + β”œβ”€β”€ setting/ # μ„€μ • + β”œβ”€β”€ signup/ # νšŒμ›κ°€μž… + └── walk/ # μš΄λ™ 좔적 +``` + +## πŸ”§ ν”„λ‘œμ νŠΈ λͺ¨λ“ˆλ³„ μ„€λͺ… + +### πŸ“± App Module +- **μ—­ν• **: 메인 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ§„μž…μ  +- **μ£Όμš” ꡬ성**: Application 클래슀, AndroidManifest +- **νŠΉμ§•**: λͺ¨λ“  λͺ¨λ“ˆμ„ ν†΅ν•©ν•˜κ³  μ˜μ‘΄μ„± μ£Όμž… μ„€μ • + +### πŸ—οΈ Build-Logic Module +- **μ—­ν• **: μ»€μŠ€ν…€ Gradle ν”ŒλŸ¬κ·ΈμΈ 및 λΉŒλ“œ 둜직 +- **μ£Όμš” ꡬ성**: + - `runcombi.android.application.gradle.kts` + - `runcombi.android.compose.gradle.kts` + - `runcombi.android.feature.gradle.kts` + - `runcombi.android.library.gradle.kts` + +### 🎯 Core Module +#### Analytics +- **μ—­ν• **: μ‚¬μš©μž 행동 뢄석 및 이벀트 좔적 +- **기술**: Firebase Analytics, μ»€μŠ€ν…€ 이벀트 λ‘œκΉ… + +#### Data Layer +- **μ—­ν• **: 데이터 μ ‘κ·Ό 및 관리 +- **ꡬ성**: + - **Auth**: μ‚¬μš©μž 인증 및 κΆŒν•œ 관리 + - **Common**: 곡톡 데이터 λͺ¨λΈ 및 μœ ν‹Έλ¦¬ν‹° + - **History**: μš΄λ™ 기둝 데이터 관리 + - **Setting**: μ‚¬μš©μž μ„€μ • 데이터 + - **User**: μ‚¬μš©μž ν”„λ‘œν•„ 및 정보 + - **Walk**: μš΄λ™ 좔적 데이터 + - **Datastore**: 둜컬 데이터 μ €μž₯ (Proto DataStore) + - **Network**: API 톡신 및 λ„€νŠΈμ›Œν¬ 처리 + +#### Domain Layer +- **μ—­ν• **: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 및 μ—”ν‹°ν‹° μ •μ˜ +- **ꡬ성**: 각 데이터 λͺ¨λ“ˆμ— λŒ€μ‘ν•˜λŠ” 도메인 λͺ¨λ“ˆ +- **νŠΉμ§•**: UseCase νŒ¨ν„΄μœΌλ‘œ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μΊ‘μŠν™” + +#### Design System +- **μ—­ν• **: μΌκ΄€λœ UI/UX μ»΄ν¬λ„ŒνŠΈ 제곡 +- **ꡬ성**: ν…Œλ§ˆ, 색상, νƒ€μ΄ν¬κ·Έλž˜ν”Ό, 곡톡 μ»΄ν¬λ„ŒνŠΈ + +#### Navigation +- **μ—­ν• **: ν™”λ©΄ κ°„ λ„€λΉ„κ²Œμ΄μ…˜ 관리 +- **기술**: Jetpack Navigation Compose + +#### UI +- **μ—­ν• **: 곡톡 UI μ»΄ν¬λ„ŒνŠΈ 및 μœ ν‹Έλ¦¬ν‹° +- **ꡬ성**: μž¬μ‚¬μš© κ°€λŠ₯ν•œ Compose μ»΄ν¬λ„ŒνŠΈ + +### ⚑ Feature Modules +#### History +- **μ—­ν• **: μš΄λ™ 기둝 쑰회 및 톡계 +- **κΈ°λŠ₯**: μš΄λ™ νžˆμŠ€ν† λ¦¬, 톡계 차트, 필터링 + +#### Login +- **μ—­ν• **: μ‚¬μš©μž 인증 및 둜그인 +- **κΈ°λŠ₯**: 카카였 둜그인, μžλ™ 둜그인 + +#### Main +- **μ—­ν• **: 메인 ν™”λ©΄ 및 λ„€λΉ„κ²Œμ΄μ…˜ +- **κΈ°λŠ₯**: ν•˜λ‹¨ λ„€λΉ„κ²Œμ΄μ…˜, ν™ˆ ν™”λ©΄ + +#### Setting +- **μ—­ν• **: μ‚¬μš©μž μ„€μ • 및 ν”„λ‘œν•„ 관리 +- **κΈ°λŠ₯**: κ°œμΈμ •λ³΄ μˆ˜μ •, μ•Œλ¦Ό μ„€μ • + +#### Signup +- **μ—­ν• **: νšŒμ›κ°€μž… 및 초기 μ„€μ • +- **κΈ°λŠ₯**: μ‚¬μš©μž 정보 μž…λ ₯, λ°˜λ €λ™λ¬Ό 등둝 + +#### Walk +- **μ—­ν• **: μš΄λ™ 좔적 및 기둝 +- **κΈ°λŠ₯**: GPS 좔적, μ‹€μ‹œκ°„ 기둝, ForegroundService 연동 + +## πŸ› οΈ ν”„λ‘œμ νŠΈ 기술 μŠ€νƒ + +### πŸ“± Android & Kotlin +- **μ–Έμ–΄**: Kotlin 100% +- **μ΅œμ†Œ SDK**: API 26 (Android 8.0) +- **νƒ€κ²Ÿ SDK**: API 35 (Android 15) +- **Jetpack Compose**: UI ν”„λ ˆμž„μ›Œν¬ + +### πŸ—οΈ μ•„ν‚€ν…μ²˜ & νŒ¨ν„΄ +- **Clean Architecture**: 계측별 관심사 뢄리 +- **MVVM**: Model-View-ViewModel νŒ¨ν„΄ +- **Repository Pattern**: 데이터 μ ‘κ·Ό 좔상화 +- **UseCase Pattern**: λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μΊ‘μŠν™” + +### πŸ”§ μ£Όμš” 라이브러리 +- **μ˜μ‘΄μ„± μ£Όμž…**: Hilt +- **비동기 처리**: Kotlin Coroutines + Flow +- **λ„€λΉ„κ²Œμ΄μ…˜**: Jetpack Navigation Compose +- **μƒνƒœ 관리**: StateFlow, MutableStateFlow +- **데이터 μ €μž₯**: Proto DataStore, Room +- **λ„€νŠΈμ›Œν¬**: Retrofit, OkHttp +- **이미지 처리**: Coil +- **κΆŒν•œ 관리**: Accompanist Permissions + +### πŸ“Š μ™ΈλΆ€ μ„œλΉ„μŠ€ +- **뢄석**: Firebase Analytics +- **ν¬λž˜μ‹œ λ¦¬ν¬νŒ…**: Firebase Crashlytics +- **지도**: Google Maps API +- **μ†Œμ…œ 둜그인**: Kakao SDK + +### πŸš€ λΉŒλ“œ 도ꡬ +- **λΉŒλ“œ μ‹œμŠ€ν…œ**: Gradle (Kotlin DSL) +- **λͺ¨λ“ˆν™”**: Feature-based λͺ¨λ“ˆ ꡬ쑰 +- **μ»€μŠ€ν…€ ν”ŒλŸ¬κ·ΈμΈ**: 자체 Gradle ν”ŒλŸ¬κ·ΈμΈ + +## 🏭 Flavor μ‹œμŠ€ν…œ + +### Product Flavors +ν”„λ‘œμ νŠΈλŠ” 개발 ν™˜κ²½κ³Ό ν”„λ‘œλ•μ…˜ ν™˜κ²½μ„ λΆ„λ¦¬ν•˜κΈ° μœ„ν•΄ 두 κ°€μ§€ flavorλ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. + +#### πŸ§ͺ Mock Flavor +- **λͺ©μ **: 개발 및 ν…ŒμŠ€νŠΈ ν™˜κ²½ +- **νŠΉμ§•**: + - κ°€μ§œ 데이터(Mock Data) μ‚¬μš© + - λ„€νŠΈμ›Œν¬ API 호좜 없이 λ‘œμ»¬μ—μ„œ ν…ŒμŠ€νŠΈ + - λΉ λ₯Έ 개발 및 디버깅 + - ν…ŒμŠ€νŠΈ λ°μ΄ν„°λ‘œ UI 검증 + +#### πŸš€ Prod Flavor +- **λͺ©μ **: μ‹€μ œ ν”„λ‘œλ•μ…˜ ν™˜κ²½ +- **νŠΉμ§•**: + - μ‹€μ œ μ„œλ²„ API 연동 + - Firebase μ„œλΉ„μŠ€ 연동 + - Google Maps API 연동 + - μ‹€μ œ μ‚¬μš©μž 데이터 처리 + + +### Flavor별 μ„€μ • 파일 +``` +app/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ mock/ # Mock flavor μ „μš© μ†ŒμŠ€ +β”‚ β”‚ β”œβ”€β”€ java/ +β”‚ β”‚ β”‚ └── com/combo/runcombi/ +β”‚ β”‚ β”‚ └── mock/ # Mock 데이터 κ΅¬ν˜„ +β”‚ β”‚ └── res/ +β”‚ β”œβ”€β”€ prod/ # Prod flavor μ „μš© μ†ŒμŠ€ +β”‚ β”‚ β”œβ”€β”€ java/ +β”‚ β”‚ β”‚ └── com/combo/runcombi/ +β”‚ β”‚ β”‚ └── prod/ # μ‹€μ œ μ„œλΉ„μŠ€ κ΅¬ν˜„ +β”‚ β”‚ └── res/ +β”‚ └── main/ # 곡톡 μ†ŒμŠ€ +``` + + +### Flavor ν™œμš© μ‹œλ‚˜λ¦¬μ˜€ + +#### πŸ§ͺ 개발 단계 (Mock) +- UI 개발 및 ν…ŒμŠ€νŠΈ +- λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 검증 +- λ„€νŠΈμ›Œν¬ 없이 λΉ λ₯Έ 반볡 개발 +- ν…ŒμŠ€νŠΈ λ°μ΄ν„°λ‘œ λ‹€μ–‘ν•œ μ‹œλ‚˜λ¦¬μ˜€ 검증 + +#### πŸš€ 배포 단계 (Prod) +- μ‹€μ œ μ„œλ²„μ™€μ˜ 연동 ν…ŒμŠ€νŠΈ +- μ„±λŠ₯ 및 μ•ˆμ •μ„± 검증 +- μ‹€μ œ μ‚¬μš©μž μ‹œλ‚˜λ¦¬μ˜€ ν…ŒμŠ€νŠΈ +- μŠ€ν† μ–΄ 배포용 λΉŒλ“œ + +## πŸ”„ CI/CD νŒŒμ΄ν”„λΌμΈ 흐름 + +### πŸ“± Slack λΉŒλ“œ μ•Œλ¦Ό μ˜ˆμ‹œ +![RunCombi Android λΉŒλ“œ 성곡 μ•Œλ¦Ό](./docs/images/slack_notification.png) + +### πŸ”„ QA 쀑심 CI/CD νŒŒμ΄ν”„λΌμΈ 흐름 + +``` +1. 개발자 μ½”λ“œ Push/PR 생성 + ↓ +2. GitHub Actions μžλ™ 트리거 + ↓ +3. μžλ™ λΉŒλ“œ μ‹€ν–‰ + ↓ +4. λΉŒλ“œ 성곡 μ‹œ: + β”œβ”€β”€ Firebase Distribution μžλ™ μ—…λ‘œλ“œ + β”œβ”€β”€ Slack #1-android μ±„λ„λ‘œ μ•Œλ¦Ό 전솑 + └── GitHub Release μžλ™ 생성 + ↓ +5. QA νŒ€ 및 ν…ŒμŠ€ν„°μ—κ²Œ μžλ™ 배포 + β”œβ”€β”€ Firebase μ½˜μ†”μ—μ„œ APK λ‹€μš΄λ‘œλ“œ + β”œβ”€β”€ ν…ŒμŠ€νŠΈ ν™˜κ²½μ—μ„œ κΈ°λŠ₯ 검증 + └── ν”Όλ“œλ°± μˆ˜μ§‘ 및 이슈 등둝 + ↓ +6. QA ν”Όλ“œλ°± 반영 및 재배포 + β”œβ”€β”€ 이슈 μˆ˜μ • 및 μ½”λ“œ κ°œμ„  + β”œβ”€β”€ μž¬λΉŒλ“œ 및 재배포 + └── μ΅œμ’… QA 승인 + ↓ +7. μŠ€ν† μ–΄ 배포 μ€€λΉ„ μ™„λ£Œ +``` + +## 🎨 λ·°λͺ¨λΈ 및 UI 둜직 νŒ¨ν„΄ + +### πŸ“± ViewModel νŒ¨ν„΄ + +#### κΈ°λ³Έ ꡬ쑰 +```kotlin +@HiltViewModel +class ExampleViewModel @Inject constructor( + private val useCase: ExampleUseCase, + private val repository: ExampleRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ExampleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _eventFlow = MutableSharedFlow() + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + fun handleAction(action: ExampleAction) { + viewModelScope.launch { + when (action) { + is ExampleAction.Load -> loadData() + is ExampleAction.Submit -> submitData(action.data) + } + } + } + + private suspend fun loadData() { + _uiState.update { it.copy(isLoading = true) } + + try { + val result = useCase.execute() + _uiState.update { + it.copy( + data = result, + isLoading = false + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + error = e.message, + isLoading = false + ) + } + } + } +} +``` + +#### UI μƒνƒœ 관리 +```kotlin +data class ExampleUiState( + val data: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val selectedItem: ExampleData? = null +) + +sealed class ExampleEvent { + object NavigateToDetail : ExampleEvent() + object ShowError : ExampleEvent() + data class ShowToast(val message: String) : ExampleEvent() +} +``` + +### 🎨 Compose UI νŒ¨ν„΄ + +#### ν™”λ©΄ ꡬ쑰 +```kotlin +@Composable +fun ExampleScreen( + onNavigate: (String) -> Unit, + viewModel: ExampleViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.handleAction(ExampleAction.Load) + } + + LaunchedEffect(Unit) { + viewModel.eventFlow.collectLatest { event -> + when (event) { + is ExampleEvent.NavigateToDetail -> onNavigate("detail") + is ExampleEvent.ShowError -> { /* μ—λŸ¬ 처리 */ } + is ExampleEvent.ShowToast -> { /* ν† μŠ€νŠΈ ν‘œμ‹œ */ } + } + } + } + + ExampleContent( + uiState = uiState, + onAction = viewModel::handleAction + ) +} +``` + +#### μ»΄ν¬λ„ŒνŠΈ ꡬ쑰 +```kotlin +@Composable +fun ExampleContent( + uiState: ExampleUiState, + onAction: (ExampleAction) -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when { + uiState.isLoading -> LoadingIndicator() + uiState.error != null -> ErrorContent( + error = uiState.error!!, + onRetry = { onAction(ExampleAction.Load) } + ) + else -> DataContent( + data = uiState.data, + onItemClick = { item -> + onAction(ExampleAction.SelectItem(item)) + } + ) + } + } +} +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b3307b6..03109c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.combo.runcombi" minSdk = 26 targetSdk = 35 - versionCode = 106 - versionName = "1.0.6" + versionCode = 107 + versionName = "1.0.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/core/designsystem/src/main/java/com/combo/runcombi/core/designsystem/component/AppTopBar.kt b/core/designsystem/src/main/java/com/combo/runcombi/core/designsystem/component/AppTopBar.kt index 90777a3..4997b92 100644 --- a/core/designsystem/src/main/java/com/combo/runcombi/core/designsystem/component/AppTopBar.kt +++ b/core/designsystem/src/main/java/com/combo/runcombi/core/designsystem/component/AppTopBar.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -25,6 +26,7 @@ fun RunCombiAppTopBar( title: String = "", onBack: () -> Unit = {}, onClose: () -> Unit = {}, + buttonTint: Color? = null, isVisibleBackBtn: Boolean = true, isVisibleCloseBtn: Boolean = false, padding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 8.dp), @@ -39,7 +41,8 @@ fun RunCombiAppTopBar( IconButton(onClick = onBack) { StableImage( drawableResId = R.drawable.ic_back, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), + tint = buttonTint ) } } else { @@ -60,7 +63,8 @@ fun RunCombiAppTopBar( IconButton(onClick = onClose) { StableImage( drawableResId = R.drawable.ic_close, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), + tint = buttonTint ) } } else { diff --git a/docs/images/slack_notification.png b/docs/images/slack_notification.png new file mode 100644 index 0000000..e7f88b1 Binary files /dev/null and b/docs/images/slack_notification.png differ diff --git a/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt b/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt index 43d6dde..c35b49b 100644 --- a/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt +++ b/feature/history/src/main/java/com/combo/runcombi/history/screen/RecordScreen.kt @@ -251,7 +251,8 @@ fun RecordContent( profileUrl = uiState.memberImageUrl, name = uiState.nickname, cal = uiState.memberCal, - description = getCalorieDescription(uiState.memberCal) + description = getMemberCalorieDescription(uiState.memberCal), + isPet = false ) } item { Spacer(modifier = Modifier.height(12.dp)) } @@ -260,7 +261,8 @@ fun RecordContent( profileUrl = petCalUi.petImageUrl, name = petCalUi.petName, cal = petCalUi.petCal, - description = getCalorieDescription(petCalUi.petCal) + description = getPetCalorieDescription(petCalUi.petCal), + isPet = true ) Spacer(modifier = Modifier.height(12.dp)) } @@ -509,6 +511,7 @@ fun CalorieProfileCard( name: String, cal: Int, description: String, + isPet: Boolean = false, ) { Card( modifier = Modifier @@ -530,10 +533,10 @@ fun CalorieProfileCard( ) { NetworkImage( imageUrl = profileUrl, + drawableResId = if (isPet) R.drawable.ic_pet_defalut else R.drawable.person_profile, modifier = Modifier .size(47.dp) .clip(RoundedCornerShape(2.dp)), - contentScale = ContentScale.Crop ) } @@ -671,7 +674,7 @@ fun RecordMemoSection(memo: String, onMemoChanged: () -> Unit, onAddMemo: () -> } -fun getCalorieDescription(cal: Int): String { +fun getMemberCalorieDescription(cal: Int): String { return when (cal) { in 0..49 -> "쑰금 μ›€μ§μ˜€μ–΄μš”!" in 50..99 -> "λ§‰λŒ€μ‚¬νƒ• ν•˜λ‚˜ νƒœμ› μ–΄μš”!" @@ -696,6 +699,32 @@ fun getCalorieDescription(cal: Int): String { } } +fun getPetCalorieDescription(cal: Int): String { + return when (cal) { + in 0..9 -> "쑰금 μ›€μ§μ˜€μ–΄μš”!" + in 10..19 -> "μ‚¬λ£Œ 10μ•Œ νƒœμ› μ–΄μš”!" + in 20..29 -> "μ‚¬λ£Œ 15μ•Œ νƒœμ› μ–΄μš”!" + in 30..39 -> "μ‚¬λ£Œ 20μ•Œ νƒœμ› μ–΄μš”!" + in 40..49 -> "μ‚¬λ£Œ 25μ•Œ νƒœμ› μ–΄μš”!" + in 50..59 -> "μ‚¬λ£Œ 30μ•Œ νƒœμ› μ–΄μš”!" + in 60..69 -> "μ‚¬λ£Œ 35μ•Œ νƒœμ› μ–΄μš”!" + in 70..79 -> "μ‚¬λ£Œ 40μ•Œ νƒœμ› μ–΄μš”!" + in 80..89 -> "μ‚¬λ£Œ 50μ•Œ νƒœμ› μ–΄μš”!" + in 90..99 -> "μ‚¬λ£Œ 55μ•Œ νƒœμ› μ–΄μš”!" + in 100..119 -> "μ‚¬λ£Œ 60μ•Œ νƒœμ› μ–΄μš”!" + in 120..139 -> "μ‚¬λ£Œ 70μ•Œ νƒœμ› μ–΄μš”!" + in 140..159 -> "μ‚¬λ£Œ 80μ•Œ νƒœμ› μ–΄μš”!" + in 160..179 -> "μ‚¬λ£Œ 100μ•Œ νƒœμ› μ–΄μš”!" + in 180..199 -> "μ‚¬λ£Œ 110μ•Œ νƒœμ› μ–΄μš”!" + in 200..249 -> "μ‚¬λ£Œ 130μ•Œ νƒœμ› μ–΄μš”!" + in 250..299 -> "μ‚¬λ£Œ 160μ•Œ νƒœμ› μ–΄μš”!" + in 300..349 -> "μ‚¬λ£Œ 180μ•Œ νƒœμ› μ–΄μš”!" + in 350..399 -> "μ‚¬λ£Œ 200μ•Œ νƒœμ› μ–΄μš”!" + in 400..Int.MAX_VALUE -> "μ‚¬λ£Œ ν•œ 쀌 νƒœμ› μ–΄μš”!" + else -> "μ‚¬λ£Œ ν•œ 쀌 νƒœμ› μ–΄μš”!" + } +} + @Preview(showBackground = true) @Composable fun PreviewRecordContent() { diff --git a/feature/main/src/main/java/com/combo/runcombi/main/component/MainTabContent.kt b/feature/main/src/main/java/com/combo/runcombi/main/component/MainTabContent.kt index 7ccdb26..58d5f6c 100644 --- a/feature/main/src/main/java/com/combo/runcombi/main/component/MainTabContent.kt +++ b/feature/main/src/main/java/com/combo/runcombi/main/component/MainTabContent.kt @@ -14,6 +14,7 @@ import com.combo.runcombi.main.navigation.MainNavigator import com.combo.runcombi.main.navigation.MainTabNavHost import com.combo.runcombi.main.navigation.MainTabNavigator import com.combo.runcombi.main.navigation.rememberMainTabNavigator +import com.combo.runcombi.walk.navigation.navigateToWalkMain @Composable fun MainTabContent( @@ -34,7 +35,13 @@ fun MainTabContent( currentDestination = backStackEntryState.value?.destination, onTabClick = { mainTab -> when (mainTab) { - MainTab.WALK -> mainTabNavigator.navigationToWalkMain() + MainTab.WALK -> mainTabNavigator.navigationToWalkMain(navOptions { + popUpTo(mainTabNavigator.navController.graph.id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }) MainTab.HISTORY -> mainTabNavigator.navigationToHistory() MainTab.My -> mainTabNavigator.navigationToSettingMain() } diff --git a/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavHost.kt b/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavHost.kt index cf58692..2adcc0a 100644 --- a/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavHost.kt +++ b/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavHost.kt @@ -10,6 +10,7 @@ import com.combo.runcombi.core.navigation.model.RouteModel import com.combo.runcombi.history.navigation.historyNavGraph import com.combo.runcombi.setting.navigation.navigateToSuggestion import com.combo.runcombi.setting.navigation.settingNavGraph +import com.combo.runcombi.walk.navigation.navigateToWalkMain import com.combo.runcombi.walk.navigation.walkNavGraph @Composable @@ -61,6 +62,14 @@ fun MainTabNavHost( inclusive = false } }) + }, + onClose = { + mainTabNavigator.navigationToWalkMain(navOptions = navOptions { + popUpTo(RouteModel.MainTabRoute.WalkRouteModel.WalkMain) { + inclusive = false + } + launchSingleTop = true + }) }) settingNavGraph( diff --git a/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavigator.kt b/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavigator.kt index 06ffe0e..729bb13 100644 --- a/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavigator.kt +++ b/feature/main/src/main/java/com/combo/runcombi/main/navigation/MainTabNavigator.kt @@ -111,8 +111,8 @@ class MainTabNavigator( ) } - fun navigationToWalkMain() { - navController.navigateToWalkMain() + fun navigationToWalkMain(navOptions: NavOptions? = null) { + navController.navigateToWalkMain(navOptions) } fun navigationToWalkTypeSelect() { diff --git a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AnnouncementScreen.kt b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AnnouncementScreen.kt index 5b51b5f..5853051 100644 --- a/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AnnouncementScreen.kt +++ b/feature/setting/src/main/java/com/combo/runcombi/setting/screen/AnnouncementScreen.kt @@ -158,10 +158,11 @@ fun TabItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier = modifier - .height(48.dp) - .clickable { onClick() } - .background(Grey01), + Box( + modifier = modifier + .height(48.dp) + .clickable { onClick() } + .background(Grey01), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally @@ -227,14 +228,15 @@ fun EventList( @Composable fun AnnouncementItem( - announcement: com.combo.runcombi.setting.model.Announcement, + announcement: Announcement, onClick: () -> Unit, ) { - Row(modifier = Modifier - .fillMaxWidth() - .height(88.dp) - .clickable { onClick() } - .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .clickable { onClick() } + .padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) { Column( modifier = Modifier.weight(1f) ) { @@ -258,7 +260,13 @@ fun AnnouncementItem( Spacer(modifier = Modifier.height(4.dp)) Text( - text = FormatUtils.formatDate(announcement.regDate), style = body3, color = Grey06 + text = if (announcement.announcementType == "NOTICE") FormatUtils.formatDate( + announcement.regDate + ) else "${FormatUtils.formatDate(announcement.startDate)} ~ ${ + FormatUtils.formatDate( + announcement.endDate + ) + }", style = body3, color = Grey06 ) } } diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt index 65e2105..cd9a82e 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/navigation/WalkNavigation.kt @@ -18,13 +18,7 @@ import com.combo.runcombi.walk.screen.WalkTypeSelectScreen import com.combo.runcombi.walk.viewmodel.WalkMainViewModel fun NavController.navigateToWalkMain( - navOptions: NavOptions? = androidx.navigation.navOptions { - popUpTo(this@navigateToWalkMain.graph.id) { - saveState = true - } - launchSingleTop = true - restoreState = true - }, + navOptions: NavOptions?, ) { this.navigate(MainTabDataModel.Walk, navOptions) } @@ -63,6 +57,7 @@ fun NavGraphBuilder.walkNavGraph( onCountdownFinished: () -> Unit, onFinish: () -> Unit, onBack: () -> Unit, + onClose: () -> Unit, onNavigateToRecord: (Int) -> Unit, ) { navigation( @@ -95,6 +90,7 @@ fun NavGraphBuilder.walkNavGraph( composable { WalkReadyScreen( onBack = onBack, + onClose = onClose, onCompleteReady = onCompleteReady, ) } diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkReadyScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkReadyScreen.kt index b6f7ab9..79610c1 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkReadyScreen.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkReadyScreen.kt @@ -32,11 +32,16 @@ import com.combo.runcombi.ui.ext.clickableSingle @Composable fun WalkReadyScreen( onBack: () -> Unit, + onClose: () -> Unit, onCompleteReady: () -> Unit, ) { Column(modifier = Modifier.background(color = Grey01)) { RunCombiAppTopBar( - isVisibleBackBtn = true, onBack = onBack + isVisibleBackBtn = true, + isVisibleCloseBtn = true, + buttonTint = Color.White, + onBack = onBack, + onClose = onClose ) Spacer(modifier = Modifier.height(50.dp)) WalkReadyContent(onCompleteReady = onCompleteReady) @@ -81,7 +86,7 @@ private fun WalkReadyContent( } } -@Preview(showBackground = true) +@Preview(showBackground = true, backgroundColor = 0xFF1c1c1c) @Composable private fun WalkReadyContentPreview() { WalkReadyContent( diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkResultScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkResultScreen.kt index 6b73429..470a62d 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkResultScreen.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkResultScreen.kt @@ -182,7 +182,9 @@ fun WalkResultScreen( pathPoints = walkData.pathPoints, isFirstRun = startRunData?.isFirstRun == "Y", nthRun = startRunData?.nthRun ?: 0, - onBack = onBack, + onBack = { + onNavigateToRecord(walkData.runData?.runId ?: 0) + }, showCaptureRequest = showCaptureRequest.value, onCaptured = { bitmap -> val file = BitmapUtil.bitmapToFile( diff --git a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt index eaccf71..da04866 100644 --- a/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt +++ b/feature/walk/src/main/java/com/combo/runcombi/walk/screen/WalkTrackingScreen.kt @@ -1,8 +1,3 @@ -@file:OptIn( - ExperimentalFoundationApi::class, ExperimentalFoundationApi::class, - ExperimentalFoundationApi::class, ExperimentalFoundationApi::class -) - package com.combo.runcombi.walk.screen import android.annotation.SuppressLint @@ -305,6 +300,7 @@ fun ResumeButton(onClick: () -> Unit) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun FinishButtonLongPress(onLongClick: () -> Unit) { Box(