diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ddc88d7..5b58508 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + id("dororong.rodi.android.hilt") } val localProperties = Properties().apply { @@ -60,8 +61,10 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.hilt.android) implementation(libs.kakao.maps) implementation(libs.kakao.navi) + ksp(libs.hilt.compiler) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/com/dororong/rodi/MainActivity.kt b/app/src/main/java/com/dororong/rodi/MainActivity.kt index a9bbfcd..52dcdd5 100644 --- a/app/src/main/java/com/dororong/rodi/MainActivity.kt +++ b/app/src/main/java/com/dororong/rodi/MainActivity.kt @@ -6,9 +6,11 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.dororong.rodi.ui.AppRoot import com.dororong.rodi.core.ui.theme.RodiTheme +import com.dororong.rodi.ui.AppRoot +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/com/dororong/rodi/RodiApplication.kt b/app/src/main/java/com/dororong/rodi/RodiApplication.kt index 276ca61..02ec220 100644 --- a/app/src/main/java/com/dororong/rodi/RodiApplication.kt +++ b/app/src/main/java/com/dororong/rodi/RodiApplication.kt @@ -3,7 +3,9 @@ package com.dororong.rodi import android.app.Application import com.kakao.sdk.common.KakaoSdk import com.kakao.vectormap.KakaoMapSdk +import dagger.hilt.android.HiltAndroidApp +@HiltAndroidApp class RodiApplication : Application() { override fun onCreate() { super.onCreate() diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index c9fee15..6c47701 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -7,6 +7,8 @@ group = "com.dororong.rodi.buildlogic" dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.compose.gradlePlugin) + compileOnly(libs.hilt.gradlePlugin) + compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) } @@ -24,6 +26,10 @@ gradlePlugin { id = "dororong.rodi.android.library.compose" implementationClass = "AndroidLibraryComposeConventionPlugin" } + register("androidHilt") { + id = "dororong.rodi.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" + } register("jvmLibrary") { id = "dororong.rodi.jvm.library" implementationClass = "JvmLibraryConventionPlugin" diff --git a/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt new file mode 100644 index 0000000..19b2afc --- /dev/null +++ b/build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -0,0 +1,11 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target.pluginManager) { + apply("com.google.devtools.ksp") + apply("com.google.dagger.hilt.android") + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..7b78f3c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.compose) apply false } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a3639be..4b9320e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -2,6 +2,7 @@ import java.util.Properties plugins { id("dororong.rodi.android.library") + id("dororong.rodi.android.hilt") } val localProperties = Properties().apply { @@ -24,6 +25,8 @@ dependencies { implementation(project(":core:common")) implementation(project(":core:domain")) implementation(libs.androidx.datastore.preferences) + implementation(libs.hilt.android) implementation(libs.kakao.maps) implementation(libs.kotlinx.coroutines.android) + ksp(libs.hilt.compiler) } diff --git a/core/data/src/main/java/com/dororong/rodi/core/data/CourseRepository.kt b/core/data/src/main/java/com/dororong/rodi/core/data/CourseRepository.kt new file mode 100644 index 0000000..5436036 --- /dev/null +++ b/core/data/src/main/java/com/dororong/rodi/core/data/CourseRepository.kt @@ -0,0 +1,16 @@ +package com.dororong.rodi.core.data + +import com.dororong.rodi.core.data.directions.KakaoDirectionsClient +import com.dororong.rodi.core.domain.Course +import javax.inject.Inject + +interface CourseRepository { + fun getCourses(): List + suspend fun getRoute(course: Course): KakaoDirectionsClient.RouteResult +} + +class CourseRepositoryImpl @Inject constructor() : CourseRepository { + override fun getCourses(): List = SampleCourses.RODI_COURSES + override suspend fun getRoute(course: Course): KakaoDirectionsClient.RouteResult = + KakaoDirectionsClient.getRoute(course) +} diff --git a/core/data/src/main/java/com/dororong/rodi/core/data/EntryRepository.kt b/core/data/src/main/java/com/dororong/rodi/core/data/EntryRepository.kt new file mode 100644 index 0000000..3ac7331 --- /dev/null +++ b/core/data/src/main/java/com/dororong/rodi/core/data/EntryRepository.kt @@ -0,0 +1,21 @@ +package com.dororong.rodi.core.data + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface EntryRepository { + val isCompleted: Flow + suspend fun setCompleted() +} + +class EntryRepositoryImpl @Inject constructor( + @ApplicationContext context: Context, +) : EntryRepository { + private val prefs = EntryPreferences(context) + + override val isCompleted: Flow = prefs.isCompleted + + override suspend fun setCompleted() = prefs.setCompleted() +} diff --git a/core/data/src/main/java/com/dororong/rodi/core/data/di/DataModule.kt b/core/data/src/main/java/com/dororong/rodi/core/data/di/DataModule.kt new file mode 100644 index 0000000..8d5c969 --- /dev/null +++ b/core/data/src/main/java/com/dororong/rodi/core/data/di/DataModule.kt @@ -0,0 +1,25 @@ +package com.dororong.rodi.core.data.di + +import com.dororong.rodi.core.data.CourseRepository +import com.dororong.rodi.core.data.CourseRepositoryImpl +import com.dororong.rodi.core.data.EntryRepository +import com.dororong.rodi.core.data.EntryRepositoryImpl +import com.dororong.rodi.core.data.navi.NaviPreferenceRepository +import com.dororong.rodi.core.data.navi.NaviPreferenceRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + @Binds + abstract fun bindCourseRepository(impl: CourseRepositoryImpl): CourseRepository + + @Binds + abstract fun bindNaviPreferenceRepository(impl: NaviPreferenceRepositoryImpl): NaviPreferenceRepository + + @Binds + abstract fun bindEntryRepository(impl: EntryRepositoryImpl): EntryRepository +} diff --git a/core/data/src/main/java/com/dororong/rodi/core/data/navi/NaviPreferenceRepository.kt b/core/data/src/main/java/com/dororong/rodi/core/data/navi/NaviPreferenceRepository.kt new file mode 100644 index 0000000..4c3ea0e --- /dev/null +++ b/core/data/src/main/java/com/dororong/rodi/core/data/navi/NaviPreferenceRepository.kt @@ -0,0 +1,17 @@ +package com.dororong.rodi.core.data.navi + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +interface NaviPreferenceRepository { + fun getAlways(): NaviApp? + fun setAlways(app: NaviApp) +} + +class NaviPreferenceRepositoryImpl @Inject constructor( + @param:ApplicationContext private val context: Context, +) : NaviPreferenceRepository { + override fun getAlways(): NaviApp? = NaviPreference.getAlways(context) + override fun setAlways(app: NaviApp) = NaviPreference.setAlways(context, app) +} diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 998aed4..bad4aea 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4,6 +4,9 @@ > 한 줄씩 누적하고, 착수 시 `docs/handoff/HANDOFF.md`로 옮겨 작업한다. ## 열린 항목 +- [ ] **Repository 인터페이스 domain 이동 검토** — 현재 `RouteResult`(Kakao `LatLng` 포함)와 `NaviApp`이 + `core:data` 타입이라 Hilt 도입 작업에서는 Repository 인터페이스/구현체를 함께 `core:data`에 둠. + domain purity를 위해 vendor 타입 분리 후 `core:domain` 이동 여부를 별도 작업으로 검토. - [ ] **Kotlin 2.2.10 → 2.4.0 / AGP 버전 업그레이드** — Google Maven 기준 Kotlin 최신 안정은 2.4.0, AGP는 현재 프로젝트(9.2.1)가 이미 공개 릴리스 노트보다 앞서 있음. 컴파일러 호환성(compose compiler, KSP 등) 검증이 필요해 Java 21 통일 작업(2026-07-01)에서 범위 밖으로 뺌. diff --git a/docs/handoff/archive/20260702-0259-HANDOFF.md b/docs/handoff/archive/20260702-0259-HANDOFF.md new file mode 100644 index 0000000..addbe66 --- /dev/null +++ b/docs/handoff/archive/20260702-0259-HANDOFF.md @@ -0,0 +1,298 @@ +# HANDOFF — Hilt 도입 + Repository 계층 분리 (Home/Entry) + +> Claude(기획)와 Codex(구현)가 주고받는 **단일 활성 작업 채널**. +> 완료되면 `docs/handoff/archive/<날짜>-<작업>.md`로 옮기고 이 파일은 다음 작업으로 비운다. + +Status: DONE +Branch: feat/hilt-di + +## Context (왜) +`HomeViewModel`/`EntryViewModel`이 `SampleCourses.RODI_COURSES`, `KakaoDirectionsClient`(object), +`NaviPreference`(object, Context 직접 요구), `EntryPreferences(context)`를 코드 안에서 직접 +생성·참조하고 있다. 이 상태로는 단위 테스트에서 대체(mock/fake) 불가능하고, 향후 서버 연동 +시(별도 논의 완료 — 코스/주차장 API 전환, 사용자 경로 추가 기능) 데이터 소스 교체 지점이 +불명확하다. Hilt로 DI 배선하고, 그 경계로 Repository 인터페이스를 도입한다. + +이 작업은 `refactor/home-mvi-contract`(PR #12) 위에서 분기했다 — `HomeContract.kt`의 +`HomeIntent`/`HomeEffect` 구조가 이미 정리되어 있어야 어디에 Repository를 주입할지 명확하기 +때문이다. PR #12가 먼저 `develop`에 머지되면 이 브랜치도 그에 맞춰 정리한다. + +## Spec (무엇을·어떻게) + +### 1. 버전 카탈로그 — `gradle/libs.versions.toml` + +`[versions]`에 추가 (Codex가 실제 최신 안정 버전을 확인해서 적용 — 아래는 참고값): +```toml +hilt = "2.57.2" +ksp = "2.2.10-2.0.2" # 프로젝트 kotlin(2.2.10)에 대응하는 KSP 버전으로 맞출 것 +``` + +`[libraries]`에 추가 (기존 `android-gradlePlugin`/`compose-gradlePlugin` 항목과 같은 패턴): +```toml +hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } +``` + +### 2. `build-logic` — Hilt Convention Plugin 신설 + +`build-logic/build.gradle.kts`의 `dependencies` 블록에 추가: +```kotlin +compileOnly(libs.hilt.gradlePlugin) +compileOnly(libs.ksp.gradlePlugin) +``` + +`build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt` 신규 생성 +(`AndroidLibraryComposeConventionPlugin.kt`와 동일한 스타일 — 플러그인만 적용, 의존성은 +각 모듈 build.gradle.kts에서 명시): +```kotlin +import org.gradle.api.Plugin +import org.gradle.api.Project + +class AndroidHiltConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target.pluginManager) { + apply("com.google.devtools.ksp") + apply("com.google.dagger.hilt.android") + } + } +} +``` + +`build-logic/build.gradle.kts`의 `gradlePlugin { plugins { ... } }` 블록에 추가: +```kotlin +register("androidHilt") { + id = "dororong.rodi.android.hilt" + implementationClass = "AndroidHiltConventionPlugin" +} +``` + +### 3. Repository 계층 — `core:data`에 인터페이스+구현체 함께 배치 + +**중요한 스코프 결정**: 이상적으로는 Repository 인터페이스를 `core:domain`에 두는 것이지만, +`RouteResult`(카카오 SDK `LatLng` 포함)와 `NaviApp`이 이미 `core:data`에 있어 domain-purity를 +지키려면 이 타입들도 함께 옮겨야 한다 — 스코프가 급격히 커진다. **이번 작업에서는 Repository +인터페이스와 구현체를 모두 `core:data`에 두는 것으로 타협한다.** domain 순수 분리는 별도 +기술부채로 `docs/BACKLOG.md`에 한 줄 추가할 것 (아래 Out of scope 참고). + +`core/data/src/main/java/com/dororong/rodi/core/data/` 아래 신규 파일: + +**`CourseRepository.kt`** +```kotlin +interface CourseRepository { + fun getCourses(): List + suspend fun getRoute(course: Course): KakaoDirectionsClient.RouteResult +} + +class CourseRepositoryImpl @Inject constructor() : CourseRepository { + override fun getCourses(): List = SampleCourses.RODI_COURSES + override suspend fun getRoute(course: Course) = KakaoDirectionsClient.getRoute(course) +} +``` +(`SampleCourses`/`KakaoDirectionsClient` object는 그대로 두고 Repository가 감싸기만 한다 — +이번 스코프는 "주입 가능하게 만들기"이지 데이터 소스 자체의 재작성이 아니다.) + +**`navi/NaviPreferenceRepository.kt`** (기존 `navi/NaviPreference.kt` 옆에 추가) +```kotlin +interface NaviPreferenceRepository { + fun getAlways(): NaviApp? + fun setAlways(app: NaviApp) +} + +class NaviPreferenceRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context +) : NaviPreferenceRepository { + override fun getAlways(): NaviApp? = NaviPreference.getAlways(context) + override fun setAlways(app: NaviApp) = NaviPreference.setAlways(context, app) +} +``` + +**`EntryRepository.kt`** +```kotlin +interface EntryRepository { + val isCompleted: Flow + suspend fun setCompleted() +} + +class EntryRepositoryImpl @Inject constructor( + @ApplicationContext context: Context +) : EntryRepository { + private val prefs = EntryPreferences(context) + override val isCompleted: Flow = prefs.isCompleted + override suspend fun setCompleted() = prefs.setCompleted() +} +``` + +**`di/DataModule.kt`** (신규 패키지 `core.data.di`) +```kotlin +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + @Binds abstract fun bindCourseRepository(impl: CourseRepositoryImpl): CourseRepository + @Binds abstract fun bindNaviPreferenceRepository(impl: NaviPreferenceRepositoryImpl): NaviPreferenceRepository + @Binds abstract fun bindEntryRepository(impl: EntryRepositoryImpl): EntryRepository +} +``` + +`core/data/build.gradle.kts`를 `dororong.rodi.android.hilt` 플러그인 추가 + 의존성: +```kotlin +plugins { + id("dororong.rodi.android.library") + id("dororong.rodi.android.hilt") // 추가 +} +dependencies { + // 기존 유지 + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} +``` + +### 4. `app` 모듈 — Hilt 진입점 + +`app/build.gradle.kts`: `dororong.rodi.android.hilt` 플러그인 추가, `hilt-android`/`hilt-compiler` +의존성 추가 (core:data와 동일 패턴). + +`RodiApplication.kt`: +```kotlin +@HiltAndroidApp +class RodiApplication : Application() { ... } // 기존 onCreate 로직 유지 +``` + +`MainActivity.kt`에 `@AndroidEntryPoint` 추가 (기존 로직 유지). + +### 5. `feature:home` — `HomeViewModel` Hilt 전환 + Contract 단순화 + +`feature/home/build.gradle.kts`: `dororong.rodi.android.hilt` 플러그인 + `hilt-android`/ +`hilt-compiler` + `androidx-hilt-navigation-compose` 의존성 추가. + +`HomeViewModel.kt`: +```kotlin +@HiltViewModel +class HomeViewModel @Inject constructor( + private val courseRepository: CourseRepository, + private val naviPreferenceRepository: NaviPreferenceRepository, +) : ViewModel() { + data class UiState( + val courses: List = emptyList(), // init 블록 또는 courseRepository.getCourses()로 초기화 + ... // 나머지 필드 동일 + ) + // onCourseClick 내부: KakaoDirectionsClient.getRoute(course) → courseRepository.getRoute(course) +} +``` +`UiState()`의 기본값이 `SampleCourses.RODI_COURSES`를 직접 참조하던 걸 제거하고, ViewModel의 +`init { }` 블록 또는 `_state = MutableStateFlow(UiState(courses = courseRepository.getCourses()))` +형태로 주입받은 Repository를 통해 채운다. + +**Contract 단순화 (Hilt 도입으로 가능해짐)**: +- `HomeIntent.OnNavigateClick`에서 `savedApp: NaviApp?` 파라미터 제거 — ViewModel이 + `naviPreferenceRepository.getAlways()`로 직접 조회 가능해졌기 때문. `kakaoMapInstalled`/ + `kakaoNaviInstalled`는 `PackageManager` 필요라 Context 의존이 여전하므로 Composable이 계산해서 + 넘기는 것 그대로 유지. +- `HomeEffect.SaveNaviPreference` 케이스 제거 — `onNaviAppSelected`에서 ViewModel이 직접 + `naviPreferenceRepository.setAlways(app)` 호출. `HomeScreen.kt`의 Effect 수집 `when`에서 + 해당 분기도 제거. +- `HomeScreen.kt`의 `navigate()` 로컬 함수에서 `NaviPreference.getAlways(context)` 호출 제거, + `vm.onIntent(HomeIntent.OnNavigateClick(course, kakaoMapInstalled, kakaoNaviInstalled))`로 + 변경 (savedApp 인자 삭제). +- `HomeScreen(vm: HomeViewModel = viewModel())` → `HomeScreen(vm: HomeViewModel = hiltViewModel())`. + +### 6. `feature:entry` — `EntryViewModel` Hilt 전환 + +`feature/entry/build.gradle.kts`: `dororong.rodi.android.hilt` 플러그인 + 의존성 추가. + +`EntryViewModel.kt`: +```kotlin +@HiltViewModel +class EntryViewModel @Inject constructor( + private val entryRepository: EntryRepository, +) : ViewModel() { // AndroidViewModel(app) → ViewModel()로 변경 + // complete()에서 prefs.setCompleted() → entryRepository.setCompleted() +} +``` +`EntryFlow.kt`: `viewModel: EntryViewModel = viewModel()` → `= hiltViewModel()`. + +## Files to touch +- `gradle/libs.versions.toml` +- `build-logic/build.gradle.kts`, `build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt`(신규) +- `app/build.gradle.kts`, `app/src/main/java/com/dororong/rodi/RodiApplication.kt`, + `app/src/main/java/com/dororong/rodi/MainActivity.kt` +- `core/data/build.gradle.kts`, + `core/data/src/main/java/com/dororong/rodi/core/data/CourseRepository.kt`(신규), + `core/data/src/main/java/com/dororong/rodi/core/data/navi/NaviPreferenceRepository.kt`(신규), + `core/data/src/main/java/com/dororong/rodi/core/data/EntryRepository.kt`(신규), + `core/data/src/main/java/com/dororong/rodi/core/data/di/DataModule.kt`(신규) +- `feature/home/build.gradle.kts`, + `feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt`, + `feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt`, + `feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt` +- `feature/entry/build.gradle.kts`, + `feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt`, + `feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt` + +## Acceptance criteria +- [ ] `RodiApplication`에 `@HiltAndroidApp`, `MainActivity`에 `@AndroidEntryPoint` +- [ ] `CourseRepository`/`NaviPreferenceRepository`/`EntryRepository` 인터페이스+구현체+`@Binds` + Hilt Module 존재, 전부 `core:data`에 위치 +- [ ] `HomeViewModel`/`EntryViewModel`이 `@HiltViewModel` + `@Inject constructor`로 전환, + 더 이상 `SampleCourses`/`KakaoDirectionsClient`/`NaviPreference`/`EntryPreferences`를 + 직접 참조하지 않고 Repository를 통해서만 접근 +- [ ] `HomeScreen`/`EntryFlow`가 `viewModel()` 대신 `hiltViewModel()` 사용 +- [ ] `HomeIntent.OnNavigateClick`에서 `savedApp` 파라미터 제거, `HomeEffect.SaveNaviPreference` + 케이스 제거 (ViewModel이 NaviPreferenceRepository로 직접 처리) +- [ ] 기존 동작 변화 없음 — 코스 목록 표시, 경로 안내, 내비 앱 선택/저장, 진입 게이트 플로우 + 전부 이전과 동일하게 동작 (에뮬레이터로 확인) +- [ ] `./gradlew assembleDebug` 성공 +- [ ] `./gradlew lint` 경고 없음 + +## Verification +```bash +./gradlew assembleDebug +./gradlew lint +``` +빌드 성공 후 에뮬레이터에서: +- 앱 최초 실행 시 진입 게이트(위치권한→약관→주의사항) 정상 동작, 완료 후 홈 진입 +- 앱 재실행 시 게이트 스킵하고 바로 홈 (EntryRepository.isCompleted 정상 동작 확인) +- 코스 클릭 → 상세 → 경로 안내 → 내비 앱 선택 → "항상" 선택 후 재선택 시 저장된 앱으로 바로 + 실행되는지 (NaviPreferenceRepository 정상 동작 확인) + +## Out of scope +- Repository 인터페이스를 `core:domain`으로 옮기는 것(domain purity) — `RouteResult`/`NaviApp`이 + vendor 타입을 포함하고 있어 스코프가 커짐. `docs/BACKLOG.md`에 기술부채로 기록할 것. +- `SampleCourses`/`KakaoDirectionsClient` 자체를 서버 API 연동으로 교체 — 별도 논의된 서버 연동 + 작업(추후 HANDOFF)에서 진행. 이번엔 기존 로직을 Repository로 감싸기만 한다. +- UseCase 계층 추가 — Repository 직접 호출로 충분히 얇은 지금 규모에서는 불필요 (스킬 + `android-architecture`의 "Option A: Domain 모델 직접 사용, 소규모" 기준 적용). +- `AppRoot.kt`(app 모듈)이 `EntryPreferences(context)`를 직접 생성해 구독하는 부분 — Composable + 최상위라 ViewModel이 없어 Hilt EntryPoint 접근이 필요한데, 이번 스코프에서는 건드리지 않는다. +- Baseline Profile, Geofencing/WorkManager 등 — 별도 작업. + +--- +## Codex Result +- Changed files: gradle/libs.versions.toml, build.gradle.kts, gradle.properties, build-logic/build.gradle.kts, build-logic/src/main/kotlin/AndroidHiltConventionPlugin.kt, app/build.gradle.kts, app/src/main/java/com/dororong/rodi/RodiApplication.kt, app/src/main/java/com/dororong/rodi/MainActivity.kt, core/data/build.gradle.kts, core/data/src/main/java/com/dororong/rodi/core/data/CourseRepository.kt, core/data/src/main/java/com/dororong/rodi/core/data/EntryRepository.kt, core/data/src/main/java/com/dororong/rodi/core/data/navi/NaviPreferenceRepository.kt, core/data/src/main/java/com/dororong/rodi/core/data/di/DataModule.kt, feature/home/build.gradle.kts, feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt, feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt, feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt, feature/entry/build.gradle.kts, feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt, feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt, docs/BACKLOG.md +- Build/test: ./gradlew assembleDebug GREEN; ./gradlew lint GREEN; emulator manual QA not run in this session +- Open questions: none + +--- +## Claude Review +- Blocking: 없음 +- Nits: + - `gradle.properties`에 `android.disallowKotlinSourceSets=false`가 추가됐는데 Spec/Files to + touch에 없고 이유가 기록돼 있지 않다. 실제로 필요했다면(KSP/Hilt 관련 빌드 에러 회피 등) + 한 줄 근거를 커밋 메시지나 BACKLOG에 남겨두는 게 좋다. + - `HomeScreen.kt`의 `LaunchedEffect` `when(effect)` 블록에서 `SaveNaviPreference` 분기 제거 후 + 빈 줄이 하나 남았다 (`OpenNaviInstallPage` 분기 다음). 사소하지만 정리 가능. + - Acceptance의 "에뮬레이터 확인"과 `./gradlew assembleDebug`/`lint` 재실행은 이 리뷰 세션에서 + 직접 재검증하지 못했다(샌드박스에서 gradlew 실행 승인이 막힘) — Codex 보고(GREEN)를 신뢰했다. + 병합 전 최소 한 번은 사람이 직접 빌드+에뮬레이터 QA로 확인 권장. +- Verdict: APPROVE + + Spec의 6개 파트(버전 카탈로그, build-logic Hilt convention plugin, core:data Repository 3종 + + DataModule, app 진입점, HomeViewModel/Contract/Screen, EntryViewModel/Flow)가 모두 diff에 + 1:1로 반영되어 있다. Repository 인터페이스+구현체가 spec대로 `core:data`에 위치하고 + `@Binds` DataModule도 정확. `HomeIntent.OnNavigateClick.savedApp`/`HomeEffect.SaveNaviPreference` + 제거와 ViewModel의 `NaviPreferenceRepository` 직접 조회/저장 전환도 Acceptance criteria와 일치. + `HomeScreen`/`EntryFlow` 모두 `hiltViewModel()`로 전환됨. domain-purity 보류 건도 BACKLOG.md에 + 기록됨(Out of scope 대로). 토큰 하드코딩·Material 아이콘·시크릿 노출 없음. 위 Nits는 병합을 + 막을 정도는 아니라 APPROVE. diff --git a/feature/entry/build.gradle.kts b/feature/entry/build.gradle.kts index e61edaa..bfe5834 100644 --- a/feature/entry/build.gradle.kts +++ b/feature/entry/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("dororong.rodi.android.library.compose") + id("dororong.rodi.android.hilt") } android { @@ -15,6 +16,9 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) implementation(libs.androidx.lifecycle.viewmodel.compose) + ksp(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt b/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt index e0c5ea4..de05d4c 100644 --- a/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt +++ b/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryFlow.kt @@ -8,7 +8,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.dororong.rodi.core.ui.terms.TermsWebView /** @@ -18,7 +18,7 @@ import com.dororong.rodi.core.ui.terms.TermsWebView @Composable fun EntryFlow( onComplete: () -> Unit, - viewModel: EntryViewModel = viewModel(), + viewModel: EntryViewModel = hiltViewModel(), ) { val step = viewModel.step diff --git a/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt b/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt index 4789f62..1646696 100644 --- a/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt +++ b/feature/entry/src/main/java/com/dororong/rodi/feature/entry/EntryViewModel.kt @@ -1,22 +1,24 @@ package com.dororong.rodi.feature.entry -import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dororong.rodi.core.data.EntryPreferences +import com.dororong.rodi.core.data.EntryRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import javax.inject.Inject enum class EntryStep { LOCATION, TERMS, PRECAUTIONS, TERMS_WEBVIEW } /** * 진입 게이트 단계 상태 머신. 마지막 단계 완료 시 DataStore에 완료를 저장하고 [onDone] 호출. */ -class EntryViewModel(app: Application) : AndroidViewModel(app) { - - private val prefs = EntryPreferences(app) +@HiltViewModel +class EntryViewModel @Inject constructor( + private val entryRepository: EntryRepository, +) : ViewModel() { var step by mutableStateOf(EntryStep.LOCATION) private set @@ -99,7 +101,7 @@ class EntryViewModel(app: Application) : AndroidViewModel(app) { fun complete(onDone: () -> Unit) { viewModelScope.launch { - prefs.setCompleted() + entryRepository.setCompleted() onDone() } } diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 526af44..3b5893d 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("dororong.rodi.android.library.compose") + id("dororong.rodi.android.hilt") } android { @@ -18,8 +19,11 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) implementation(libs.kakao.maps) implementation(libs.kakao.navi) implementation(libs.play.services.location) + ksp(libs.hilt.compiler) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt index dc77b7b..8c62154 100644 --- a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt +++ b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeContract.kt @@ -9,21 +9,14 @@ sealed interface HomeIntent { data class OnDistanceFilterChange(val km: Int?) : HomeIntent data class OnLocationUpdate(val lat: Double, val lng: Double) : HomeIntent - /** - * 경로 안내 버튼 클릭. 설치 여부/저장된 선호 앱 조회는 Context가 필요해 Composable이 - * 미리 계산해서 함께 넘긴다 — ViewModel은 순수 분기 로직만 담당한다. - */ data class OnNavigateClick( val course: Course, - val savedApp: NaviApp?, val kakaoMapInstalled: Boolean, val kakaoNaviInstalled: Boolean, ) : HomeIntent - /** NaviPickerSheet(SELECT 모드)에서 앱을 골랐을 때. */ data class OnNaviAppSelected(val app: NaviApp, val course: Course, val always: Boolean) : HomeIntent - /** NaviPickerSheet(INSTALL 모드)에서 앱을 골랐을 때(설치 페이지로 이동). */ data class OnInstallNaviAppSelected(val app: NaviApp) : HomeIntent } @@ -33,5 +26,4 @@ sealed interface HomeEffect { data class ShowNaviPicker(val course: Course) : HomeEffect data class ShowInstallNaviPicker(val course: Course) : HomeEffect data class OpenNaviInstallPage(val app: NaviApp) : HomeEffect - data class SaveNaviPreference(val app: NaviApp) : HomeEffect } diff --git a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt index 463b855..3d09fd0 100644 --- a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeScreen.kt @@ -59,9 +59,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.dororong.rodi.core.data.navi.NaviApp -import com.dororong.rodi.core.data.navi.NaviPreference import com.dororong.rodi.core.domain.Course import com.dororong.rodi.core.ui.terms.TermsDocument import com.dororong.rodi.core.ui.terms.TermsWebView @@ -91,7 +90,7 @@ private const val PARKING_FOCUS_ZOOM = 15 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(vm: HomeViewModel = viewModel()) { +fun HomeScreen(vm: HomeViewModel = hiltViewModel()) { val context = LocalContext.current val state by vm.state.collectAsState() val coroutineScope = rememberCoroutineScope() @@ -225,7 +224,6 @@ fun HomeScreen(vm: HomeViewModel = viewModel()) { NaviApp.KAKAONAVI -> KakaoNaviLauncher.openInstallPage(context) } - is HomeEffect.SaveNaviPreference -> NaviPreference.setAlways(context, effect.app) } } } @@ -438,7 +436,6 @@ fun HomeScreen(vm: HomeViewModel = viewModel()) { coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() } } val navigate: () -> Unit = { - val saved = NaviPreference.getAlways(context) val kakaoMapInstalled = runCatching { context.packageManager.getPackageInfo("net.daum.android.map", 0); true }.getOrDefault(false) @@ -448,7 +445,6 @@ fun HomeScreen(vm: HomeViewModel = viewModel()) { vm.onIntent( HomeIntent.OnNavigateClick( course = selectedCourse, - savedApp = saved, kakaoMapInstalled = kakaoMapInstalled, kakaoNaviInstalled = kakaoNaviInstalled, ), diff --git a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt index d1b6c3a..2412b45 100644 --- a/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/dororong/rodi/feature/home/HomeViewModel.kt @@ -2,11 +2,12 @@ package com.dororong.rodi.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.dororong.rodi.core.data.SampleCourses -import com.dororong.rodi.core.data.directions.KakaoDirectionsClient +import com.dororong.rodi.core.data.CourseRepository import com.dororong.rodi.core.data.directions.KakaoDirectionsClient.RouteResult import com.dororong.rodi.core.data.navi.NaviApp +import com.dororong.rodi.core.data.navi.NaviPreferenceRepository import com.dororong.rodi.core.domain.Course +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,11 +21,16 @@ import kotlin.math.cos import kotlin.math.pow import kotlin.math.sin import kotlin.math.sqrt +import javax.inject.Inject -class HomeViewModel : ViewModel() { +@HiltViewModel +class HomeViewModel @Inject constructor( + private val courseRepository: CourseRepository, + private val naviPreferenceRepository: NaviPreferenceRepository, +) : ViewModel() { data class UiState( - val courses: List = SampleCourses.RODI_COURSES, + val courses: List = emptyList(), val selectedCourseId: Int? = null, val routeByCourse: Map = emptyMap(), val routingCourseIds: Set = emptySet(), @@ -48,7 +54,7 @@ class HomeViewModel : ViewModel() { } } - private val _state = MutableStateFlow(UiState()) + private val _state = MutableStateFlow(UiState(courses = courseRepository.getCourses())) val state: StateFlow = _state.asStateFlow() private val _effect = Channel(Channel.BUFFERED) @@ -80,7 +86,7 @@ class HomeViewModel : ViewModel() { if (course.isParking) return _state.update { it.copy(routingCourseIds = it.routingCourseIds + id) } viewModelScope.launch { - val result = KakaoDirectionsClient.getRoute(course) + val result = courseRepository.getRoute(course) _state.update { it.copy( routeByCourse = it.routeByCourse + (id to result), @@ -100,11 +106,12 @@ class HomeViewModel : ViewModel() { private fun onNavigateClick(intent: HomeIntent.OnNavigateClick) { viewModelScope.launch { + val savedApp = naviPreferenceRepository.getAlways() when { - intent.savedApp == NaviApp.KAKAOMAP && intent.kakaoMapInstalled -> + savedApp == NaviApp.KAKAOMAP && intent.kakaoMapInstalled -> _effect.send(HomeEffect.LaunchKakaoMap(intent.course)) - intent.savedApp == NaviApp.KAKAONAVI && intent.kakaoNaviInstalled -> + savedApp == NaviApp.KAKAONAVI && intent.kakaoNaviInstalled -> _effect.send(HomeEffect.LaunchKakaoNavi(intent.course)) intent.kakaoMapInstalled && intent.kakaoNaviInstalled -> @@ -119,7 +126,7 @@ class HomeViewModel : ViewModel() { private fun onNaviAppSelected(intent: HomeIntent.OnNaviAppSelected) { viewModelScope.launch { - if (intent.always) _effect.send(HomeEffect.SaveNaviPreference(intent.app)) + if (intent.always) naviPreferenceRepository.setAlways(intent.app) when (intent.app) { NaviApp.KAKAOMAP -> _effect.send(HomeEffect.LaunchKakaoMap(intent.course)) NaviApp.KAKAONAVI -> _effect.send(HomeEffect.LaunchKakaoNavi(intent.course)) diff --git a/gradle.properties b/gradle.properties index 2d6942d..4ccdb25 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,3 +13,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # org.gradle.parallel=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +android.disallowKotlinSourceSets=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7afc1db..cd1fa0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,10 @@ coroutines = "1.10.2" # DataStore datastore = "1.1.7" +# Hilt / KSP +hilt = "2.59.2" +ksp = "2.2.10-2.0.2" + # Test (JUnit / Espresso) junit = "4.13.2" junitVersion = "1.3.0" @@ -33,6 +37,8 @@ playServicesLocation = "21.4.0" # build-logic classpath (AGP / Kotlin Gradle Plugin) android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } # AndroidX Core / Lifecycle / Activity @@ -59,6 +65,11 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- # DataStore androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +# Hilt +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } + # Test (JUnit / Espresso) junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -74,4 +85,6 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic [plugins] # 앱/모듈에서 alias로 적용하는 Gradle 플러그인 (build-logic classpath는 [libraries] 참고) android-application = { id = "com.android.application", version.ref = "agp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }