diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd25f02..4ce1ef0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: with: validate-wrappers: true + - name: Write google-services.json + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json + - name: Run ${{ matrix.task }} run: ./gradlew ${{ matrix.task }} --stacktrace diff --git a/.gitignore b/.gitignore index 047bbc3..9c31517 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .idea/ .kotlin/ build/ +bin/ local.properties */build/ .worktree-* +app/google-services.json diff --git a/README.md b/README.md index 39952bd..9e5e19f 100644 --- a/README.md +++ b/README.md @@ -69,23 +69,31 @@ API 키 등 비밀 값이 필요한 경우에도 `local.properties`에 두고 ## 프로젝트 구조 -멀티모듈 구조로 구성한다. 세부 모듈 구성은 진행 상황에 따라 확정한다. +멀티모듈 구조로 구성한다. 새 feature나 데이터 소스를 추가할 때도 아래 구조와 의존성 방향을 따른다. ``` GAMSS-Android/ -├─ app/ # 앱 진입점, DI 구성, 네비게이션 호스트 (예정) -├─ core/ # 공통 유틸, 디자인 시스템, 네트워크/DB 기반 (예정) -├─ feature/ # 화면 단위 기능 모듈 (예정) -├─ data/ # 데이터 소스, 저장소 구현 (예정) -└─ gradle/ # 버전 카탈로그, wrapper +├─ app/ # 앱 진입점, Hilt DI 그래프 조립, Navigation3 호스트 +├─ domain/ # UseCase, Repository 인터페이스, 도메인 모델 (순수 Kotlin/JVM, Android 의존성 없음) +├─ data/ # Repository 구현체, 원격(remote)·로컬 데이터 소스, DI 모듈(di) +├─ core/ +│ ├─ common/ # 모듈 간 공유되는 순수 유틸 (AppResult 등) +│ └─ ui/ # 공용 Compose UI 컴포넌트 (GamssBottomBar 등) +├─ feature/ +│ └─ home/ # 화면 단위 기능 모듈. 화면별로 하나씩 추가한다 +└─ gradle/ # 버전 카탈로그, wrapper ``` -| 모듈 | 역할 | -| --- | --- | -| `app` | 앱 진입점, DI 그래프 구성, 네비게이션 호스트 | -| `core` | 여러 기능에서 공유하는 공통 코드 (UI 컴포넌트, 네트워크·DB 기반, 유틸) | -| `feature` | 화면 단위 기능 모듈 (기능별로 분리) | -| `data` | 데이터 소스 및 저장소(Repository) 구현 | +| 모듈 | 역할 | 의존하는 모듈 | +| --- | --- | --- | +| `app` | 앱 진입점, Hilt DI 그래프 조립, Navigation3 호스트 | `domain`, `data`, `core:common`, `core:ui`, `feature:*` | +| `domain` | UseCase, Repository 인터페이스, 도메인 모델 | 없음 | +| `data` | Repository 구현, 원격/로컬 데이터 소스, Hilt DI 모듈 | `domain`, `core:common` | +| `core:common` | 여러 모듈이 공유하는 순수 유틸 | 없음 | +| `core:ui` | 공용 Compose UI 컴포넌트 | 없음 | +| `feature:*` | 화면 단위 기능 모듈 (기능별로 분리) | `core:ui` (필요 시 `domain`) | + +**의존성 규칙**: 안쪽 레이어(`domain`)는 바깥쪽 어떤 모듈도 참조하지 않는다. `data`, `feature`, `app`은 `domain`이 정의한 인터페이스(Repository, UseCase)에 의존하고, 구현은 바깥쪽(`data`)에 둔다. `app`은 조립부이므로 예외적으로 전체 모듈을 참조한다. ## 기술 스택 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ff2fda..11e7cec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,9 +1,13 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt) + alias(libs.plugins.googleServices) + alias(libs.plugins.firebaseCrashlytics) } android { @@ -36,22 +40,29 @@ android { kotlin { compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_17 } } dependencies { - implementation(projects.core) + implementation(projects.domain) implementation(projects.data) + implementation(projects.core.common) + implementation(projects.core.ui) + implementation(projects.feature.home) + implementation(projects.feature.chat) + implementation(projects.feature.calendar) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.navigation3.runtime) implementation(libs.navigation3.ui) @@ -59,5 +70,9 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.analytics) + debugImplementation(libs.compose.ui.tooling) } diff --git a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt index 796fce1..523d975 100644 --- a/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt +++ b/app/src/main/kotlin/com/gamss/android/app/MainActivity.kt @@ -4,7 +4,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.gamss.android.app.navigation.AppNavHost +import com.gamss.android.app.navigation.GamssNavHost import com.gamss.android.app.ui.theme.GamssTheme import dagger.hilt.android.AndroidEntryPoint @@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { GamssTheme { - AppNavHost() + GamssNavHost() } } } diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/AppNavHost.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/AppNavHost.kt deleted file mode 100644 index 20f28cf..0000000 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/AppNavHost.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.gamss.android.app.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import com.gamss.android.app.ui.HomeScreen - -@Composable -fun AppNavHost() { - val backStack = rememberNavBackStack(HomeKey) - - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryProvider = entryProvider { - entry { HomeScreen() } - }, - ) -} diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/GamssNavHost.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssNavHost.kt new file mode 100644 index 0000000..fa5165c --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/GamssNavHost.kt @@ -0,0 +1,53 @@ +package com.gamss.android.app.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.gamss.android.core.ui.GamssBottomBar +import com.gamss.android.feature.calendar.CalendarScreen +import com.gamss.android.feature.calendar.navigation.CalendarKey +import com.gamss.android.feature.chat.ChattingListScreen +import com.gamss.android.feature.chat.navigation.ChatKey +import com.gamss.android.feature.home.HomeScreen +import com.gamss.android.feature.home.navigation.HomeKey + +@Composable +fun GamssNavHost() { + val navigationState = rememberNavigationState( + startKey = HomeKey, + topLevelKeys = topLevelDestinationKeys, + ) + val navigator = remember(navigationState) { Navigator(navigationState) } + + Scaffold( + bottomBar = { + if (navigationState.currentKey == navigationState.currentTopLevelKey) { + GamssBottomBar( + items = topLevelBottomBarItems, + selectedValue = navigationState.currentTopLevelKey, + onItemClick = navigator::navigate, + ) + } + }, + ) { innerPadding -> + NavDisplay( + modifier = Modifier.padding(innerPadding), + entries = navigationState.toEntries( + entryProvider = entryProvider { + entry { HomeScreen() } + entry { ChattingListScreen() } + entry { CalendarScreen() } + }, + ), + onBack = { + if (navigationState.canGoBack) { + navigator.goBack() + } + }, + ) + } +} diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/NavigationState.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/NavigationState.kt new file mode 100644 index 0000000..43b0e53 --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/NavigationState.kt @@ -0,0 +1,90 @@ +package com.gamss.android.app.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator + +/** + * GAMSS 메인 탭 네비게이션 상태를 생성한다. + * + * Navigation3의 back stack은 rememberSaveable 기반으로 동작하므로 화면 회전이나 + * 프로세스 재생성 이후에도 복원 가능한 key를 사용해야 한다. + * + * @param startKey 앱의 메인 영역에서 처음 보여줄 최상위 key + * @param topLevelKeys bottom bar에 연결되는 최상위 key 목록 + */ +@Composable +fun rememberNavigationState( + startKey: NavKey, + topLevelKeys: Set, +): NavigationState { + val topLevelStack = rememberNavBackStack(startKey) + val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) } + + return remember(startKey, topLevelKeys) { + NavigationState( + startKey = startKey, + topLevelStack = topLevelStack, + subStacks = subStacks, + ) + } +} + +/** + * bottom bar 기반 화면 전환을 위한 상태 홀더. + * + * topLevelStack은 사용자가 방문한 탭 순서를 저장하고, subStacks는 각 탭 내부의 상세 화면 + * stack을 따로 저장한다. 탭을 전환해도 탭의 상세 화면 흐름을 유지할 수 있다. + */ +class NavigationState( + val startKey: NavKey, + val topLevelStack: NavBackStack, + val subStacks: Map>, +) { + val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() } + + val topLevelKeys + get() = subStacks.keys + + val currentSubStack: NavBackStack + get() = subStacks[currentTopLevelKey] + ?: error("현재 탭($currentTopLevelKey)에 해당하는 back stack이 없습니다.") + + val currentKey: NavKey by derivedStateOf { currentSubStack.last() } + + val canGoBack: Boolean by derivedStateOf { currentKey != startKey } +} + +/** + * NavigationState를 NavDisplay에서 사용할 entry 목록으로 변환한다. + * + * 각 탭의 sub stack에 saveable state와 ViewModelStore decorator를 붙여서, 탭 전환 중에도 + * 화면 상태와 ViewModel 생명주기가 탭별 back stack에 맞게 유지되도록 한다. + */ +@Composable +fun NavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry, +): List> { + val decoratedEntries = subStacks.mapValues { (_, stack) -> + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ) + } + + return topLevelStack + .flatMap { decoratedEntries[it] ?: emptyList() } +} diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt new file mode 100644 index 0000000..b3fd1e7 --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/Navigator.kt @@ -0,0 +1,75 @@ +package com.gamss.android.app.navigation + +import androidx.navigation3.runtime.NavKey + +/** + * NavigationState를 변경하는 앱 전용 navigator. + * + * GAMSS의 bottom bar 정책을 한 곳에 모아둔다. + * - 현재 탭을 다시 선택하면 해당 탭의 root 화면으로 이동 + * - 다른 탭을 선택하면 방문한 탭 순서를 topLevelStack에 기록 + * - 상세 화면은 현재 탭의 sub stack에 쌓되, 같은 key가 이미 있으면 마지막으로 이동 + */ +class Navigator(val state: NavigationState) { + + /** + * 지정한 key로 이동한다. + * top-level key인지, 현재 탭인지, 상세 key인지에 따라 stack 갱신 규칙이 달라진다. + */ + fun navigate(key: NavKey) { + when (key) { + state.currentTopLevelKey -> clearSubStack() + in state.topLevelKeys -> goToTopLevel(key) + else -> goToKey(key) + } + } + + /** + * 현재 위치에서 뒤로 이동한다. + * + * 현재 탭의 root 화면에서는 이전에 방문한 탭으로 돌아가고, 상세 화면에서는 현재 탭의 + * sub stack에서 한 단계 pop한다. + */ + fun goBack() { + when (state.currentKey) { + state.startKey -> Unit + state.currentTopLevelKey -> { + state.topLevelStack.removeLastOrNull() + } + else -> state.currentSubStack.removeLastOrNull() + } + } + + /** + * 현재 탭의 상세 화면으로 이동한다. + */ + private fun goToKey(key: NavKey) { + state.currentSubStack.apply { + remove(key) + add(key) + } + } + + /** + * 다른 top-level 탭으로 이동한다. + */ + private fun goToTopLevel(key: NavKey) { + state.topLevelStack.apply { + if (key == state.startKey) { + clear() + } else { + remove(key) + } + add(key) + } + } + + /** + * 현재 탭을 다시 선택했을 때 root 화면만 남긴다. + */ + private fun clearSubStack() { + state.currentSubStack.run { + if (size > 1) subList(1, size).clear() + } + } +} diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/gamss/android/app/navigation/TopLevelDestination.kt new file mode 100644 index 0000000..03c4574 --- /dev/null +++ b/app/src/main/kotlin/com/gamss/android/app/navigation/TopLevelDestination.kt @@ -0,0 +1,39 @@ +package com.gamss.android.app.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation3.runtime.NavKey +import com.gamss.android.core.ui.GamssBottomBarItem +import com.gamss.android.feature.calendar.navigation.CalendarKey +import com.gamss.android.feature.chat.navigation.ChatKey +import com.gamss.android.feature.home.navigation.HomeKey + +/** + * bottom bar에 표시되는 최상위 탭 목록. + * + * 새로운 feature 모듈이 하단 탭으로 추가될 때마다 이 목록에 항목을 더한다. + */ +data class TopLevelDestination( + val key: NavKey, + val icon: ImageVector, + val label: String, +) + +val topLevelDestinations = listOf( + TopLevelDestination(key = HomeKey, icon = Icons.Filled.Home, label = "홈"), + TopLevelDestination(key = ChatKey, icon = Icons.Filled.Email, label = "대화"), + TopLevelDestination(key = CalendarKey, icon = Icons.Filled.DateRange, label = "달력"), +) + +val topLevelDestinationKeys = topLevelDestinations.map { it.key }.toSet() + +val topLevelBottomBarItems: List> = topLevelDestinations.map { destination -> + GamssBottomBarItem( + value = destination.key, + icon = destination.icon, + label = destination.label, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index 3e4ffb6..7de44a7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,9 +3,12 @@ plugins { alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.kotlinJvm) apply false alias(libs.plugins.composeCompiler) apply false + alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.detekt) + alias(libs.plugins.googleServices) apply false + alias(libs.plugins.firebaseCrashlytics) apply false } val detektPluginId = libs.plugins.detekt.get().pluginId diff --git a/core/build.gradle.kts b/core/common/build.gradle.kts similarity index 98% rename from core/build.gradle.kts rename to core/common/build.gradle.kts index a3b9cb9..bea50c5 100644 --- a/core/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -8,4 +8,4 @@ kotlin { dependencies { testImplementation(libs.junit) -} +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/gamss/android/core/AppResult.kt b/core/common/src/main/java/com/gamss/android/core/common/AppResult.kt similarity index 94% rename from core/src/main/kotlin/com/gamss/android/core/AppResult.kt rename to core/common/src/main/java/com/gamss/android/core/common/AppResult.kt index 5269fd1..e3b4c39 100644 --- a/core/src/main/kotlin/com/gamss/android/core/AppResult.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/AppResult.kt @@ -1,4 +1,4 @@ -package com.gamss.android.core +package com.gamss.android.core.common sealed interface AppResult { data class Success(val data: T) : AppResult diff --git a/core/src/test/kotlin/com/gamss/android/core/AppResultTest.kt b/core/src/test/kotlin/com/gamss/android/core/AppResultTest.kt deleted file mode 100644 index 2b679ea..0000000 --- a/core/src/test/kotlin/com/gamss/android/core/AppResultTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.gamss.android.core - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class AppResultTest { - - @Test - fun of_capturesSuccess() { - val result = AppResult.of { 42 } - assertTrue(result is AppResult.Success) - assertEquals(42, result.getOrNull()) - } - - @Test - fun of_capturesFailure() { - val result = AppResult.of { error("boom") } - assertTrue(result is AppResult.Failure) - } - - @Test - fun map_transformsSuccess() { - val result = AppResult.Success(2).map { it * 3 } - assertEquals(6, result.getOrNull()) - } - - @Test - fun map_passesThroughFailure() { - val failure: AppResult = AppResult.Failure(IllegalStateException()) - val mapped = failure.map { it * 3 } - assertTrue(mapped is AppResult.Failure) - } -} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..9fc6480 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeCompiler) +} + +android { + namespace = "com.gamss.android.core.ui" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + + // compose + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons.core) + debugImplementation(libs.compose.ui.tooling) + + // test + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBar.kt b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBar.kt new file mode 100644 index 0000000..f97a1b8 --- /dev/null +++ b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBar.kt @@ -0,0 +1,25 @@ +package com.gamss.android.core.ui + +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun GamssBottomBar( + items: List>, + selectedValue: T, + onItemClick: (T) -> Unit, +) { + NavigationBar { + items.forEach { item -> + NavigationBarItem( + selected = item.value == selectedValue, + onClick = { onItemClick(item.value) }, + icon = { Icon(imageVector = item.icon, contentDescription = item.label) }, + label = { Text(text = item.label) }, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBarItem.kt b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBarItem.kt new file mode 100644 index 0000000..3a740da --- /dev/null +++ b/core/ui/src/main/kotlin/com/gamss/android/core/ui/GamssBottomBarItem.kt @@ -0,0 +1,9 @@ +package com.gamss.android.core.ui + +import androidx.compose.ui.graphics.vector.ImageVector + +data class GamssBottomBarItem( + val value: T, + val icon: ImageVector, + val label: String, +) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index c40d677..eb236a3 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,5 +1,8 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.androidLibrary) + alias(libs.plugins.ksp) } android { @@ -8,21 +11,35 @@ android { defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + buildConfig = true + } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + } kotlin { compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + jvmTarget = JvmTarget.JVM_17 } } dependencies { - api(projects.domain) - implementation(projects.core) -} + implementation(projects.domain) + implementation(projects.core.common) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.kotlinx.serialization) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.kotlinx.serialization.json) +} \ No newline at end of file diff --git a/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt b/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt new file mode 100644 index 0000000..ee6a51a --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/NetworkModule.kt @@ -0,0 +1,61 @@ +package com.gamss.android.data.di + +import com.gamss.android.data.BuildConfig +import com.gamss.android.data.remote.auth.AuthService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + return Retrofit.Builder() + // mock 주소. 실제 백엔드 API 주소가 확정되면 교체한다. + .baseUrl("https://api.gamss.example.com/") + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @Provides + @Singleton + fun provideAuthService(retrofit: Retrofit): AuthService = + retrofit.create(AuthService::class.java) +} diff --git a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt new file mode 100644 index 0000000..98d28df --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt @@ -0,0 +1,18 @@ +package com.gamss.android.data.di + +import com.gamss.android.data.repository.AuthRepositoryImpl +import com.gamss.android.domain.repository.AuthRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class RepositoryModule { + + @Binds + abstract fun bindAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository +} diff --git a/data/src/main/java/com/gamss/android/data/remote/auth/AuthService.kt b/data/src/main/java/com/gamss/android/data/remote/auth/AuthService.kt new file mode 100644 index 0000000..6c5e8d0 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/auth/AuthService.kt @@ -0,0 +1,12 @@ +package com.gamss.android.data.remote.auth + +import com.gamss.android.data.remote.auth.model.request.LoginRequest +import com.gamss.android.data.remote.auth.model.response.LoginResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthService { + + @POST("/login") + suspend fun login(@Body request: LoginRequest): LoginResponse +} diff --git a/data/src/main/java/com/gamss/android/data/remote/auth/model/request/LoginRequest.kt b/data/src/main/java/com/gamss/android/data/remote/auth/model/request/LoginRequest.kt new file mode 100644 index 0000000..2e3d74e --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/auth/model/request/LoginRequest.kt @@ -0,0 +1,10 @@ +package com.gamss.android.data.remote.auth.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequest( + @SerialName("userId") + val userId: String, +) diff --git a/data/src/main/java/com/gamss/android/data/remote/auth/model/response/LoginResponse.kt b/data/src/main/java/com/gamss/android/data/remote/auth/model/response/LoginResponse.kt new file mode 100644 index 0000000..2b9964f --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/auth/model/response/LoginResponse.kt @@ -0,0 +1,18 @@ +package com.gamss.android.data.remote.auth.model.response + +import com.gamss.android.domain.model.AuthResponse +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, +) { + fun toDomain() = AuthResponse( + accessToken = accessToken, + refreshToken = refreshToken + ) +} diff --git a/data/src/main/java/com/gamss/android/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..81edc11 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.data.remote.auth.AuthService +import com.gamss.android.data.remote.auth.model.request.LoginRequest +import com.gamss.android.domain.model.AuthResponse +import com.gamss.android.domain.repository.AuthRepository +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authService: AuthService, +) : AuthRepository { + + override suspend fun login(userId: String): AppResult = + AppResult.of { authService.login(LoginRequest(userId = userId)).toDomain() } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 58fe427..c2a6b24 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -7,7 +7,7 @@ kotlin { } dependencies { - api(projects.core) + implementation(projects.core.common) testImplementation(libs.junit) } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/model/AuthResponse.kt b/domain/src/main/kotlin/com/gamss/android/domain/model/AuthResponse.kt new file mode 100644 index 0000000..c575b0c --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/model/AuthResponse.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.model + +data class AuthResponse( + val accessToken: String? = null, + val refreshToken: String? = null, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/AuthRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..9008a08 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/AuthRepository.kt @@ -0,0 +1,8 @@ +package com.gamss.android.domain.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.AuthResponse + +interface AuthRepository { + suspend fun login(userId: String): AppResult +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/UseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/usecase/UseCase.kt similarity index 79% rename from domain/src/main/kotlin/com/gamss/android/domain/UseCase.kt rename to domain/src/main/kotlin/com/gamss/android/domain/usecase/UseCase.kt index dc41447..abbb063 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/UseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/usecase/UseCase.kt @@ -1,4 +1,4 @@ -package com.gamss.android.domain +package com.gamss.android.domain.usecase interface UseCase { suspend operator fun invoke(params: P): R diff --git a/feature/calendar/build.gradle.kts b/feature/calendar/build.gradle.kts new file mode 100644 index 0000000..e19472b --- /dev/null +++ b/feature/calendar/build.gradle.kts @@ -0,0 +1,62 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.gamss.android.feature.calendar" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(projects.core.ui) + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + + implementation(libs.navigation3.runtime) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) + + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.orbit.test) + androidTestImplementation(libs.androidx.junit) +} \ No newline at end of file diff --git a/feature/calendar/src/androidTest/java/com/gamss/android/feature/calendar/ExampleInstrumentedTest.kt b/feature/calendar/src/androidTest/java/com/gamss/android/feature/calendar/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..5e78716 --- /dev/null +++ b/feature/calendar/src/androidTest/java/com/gamss/android/feature/calendar/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.gamss.android.feature.calendar + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.gamss.android.feature.calendar.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/data/src/main/AndroidManifest.xml b/feature/calendar/src/main/AndroidManifest.xml similarity index 85% rename from data/src/main/AndroidManifest.xml rename to feature/calendar/src/main/AndroidManifest.xml index b2d3ea1..a5918e6 100644 --- a/data/src/main/AndroidManifest.xml +++ b/feature/calendar/src/main/AndroidManifest.xml @@ -1,2 +1,4 @@ - + + + \ No newline at end of file diff --git a/app/src/main/kotlin/com/gamss/android/app/ui/HomeScreen.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt similarity index 71% rename from app/src/main/kotlin/com/gamss/android/app/ui/HomeScreen.kt rename to feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt index 740a4b2..b4cc2ae 100644 --- a/app/src/main/kotlin/com/gamss/android/app/ui/HomeScreen.kt +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarScreen.kt @@ -1,4 +1,4 @@ -package com.gamss.android.app.ui +package com.gamss.android.feature.calendar import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -9,9 +9,15 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun HomeScreen() { +fun CalendarScreen( + viewModel: CalendarViewModel = hiltViewModel(), +) { + viewModel.collectSideEffect { } + Scaffold { innerPadding -> Box( modifier = Modifier @@ -20,7 +26,7 @@ fun HomeScreen() { contentAlignment = Alignment.Center, ) { Text( - text = "GAMSS", + text = "달력", style = MaterialTheme.typography.headlineLarge, ) } diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt new file mode 100644 index 0000000..104161b --- /dev/null +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarSideEffect.kt @@ -0,0 +1,3 @@ +package com.gamss.android.feature.calendar + +sealed interface CalendarSideEffect diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt new file mode 100644 index 0000000..5ef4ebb --- /dev/null +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarState.kt @@ -0,0 +1,5 @@ +package com.gamss.android.feature.calendar + +data class CalendarState( + val isLoading: Boolean = false, +) diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..8516751 --- /dev/null +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/CalendarViewModel.kt @@ -0,0 +1,15 @@ +package com.gamss.android.feature.calendar + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class CalendarViewModel @Inject constructor() : + ViewModel(), + ContainerHost { + + override val container = container(CalendarState()) +} diff --git a/feature/calendar/src/main/java/com/gamss/android/feature/calendar/navigation/CalendarKey.kt b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/navigation/CalendarKey.kt new file mode 100644 index 0000000..d21aa69 --- /dev/null +++ b/feature/calendar/src/main/java/com/gamss/android/feature/calendar/navigation/CalendarKey.kt @@ -0,0 +1,7 @@ +package com.gamss.android.feature.calendar.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object CalendarKey : NavKey diff --git a/feature/calendar/src/test/java/com/gamss/android/feature/calendar/ExampleUnitTest.kt b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/ExampleUnitTest.kt new file mode 100644 index 0000000..27615f5 --- /dev/null +++ b/feature/calendar/src/test/java/com/gamss/android/feature/calendar/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.calendar + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts new file mode 100644 index 0000000..917fd2d --- /dev/null +++ b/feature/chat/build.gradle.kts @@ -0,0 +1,61 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.gamss.android.feature.chat" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + + implementation(libs.navigation3.runtime) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) + + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.orbit.test) + androidTestImplementation(libs.androidx.junit) +} \ No newline at end of file diff --git a/feature/chat/src/androidTest/java/com/gamss/android/feature/chat/ExampleInstrumentedTest.kt b/feature/chat/src/androidTest/java/com/gamss/android/feature/chat/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..b164cd6 --- /dev/null +++ b/feature/chat/src/androidTest/java/com/gamss/android/feature/chat/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.gamss.android.feature.chat + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.gamss.android.feature.chat.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/chat/src/main/AndroidManifest.xml b/feature/chat/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/chat/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt new file mode 100644 index 0000000..b86f56e --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt @@ -0,0 +1,60 @@ +package com.gamss.android.feature.chat + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +private data class DummyChat( + val name: String, + val lastMessage: String, +) + +private val dummyChats = List(10) { + DummyChat(name = "채팅방 ${it + 1}", lastMessage = "임시 메시지 내용입니다.") +} + +@Composable +fun ChattingListScreen( + viewModel: ChattingListViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + + viewModel.collectSideEffect { } + + Scaffold( + topBar = { + Text( + text = "채팅", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp), + ) + }, + ) { innerPadding -> + if (state.isLoading) { + CircularProgressIndicator() + } else { + LazyColumn(modifier = Modifier.padding(innerPadding)) { + items(dummyChats) { chat -> + ListItem( + headlineContent = { Text(chat.name) }, + supportingContent = { Text(chat.lastMessage) }, + ) + Divider() + } + } + } + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListSideEffect.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListSideEffect.kt new file mode 100644 index 0000000..5349ff9 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListSideEffect.kt @@ -0,0 +1,3 @@ +package com.gamss.android.feature.chat + +sealed interface ChattingListSideEffect diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListState.kt new file mode 100644 index 0000000..087a0d5 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListState.kt @@ -0,0 +1,5 @@ +package com.gamss.android.feature.chat + +data class ChattingListState( + val isLoading: Boolean = false, +) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListViewModel.kt new file mode 100644 index 0000000..fa0c732 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListViewModel.kt @@ -0,0 +1,15 @@ +package com.gamss.android.feature.chat + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class ChattingListViewModel @Inject constructor() : + ViewModel(), + ContainerHost { + + override val container = container(ChattingListState()) +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/navigation/ChatKey.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/navigation/ChatKey.kt new file mode 100644 index 0000000..518747d --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/navigation/ChatKey.kt @@ -0,0 +1,7 @@ +package com.gamss.android.feature.chat.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object ChatKey : NavKey diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ExampleUnitTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ExampleUnitTest.kt new file mode 100644 index 0000000..e765379 --- /dev/null +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts new file mode 100644 index 0000000..84a02b8 --- /dev/null +++ b/feature/home/build.gradle.kts @@ -0,0 +1,63 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.gamss.android.feature.home" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(projects.core.ui) + + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + + implementation(libs.navigation3.runtime) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + implementation(libs.orbit.compose) + + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.orbit.test) + androidTestImplementation(libs.androidx.junit) +} \ No newline at end of file diff --git a/feature/home/src/androidTest/java/com/gamss/android/feature/home/ExampleInstrumentedTest.kt b/feature/home/src/androidTest/java/com/gamss/android/feature/home/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..45785b9 --- /dev/null +++ b/feature/home/src/androidTest/java/com/gamss/android/feature/home/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.gamss.android.feature.home + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.gamss.android.feature.home.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/home/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt new file mode 100644 index 0000000..68cde8b --- /dev/null +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt @@ -0,0 +1,62 @@ +package com.gamss.android.feature.home + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun HomeScreen( + viewModel: HomeViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val context = LocalContext.current + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is HomeSideEffect.ShowToast -> + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + + Scaffold { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.greeting.ifEmpty { "GAMSS" }, + style = MaterialTheme.typography.headlineLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = viewModel::loadGreeting) { + Text("새로고침") + } + } + } + } + } +} diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt new file mode 100644 index 0000000..a07bba6 --- /dev/null +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeSideEffect.kt @@ -0,0 +1,5 @@ +package com.gamss.android.feature.home + +sealed interface HomeSideEffect { + data class ShowToast(val message: String) : HomeSideEffect +} diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt new file mode 100644 index 0000000..a59a738 --- /dev/null +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt @@ -0,0 +1,6 @@ +package com.gamss.android.feature.home + +data class HomeState( + val isLoading: Boolean = false, + val greeting: String = "", +) diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..90e1927 --- /dev/null +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt @@ -0,0 +1,29 @@ +package com.gamss.android.feature.home + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel(), ContainerHost { + + override val container = container(HomeState()) + + init { + loadGreeting() + } + + fun loadGreeting() = intent { + reduce { state.copy(isLoading = true) } + + // mock 데이터. 실제 UseCase/Repository 연동 시 교체한다. + delay(500) + val mockGreeting = "오늘 하루는 어땠나요?" + + reduce { state.copy(isLoading = false, greeting = mockGreeting) } + postSideEffect(HomeSideEffect.ShowToast("불러오기 완료")) + } +} diff --git a/app/src/main/kotlin/com/gamss/android/app/navigation/HomeKey.kt b/feature/home/src/main/java/com/gamss/android/feature/home/navigation/HomeKey.kt similarity index 72% rename from app/src/main/kotlin/com/gamss/android/app/navigation/HomeKey.kt rename to feature/home/src/main/java/com/gamss/android/feature/home/navigation/HomeKey.kt index acb6d69..da9b979 100644 --- a/app/src/main/kotlin/com/gamss/android/app/navigation/HomeKey.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/navigation/HomeKey.kt @@ -1,4 +1,4 @@ -package com.gamss.android.app.navigation +package com.gamss.android.feature.home.navigation import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/ExampleUnitTest.kt b/feature/home/src/test/java/com/gamss/android/feature/home/ExampleUnitTest.kt new file mode 100644 index 0000000..3a399a1 --- /dev/null +++ b/feature/home/src/test/java/com/gamss/android/feature/home/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.home + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7c0352..6c8c645 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,15 +7,38 @@ android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.11.0" +androidx-hilt = "1.2.0" androidx-lifecycle = "2.9.4" +lifecycle-viewmodel-navigation3 = "2.10.0" compose-bom = "2026.04.01" navigation3 = "1.1.0-rc01" junit = "4.13.2" detekt = "1.23.8" +kotlinx-serialization = "1.11.0" +orbit-mvi = "11.0.0" +retrofit = "3.0.0" +okhttp = "5.4.0" +room = "3.0.0-alpha06" +coil = "3.5.0" +calendar = "2.10.0" +firebase-bom = "34.15.0" +firebase-crashlytics-gradle = "3.0.7" +google-services = "4.4.4" +androidx-credentials = "1.5.0" +googleid = "1.1.1" +coreKtx = "1.17.0" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +appcompat = "1.7.1" +material = "1.14.0" +runtime = "1.11.4" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3-android", version.ref = "lifecycle-viewmodel-navigation3" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-ui = { module = "androidx.compose.ui:ui" } @@ -23,6 +46,7 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" } navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } @@ -34,6 +58,42 @@ junit = { module = "junit:junit", version.ref = "junit" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +orbit-core = { module = "org.orbit-mvi:orbit-core", version.ref = "orbit-mvi" } +orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbit-mvi" } +orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbit-mvi" } +orbit-test = { module = "org.orbit-mvi:orbit-test", version.ref = "orbit-mvi" } + +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } + +room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" } +room-ktx = { module = "androidx.room3:room3-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room" } + +coil-bom = { module = "io.coil-kt.coil3:coil-bom", version.ref = "coil" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp" } + +calendar-compose = { module = "com.kizitonwose.calendar:compose", version.ref = "calendar" } + +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } + +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } +googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } @@ -42,4 +102,6 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +googleServices = { id = "com.google.gms.google-services", version.ref = "google-services" } +firebaseCrashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics-gradle" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4e1657e..1174dbc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,12 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") rootProject.name = "GAMSS-Android" include(":app") -include(":core") + include(":domain") include(":data") + +include(":core:common") +include(":core:ui") +include(":feature:home") +include(":feature:chat") +include(":feature:calendar")