diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md
new file mode 100644
index 0000000..0f5c8f2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue_template.md
@@ -0,0 +1,14 @@
+---
+name: issue_template
+about: 이슈 전용 탬플릿 입니다
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## ISSUE
+- 이슈에 대한 설명
+
+## ❗ TO DO
+- [ ] 해야 할 일에 대한 설명
diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml
new file mode 100644
index 0000000..ba915c4
--- /dev/null
+++ b/.github/auto_assign.yml
@@ -0,0 +1,6 @@
+addAssignees: author
+
+addReviewers: true
+
+reviewers:
+ - sonms
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..88eaf1e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,12 @@
+## ISSUE
+- closed #이슈번호
+
+## ❗ WORK DESCRIPTION
+- PR에 대한 설명
+
+## Uncompleted Tasks 😅
+- [ ]
+
+## 📸 SCREENSHOT
+
+
diff --git a/.github/workflows/auto_assign_action.yml b/.github/workflows/auto_assign_action.yml
new file mode 100644
index 0000000..721a0f9
--- /dev/null
+++ b/.github/workflows/auto_assign_action.yml
@@ -0,0 +1,13 @@
+name: "Auto Assign and Reviewers"
+on:
+ pull_request:
+ types: [opened, ready_for_review]
+
+jobs:
+ add-reviewers-and-assignees:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Assign author and add reviewers
+ uses: kentaro-m/auto-assign-action@v2.0.0
+ with:
+ configuration-path: '.github/auto_assign.yml'
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index aa724b7..d14fddb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,249 @@
+# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos,windows,linux
+# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,macos,windows,linux
+
+### Android ###
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
+.idea/*
+!.idea/codeInsightSettings.xml
+!.idea/icon.svg
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+### Android Patch ###
+gen-external-apklibs
+
+# Replacement of .externalNativeBuild directories introduced
+# with Android Studio 3.5.
+
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
.DS_Store
-/build
-/captures
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+.navigation/
+*.ipr
+*.swp
+
+# Keystores files
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+
+# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
-.cxx
-local.properties
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iws
+/out/
+
+# User-specific configurations
+.idea/caches/
+.idea/libraries/
+.idea/shelf/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/.name
+.idea/compiler.xml
+.idea/copyright/profiles_settings.xml
+.idea/encodings.xml
+.idea/misc.xml
+.idea/modules.xml
+.idea/scopes/scope_settings.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+.idea/datasources.xml
+.idea/dataSources.ids
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+.idea/assetWizardSettings.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/navEditor.xml
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.war
+*.ear
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+hs_err_pid*
+
+## Plugin-specific files:
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Mongo Explorer plugin
+.idea/mongoSettings.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos,windows,linux
+
+# Fastlane dependencies
+vendor/
+
+### Claude ###
+# Claude Code
+CLAUDE.md
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b86273d..fb7f4a8 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 2de1171..f8a571a 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -1,5 +1,6 @@
+
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index c224ad5..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 74dd639..5705276 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,10 +1,10 @@
-
+
-
\ No newline at end of file
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6f54aee..1a71cb4 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.android.application)
+ alias(libs.plugins.focpet.android.application)
+ alias(libs.plugins.focpet.android.buildconfig)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.dagger.hilt)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.devtools.ksp)
}
android {
@@ -44,14 +48,10 @@ android {
}
dependencies {
+ // presentation
+ implementation(projects.presentation.main)
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
- implementation(libs.androidx.activity.compose)
- implementation(platform(libs.androidx.compose.bom))
-
- testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
- androidTestImplementation(platform(libs.androidx.compose.bom))
+ implementation(libs.timber)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.coil.compose)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d8a5166..756ed0a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
diff --git a/app/src/main/java/com/focpet/FocpetApplication.kt b/app/src/main/java/com/focpet/FocpetApplication.kt
new file mode 100644
index 0000000..1df1364
--- /dev/null
+++ b/app/src/main/java/com/focpet/FocpetApplication.kt
@@ -0,0 +1,34 @@
+package com.focpet
+
+import android.app.Application
+import androidx.appcompat.app.AppCompatDelegate
+import coil.ImageLoader
+import coil.ImageLoaderFactory
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class FocpetApplication: Application(), ImageLoaderFactory {
+ @Inject
+ lateinit var imageLoaderProvider: dagger.Lazy
+
+ override fun onCreate() {
+ super.onCreate()
+
+ setDayMode()
+ initTimber()
+ }
+
+ private fun setDayMode() {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
+ }
+
+ override fun newImageLoader(): ImageLoader {
+ return imageLoaderProvider.get()
+ }
+}
diff --git a/app/src/main/java/com/focpet/MainActivity.kt b/app/src/main/java/com/focpet/MainActivity.kt
deleted file mode 100644
index f813759..0000000
--- a/app/src/main/java/com/focpet/MainActivity.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.focpet
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import com.focpet.ui.theme.FocpetTheme
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContent {
- FocpetTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
- )
- }
- }
- }
- }
-}
-
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
- FocpetTheme {
- Greeting("Android")
- }
-}
diff --git a/app/src/main/java/com/focpet/di/CoilModule.kt b/app/src/main/java/com/focpet/di/CoilModule.kt
new file mode 100644
index 0000000..3ec2636
--- /dev/null
+++ b/app/src/main/java/com/focpet/di/CoilModule.kt
@@ -0,0 +1,44 @@
+package com.focpet.di
+
+import android.content.Context
+import coil.ImageLoader
+import coil.disk.DiskCache
+import coil.memory.MemoryCache
+import coil.util.DebugLogger
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object CoilModule {
+
+ @Provides
+ @Singleton
+ fun provideImageLoader(
+ @ApplicationContext context: Context,
+ okHttpClient: OkHttpClient
+ ): ImageLoader {
+ return ImageLoader.Builder(context)
+ .okHttpClient(okHttpClient)
+ .memoryCache {
+ MemoryCache.Builder(context)
+ .maxSizePercent(0.25)
+ .strongReferencesEnabled(true)
+ .build()
+ }
+ .diskCache {
+ DiskCache.Builder()
+ .directory(context.cacheDir.resolve("image_cache"))
+ .maxSizeBytes(100L * 1024 * 1024)
+ .build()
+ }
+ .logger(DebugLogger())
+ .allowRgb565(true)
+ .build()
+ }
+}
diff --git a/build-logic/.gitignore b/build-logic/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/build-logic/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/build-logic/convention/.gitignore b/build-logic/convention/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/build-logic/convention/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
new file mode 100644
index 0000000..58834d6
--- /dev/null
+++ b/build-logic/convention/build.gradle.kts
@@ -0,0 +1,80 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ `kotlin-dsl`
+}
+
+group = "com.focpet.buildlogic"
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+}
+
+dependencies {
+ compileOnly(libs.android.gradlePlugin)
+ compileOnly(libs.kotlin.gradlePlugin)
+ compileOnly(libs.compose.compiler.gradle.plugin)
+ compileOnly(libs.ksp.gradlePlugin)
+ compileOnly(libs.hilt.gradlePlugin)
+}
+
+gradlePlugin {
+ plugins {
+ // app 모듈용
+ register("androidApplication") {
+ id = "focpet.android.application"
+ implementationClass = "AndroidApplicationConventionPlugin"
+ }
+ // 일반 library용
+ register("androidLibrary") {
+ id = "focpet.android.library"
+ implementationClass = "AndroidLibraryConventionPlugin"
+ }
+ // BuildConfig library용
+ register("androidBuildConfig") {
+ id = "focpet.android.buildconfig"
+ implementationClass = "AndroidBuildConfigConventionPlugin"
+ }
+ // Compose 설정용
+ register("androidCompose") {
+ id = "focpet.android.compose"
+ implementationClass = "AndroidComposeConventionPlugin"
+ }
+ // presentation(feature) 모듈용
+ register("androidPresentation") {
+ id = "focpet.android.presentation"
+ implementationClass = "AndroidPresentationConventionPlugin"
+ }
+ // data 모듈용
+ register("androidData") {
+ id = "focpet.android.data"
+ implementationClass = "AndroidDataConventionPlugin"
+ }
+ // domain 모듈용
+ register("kotlinJvm") {
+ id = "focpet.kotlin.jvm"
+ implementationClass = "KotlinJvmConventionPlugin"
+ }
+ // Hilt 설정용
+ register("hilt") {
+ id = "focpet.hilt"
+ implementationClass = "HiltConventionPlugin"
+ }
+ // Serialization용
+ register("serialization") {
+ id = "focpet.serialization"
+ implementationClass = "SerializationConventionPlugin"
+ }
+ // Test 설정용
+ register("androidTest") {
+ id = "focpet.android.test"
+ implementationClass = "AndroidTestConventionPlugin"
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
new file mode 100644
index 0000000..4143c5f
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -0,0 +1,47 @@
+import com.android.build.api.dsl.ApplicationExtension
+import com.focpet.buildlogic.configureKotlinAndroid
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+
+/**
+ * app 모듈 전용 Convention Plugin.
+ * 프로젝트에서 오직 :app 모듈에서만 사용.
+ *
+ * Library와 다르게 Application은:
+ * - targetSdk 설정 필요
+ * - applicationId 설정 필요 (모듈 build.gradle에서 직접 설정)
+ * - Compose 포함 (app도 Compose 사용)
+ *
+ * 적용되는 설정:
+ * - com.android.application
+ * - org.jetbrains.kotlin.android
+ * - org.jetbrains.kotlin.plugin.compose
+ * - compileSdk, minSdk, JVM 11 (configureKotlinAndroid 헬퍼)
+ */
+class AndroidApplicationConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.android.application")
+ apply("org.jetbrains.kotlin.android")
+ apply("org.jetbrains.kotlin.plugin.compose")
+ apply("focpet.hilt")
+ }
+ extensions.configure {
+ configureKotlinAndroid(this)
+ // targetSdk는 compileSdk와 동일하게
+ defaultConfig.targetSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
+ buildFeatures {
+ compose = true
+ }
+ packaging {
+ resources {
+ excludes.add("META-INF/**")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidBuildConfigConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidBuildConfigConventionPlugin.kt
new file mode 100644
index 0000000..bb1acff
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidBuildConfigConventionPlugin.kt
@@ -0,0 +1,23 @@
+import com.android.build.api.dsl.ApplicationExtension
+import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.dsl.LibraryExtension
+import com.focpet.buildlogic.configureBuildTypes
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+/**
+ * BuildConfig의 사용을 필요한 곳에서만 설정할 수 있도록 설정하는 Convention Plugin
+ * 불필요한 buildconfig를 만드는 의존성을 분리하여 필요한 곳에서만 사용할 수 있게 구현
+ * */
+class AndroidBuildConfigConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ // 현재 모듈이 App인지 Library인지 확인해서 알맞은 Extension을 가져옵니다
+ val extension = extensions.findByType(ApplicationExtension::class.java)
+ ?: extensions.findByType(LibraryExtension::class.java)
+ ?: error("AndroidBuildConfigConventionPlugin은 안드로이드 애플리케이션 또는 라이브러리 플러그인이 적용된 이후에만 사용할 수 있습니다.")
+
+ configureBuildTypes(extension as CommonExtension<*, *, *, *, *, *>)
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
new file mode 100644
index 0000000..266ea70
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt
@@ -0,0 +1,34 @@
+import com.focpet.buildlogic.androidExtension
+import com.focpet.buildlogic.configureAndroidCompose
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Compose 설정용 Convention Plugin
+ * Compose가 필요한 모듈에 AndroidLibraryConventionPlugin과 함께 적용
+ *
+ * 단독으로 쓰이지 않고 Presentation/Data Plugin 내부에서 조합되어 사용
+ *
+ * 적용되는 설정:
+ * - org.jetbrains.kotlin.plugin.compose
+ * - buildFeatures.compose = true
+ * - ComposeCompiler sourceInformation
+ */
+class AndroidComposeConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
+ configureAndroidCompose(androidExtension)
+
+ dependencies {
+ val bom = libs.findLibrary("androidx.compose.bom").get()
+ "implementation"(platform(bom))
+ "implementation"(libs.findBundle("compose").get())
+ "debugImplementation"(libs.findLibrary("androidx.ui.tooling").get())
+ "debugImplementation"(libs.findLibrary("androidx.ui.test.manifest").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidDataConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidDataConventionPlugin.kt
new file mode 100644
index 0000000..7d74a5f
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidDataConventionPlugin.kt
@@ -0,0 +1,48 @@
+import com.android.build.api.dsl.LibraryExtension
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Data 모듈용 Convention Plugin
+ *
+ * library + hilt + serialization을 조합한 플러그인
+ * network(Retrofit), local(DataStore), repository 구현체가 여기에 위치
+ *
+ * 적용되는 설정:
+ * - convention.android.library (compileSdk, minSdk, JVM 11)
+ * - convention.hilt (Hilt 의존성)
+ * - convention.serialization (Serialization 플러그인)
+ * - domain 모듈 의존성 자동 추가
+ * - Retrofit, OkHttp, DataStore, kotlinx-serialization 의존성 자동 추가
+ */
+class AndroidDataConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("focpet.android.library")
+ apply("focpet.hilt")
+ apply("focpet.serialization")
+ }
+ extensions.configure {
+ packaging {
+ resources {
+ excludes.add("META-INF/**")
+ }
+ }
+ }
+ dependencies {
+ "implementation"(project(":core:network"))
+
+ // Local
+ "implementation"(libs.findLibrary("androidx.datastore.preferences").get())
+
+ "implementation"(libs.findLibrary("javax.inject").get())
+
+ "implementation"(libs.findBundle("retrofit").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
new file mode 100644
index 0000000..83f0ff4
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -0,0 +1,33 @@
+import com.android.build.api.dsl.LibraryExtension
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
+import com.focpet.buildlogic.configureKotlinAndroid
+import com.focpet.buildlogic.disableUnnecessaryAndroidTests
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+
+/**
+ * 일반 Android Library 모듈용 Convention Plugin
+ * core:common, core:designsystem, core:navigation, core:type 에서 사용
+ *
+ * 적용되는 설정:
+ * - com.android.library
+ * - org.jetbrains.kotlin.android
+ * - compileSdk, minSdk, JVM 11 (configureKotlinAndroid 헬퍼)
+ */
+class AndroidLibraryConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.android.library")
+ apply("org.jetbrains.kotlin.android")
+ }
+ extensions.configure {
+ configureKotlinAndroid(this)
+ }
+ extensions.configure {
+ disableUnnecessaryAndroidTests(target)
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidPresentationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidPresentationConventionPlugin.kt
new file mode 100644
index 0000000..531caf8
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidPresentationConventionPlugin.kt
@@ -0,0 +1,59 @@
+import com.android.build.api.dsl.LibraryExtension
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Presentation(feature) 모듈용 Convention Plugin
+ * auth, home, main, mypage 모듈에서 사용
+ *
+ * library + compose + hilt를 조합한 플러그인
+ * 세 플러그인을 매번 따로 적용하지 않고 이걸 하나만 적용
+ *
+ * 적용되는 설정:
+ * - focpet.android.library (compileSdk, minSdk, JVM 11)
+ * - focpet.android.compose (Compose 활성화)
+ * - focpet.hilt (Hilt 의존성)
+ * - domain, core 모듈 의존성 자동 추가
+ */
+class AndroidPresentationConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("focpet.android.library")
+ apply("focpet.android.compose")
+ apply("focpet.hilt")
+ apply("focpet.serialization")
+ }
+
+ // 리소스 패키징 옵션을 설정합니다
+ extensions.configure {
+ packaging {
+ resources {
+ excludes.add("META-INF/**")
+ }
+ }
+ }
+
+ dependencies {
+ "implementation"(project(":core:common"))
+ "implementation"(project(":core:designsystem"))
+ "implementation"(project(":core:navigation"))
+
+ // AndroidX
+ "implementation"(libs.findBundle("androidx").get())
+
+ // Hilt Navigation
+ "implementation"(libs.findLibrary("hilt-navigation-compose").get())
+
+ // Timber
+ "implementation"(libs.findLibrary("timber").get())
+
+ // Immutable
+ "implementation"(libs.findLibrary("kotlinx.immutable").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt
new file mode 100644
index 0000000..ac305d9
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt
@@ -0,0 +1,27 @@
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Android Test 설정용 Convention Plugin
+ * 테스트가 필요한 모듈에 추가로 적용
+ *
+ * 적용되는 설정:
+ * - JUnit, Espresso, Compose UI Test 의존성 자동 추가
+ */
+class AndroidTestConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ dependencies {
+ "testImplementation"(libs.findLibrary("junit").get())
+ "androidTestImplementation"(libs.findLibrary("androidx.junit").get())
+ "androidTestImplementation"(libs.findLibrary("androidx.espresso.core").get())
+ "androidTestImplementation"(platform(libs.findLibrary("androidx.compose.bom").get()))
+ "androidTestImplementation"(libs.findLibrary("androidx.ui.test.junit4").get())
+ "debugImplementation"(libs.findLibrary("androidx.ui.tooling").get())
+ "debugImplementation"(libs.findLibrary("androidx.ui.test.manifest").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt
new file mode 100644
index 0000000..78f7d24
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/HiltConventionPlugin.kt
@@ -0,0 +1,32 @@
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Hilt 의존성 주입 설정용 Convention Plugin
+ * Hilt가 필요한 모듈에 추가로 적용
+ *
+ * data, presentation 모듈에서 사용
+ * domain은 Hilt 불필요 (순수 Kotlin 인터페이스/UseCase만 존재)
+ *
+ * 적용되는 설정:
+ * - com.google.dagger.hilt.android 플러그인
+ * - com.google.devtools.ksp 플러그인 (어노테이션 처리)
+ * - hilt-android implementation
+ * - hilt-compiler ksp
+ */
+class HiltConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.google.dagger.hilt.android")
+ apply("com.google.devtools.ksp")
+ }
+ dependencies {
+ "implementation"(libs.findLibrary("hilt").get())
+ "ksp"(libs.findLibrary("hilt.compiler").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt
new file mode 100644
index 0000000..ee64ba6
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/KotlinJvmConventionPlugin.kt
@@ -0,0 +1,27 @@
+import com.focpet.buildlogic.configureKotlinJvm
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+
+/**
+ * 순수 Kotlin 모듈(domain)용 Convention Plugin
+ *
+ * domain 모듈은 Android 의존성이 없어야 해서
+ * com.android.library 대신 org.jetbrains.kotlin.jvm 사용
+ *
+ * 장점:
+ * - 빌드 속도가 Android 모듈보다 빠름
+ * - Android 의존성 실수로 추가하면 컴파일 에러로 바로 감지 가능
+ *
+ * 적용되는 설정:
+ * - org.jetbrains.kotlin.jvm
+ * - JVM 11
+ */
+class KotlinJvmConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ apply(plugin = "org.jetbrains.kotlin.jvm")
+ configureKotlinJvm()
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/SerializationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/SerializationConventionPlugin.kt
new file mode 100644
index 0000000..c694dfd
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/SerializationConventionPlugin.kt
@@ -0,0 +1,27 @@
+import com.focpet.buildlogic.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+/**
+ * Kotlin Serialization 설정용 Convention Plugin
+ * 네트워크 통신 시 JSON 직렬화/역직렬화에 사용
+ *
+ * data 모듈에서 Retrofit + kotlinx.serialization 조합으로 사용
+ *
+ * 적용되는 설정:
+ * - org.jetbrains.kotlin.plugin.serialization 플러그인
+ * (실제 kotlinx-serialization-json 라이브러리는
+ * AndroidDataConventionPlugin에서 의존성으로 추가)
+ */
+class SerializationConventionPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
+
+ dependencies {
+ "implementation"(libs.findLibrary("kotlinx.serialization.json").get())
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidCompose.kt
new file mode 100644
index 0000000..d4e0344
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidCompose.kt
@@ -0,0 +1,34 @@
+package com.focpet.buildlogic
+
+import com.android.build.api.dsl.CommonExtension
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.getByType
+import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
+
+/**
+ * Compose 관련 설정 헬퍼
+ * AndroidComposeConventionPlugin에서 호출
+ *
+ * Compose를 사용하는 모듈은 두 가지 필요
+ * 1. buildFeatures.compose = true → Compose 컴파일러 활성화
+ * 2. ComposeCompilerGradlePluginExtension → Compose Compiler 세부 옵션
+ *
+ * AGP 8.x부터는 kotlin.plugin.compose를 적용하면
+ * buildFeatures.compose가 자동 활성화되기도 하지만
+ * 명시적으로 선언해서 의도를 명확히 하는 게 좋을듯
+ */
+internal fun Project.configureAndroidCompose(
+ commonExtension: CommonExtension<*, *, *, *, *, *>,
+) {
+ commonExtension.apply {
+ buildFeatures {
+ compose = true
+ }
+ }
+
+ extensions.getByType().apply {
+ // Compose 소스 정보를 컴파일 결과에 포함시켜
+ // Layout Inspector에서 Composable 위치 추적이 가능해짐
+ includeSourceInformation.set(true)
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidInstrumentedTests.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidInstrumentedTests.kt
new file mode 100644
index 0000000..cfe9326
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/AndroidInstrumentedTests.kt
@@ -0,0 +1,16 @@
+package com.focpet.buildlogic
+
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
+import org.gradle.api.Project
+
+/**
+ * `androidTest` 소스셋이 없는 라이브러리 모듈의 경우,
+ * 불필요한 Android Instrumented Test 작업을 비활성화하여 빌드 시간을 최적화합니다.
+ * 기기에서 직접 실행해보는 테스트(androidTest)를 위한 준비 작업을 백그라운드에서 진행하는데 그걸 막기 위함
+ */
+fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
+ project: Project,
+) = beforeVariants { variant ->
+ variant.androidTest.enable = variant.androidTest.enable
+ && project.projectDir.resolve("src/androidTest").exists()
+}
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/BuildType.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/BuildType.kt
new file mode 100644
index 0000000..468cb9a
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/BuildType.kt
@@ -0,0 +1,62 @@
+package com.focpet.buildlogic
+
+import com.android.build.api.dsl.ApplicationExtension
+import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.dsl.LibraryExtension
+import org.gradle.api.Project
+import java.util.Properties
+
+/**
+ * Build Type을 세팅 헬퍼 - ApplicationExtension, LibraryExtension 공통
+ * */
+fun Project.configureBuildTypes(
+ commonExtension: CommonExtension<*, *, *, *, *, *>,
+) {
+ val properties = Properties().apply {
+ // 루트 디렉토리의 local.properties 파일을 읽어옵니다
+ project.rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
+ }
+
+ fun configureBuildTypeFields(
+ buildConfigField: (String, String, String) -> Unit,
+ isDebug: Boolean
+ ) {
+ // debug 환경이면 "dev", release 환경이면 "prod" 접두사를 사용합니다
+ val prefix = if (isDebug) "dev" else "prod"
+
+ buildConfigField(
+ "String",
+ "BASE_URL",
+ properties.getQuotedProperty("$prefix.base.url")
+ )
+ }
+
+ commonExtension.apply {
+ when (this) {
+ is ApplicationExtension -> {
+ buildFeatures { buildConfig = true }
+ buildTypes {
+ getByName("debug") { configureBuildTypeFields(::buildConfigField, true) }
+ getByName("release") { configureBuildTypeFields(::buildConfigField, false) }
+ }
+ }
+
+ is LibraryExtension -> {
+ buildFeatures { buildConfig = true }
+ buildTypes {
+ getByName("debug") { configureBuildTypeFields(::buildConfigField, true) }
+ getByName("release") { configureBuildTypeFields(::buildConfigField, false) }
+ }
+ }
+ }
+ }
+}
+
+// local.properties에서 값을 가져올 때, 양끝에 따옴표("")가 없으면 자동으로 붙여주는 안전 장치입니다
+private fun Properties.getQuotedProperty(key: String): String {
+ val value = getProperty(key) ?: ""
+ if (value.startsWith("\"") && value.endsWith("\"")) {
+ return value
+ }
+ return "\"$value\""
+}
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Extensions.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Extensions.kt
new file mode 100644
index 0000000..ddaf72d
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Extensions.kt
@@ -0,0 +1,31 @@
+package com.focpet.buildlogic
+
+import com.android.build.api.dsl.ApplicationExtension
+import com.android.build.api.dsl.CommonExtension
+import com.android.build.api.dsl.LibraryExtension
+import org.gradle.api.Project
+import org.gradle.api.artifacts.VersionCatalog
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.kotlin.dsl.getByType
+
+// build-logic Convention Plugin 안에서 libs.versions.toml에 접근하기 위한 확장함수
+// 일반 build.gradle.kts에서는 libs가 자동 주입되지만
+// Plugin 클래스 안(Convention Plugin 클래스 안에서는 build.gradle.kts와 달리)에서는 Project의 extension에서 직접 VersionCatalog를 꺼내는 확장함수가 필요
+// 모든 Convention Plugin에서 libs.findLibrary(), libs.findVersion() 형태로 사용
+val Project.libs: VersionCatalog
+ get() = extensions.getByType().named("libs")
+
+/**
+ * 모듈이 Application인지 Library인지 몰라도
+ * 공통 타입인 CommonExtension으로 접근할 수 있게 해주는 확장함수
+ *
+ * 예를 들어 configureKotlinAndroid()는 Application/Library 둘 다 지원해야 하는데
+ * 각각 타입이 달라서 CommonExtension으로 추상화해서 받 음
+ *
+ * Library → Application 순으로 시도하고 둘 다 없으면 에러 출력
+ */
+val Project.androidExtension: CommonExtension<*, *, *, *, *, *>
+ get() = runCatching { extensions.getByType() }
+ .recoverCatching { extensions.getByType() }
+ .onFailure { println("Could not find Library or Application extension") }
+ .getOrThrow()
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/KotlinAndroid.kt
new file mode 100644
index 0000000..4c10be3
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/KotlinAndroid.kt
@@ -0,0 +1,75 @@
+package com.focpet.buildlogic
+
+import com.android.build.api.dsl.CommonExtension
+import org.gradle.api.JavaVersion
+import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+/**
+ * Android 모듈(Library/Application) 공통 설정 헬퍼
+ * AndroidLibraryConventionPlugin, AndroidApplicationConventionPlugin에서 호출돼
+ *
+ * compileSdk, minSdk는 libs.versions.toml에서 읽어와서
+ * 버전 변경 시 toml 한 곳만 수정하면 댐
+ */
+internal fun Project.configureKotlinAndroid(
+ commonExtension: CommonExtension<*, *, *, *, *, *>,
+) {
+ commonExtension.apply {
+ // toml의 [versions]에서 읽어옴
+ // 모든 Android 모듈이 동일한 compileSdk를 사용하도록 강제
+ compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
+
+ defaultConfig {
+ // 지원하는 최소 Android 버전
+ minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
+ }
+
+ compileOptions {
+ // Java 소스 코드 호환성 버전
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ }
+
+ // Kotlin 컴파일 옵션은 Android/JVM 공통이라 별도 함수로 분리
+ configureKotlin()
+}
+
+/**
+ * 순수 Kotlin 모듈(domain) 공통 설정 헬퍼
+ * KotlinJvmConventionPlugin에서 호출
+ *
+ * Android 의존성이 없는 domain 모듈은 compileSdk/minSdk 설정이 필요 없어서
+ * configureKotlin()만 호출
+ */
+internal fun Project.configureKotlinJvm() {
+ extensions.configure {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ configureKotlin()
+}
+
+/**
+ * Kotlin 컴파일 옵션 공통 설정
+ * Android/JVM 모듈 모두 동일하게 적용되는 설정이라 private으로 분리
+ *
+ * freeCompilerArgs:
+ * - RequiresOptIn: @OptIn 어노테이션 사용을 위한 opt-in 설정
+ * (예: ExperimentalCoroutinesApi 같은 실험적 API 사용 시 필요)
+ */
+private fun Project.configureKotlin() {
+ tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ freeCompilerArgs.addAll(
+ listOf("-opt-in=kotlin.RequiresOptIn")
+ )
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Namespace.kt b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Namespace.kt
new file mode 100644
index 0000000..0d02da0
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/focpet/buildlogic/Namespace.kt
@@ -0,0 +1,12 @@
+package com.focpet.buildlogic
+
+import org.gradle.api.Project
+
+/**
+ * 모듈의 네임스페이스를 설정합니다. (예: "com.focpet.presentation.home")
+ */
+fun Project.setNamespace(name: String) {
+ androidExtension.apply {
+ namespace = "com.focpet.$name"
+ }
+}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
new file mode 100644
index 0000000..af69125
--- /dev/null
+++ b/build-logic/settings.gradle.kts
@@ -0,0 +1,16 @@
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "build-logic"
+include(":convention")
diff --git a/core/common/.gitignore b/core/common/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/common/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
new file mode 100644
index 0000000..28c3d5d
--- /dev/null
+++ b/core/common/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.focpet.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.focpet.android.library)
+ alias(libs.plugins.focpet.android.compose)
+}
+
+android {
+ setNamespace("core.common")
+}
diff --git a/core/common/src/main/AndroidManifest.xml b/core/common/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/common/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/common/src/main/java/com/focpet/core/common/base/UiState.kt b/core/common/src/main/java/com/focpet/core/common/base/UiState.kt
new file mode 100644
index 0000000..5d75510
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/base/UiState.kt
@@ -0,0 +1,18 @@
+package com.focpet.core.common.base
+
+import androidx.compose.runtime.Stable
+
+@Stable
+sealed interface UiState {
+ data object Empty : UiState
+
+ data object Loading : UiState
+
+ data class Success(
+ val data: T,
+ ) : UiState
+
+ data class Failure(
+ val message: String,
+ ) : UiState
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/extension/FlowExt.kt b/core/common/src/main/java/com/focpet/core/common/extension/FlowExt.kt
new file mode 100644
index 0000000..748d9b3
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/extension/FlowExt.kt
@@ -0,0 +1,48 @@
+package com.focpet.core.common.extension
+
+import android.os.SystemClock
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.flowWithLifecycle
+import kotlinx.coroutines.flow.Flow
+
+@Composable
+fun Flow.collectSideEffect(
+ lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
+ minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
+ action: suspend (T) -> Unit
+) {
+ val lifecycle = lifecycleOwner.lifecycle
+
+ LaunchedEffect(this, lifecycle) {
+ flowWithLifecycle(lifecycle, minActiveState)
+ .collect(action)
+ }
+}
+
+@Composable
+fun Flow.collectSingleEvent(
+ lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
+ throttleTime: Long = 500L,
+ action: suspend (T) -> Unit
+) {
+ val lifecycle = lifecycleOwner.lifecycle
+ var lastEmitTime by remember { mutableLongStateOf(0L) }
+
+ LaunchedEffect(this, lifecycle) {
+ flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { value ->
+ val currentTime = SystemClock.uptimeMillis()
+ if (currentTime - lastEmitTime > throttleTime) {
+ lastEmitTime = currentTime
+ action(value)
+ }
+ }
+ }
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/extension/ModifierExt.kt b/core/common/src/main/java/com/focpet/core/common/extension/ModifierExt.kt
new file mode 100644
index 0000000..7ee0ca5
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/extension/ModifierExt.kt
@@ -0,0 +1,26 @@
+package com.focpet.core.common.extension
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.dp
+
+fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed {
+ this.clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ onClick()
+ }
+}
+
+fun Modifier.forcePixelToDp(painter: Painter): Modifier {
+ return this.size(
+ width = painter.intrinsicSize.width.dp,
+ height = painter.intrinsicSize.height.dp
+ )
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/extension/StateFlowExt.kt b/core/common/src/main/java/com/focpet/core/common/extension/StateFlowExt.kt
new file mode 100644
index 0000000..9d926fa
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/extension/StateFlowExt.kt
@@ -0,0 +1,18 @@
+package com.focpet.core.common.extension
+
+import android.provider.SyncStateContract.Helpers.update
+import com.kiero.core.model.UiState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+
+inline fun MutableStateFlow>.updateSuccess(
+ crossinline onUpdate: (T) -> T
+) {
+ update { currentState ->
+ if (currentState is UiState.Success) {
+ currentState.copy(data = onUpdate(currentState.data))
+ } else {
+ currentState
+ }
+ }
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/extension/StringExt.kt b/core/common/src/main/java/com/focpet/core/common/extension/StringExt.kt
new file mode 100644
index 0000000..e69de29
diff --git a/core/common/src/main/java/com/focpet/core/common/extension/ThrowableExt.kt b/core/common/src/main/java/com/focpet/core/common/extension/ThrowableExt.kt
new file mode 100644
index 0000000..c3b077e
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/extension/ThrowableExt.kt
@@ -0,0 +1,16 @@
+package com.focpet.core.common.extension
+
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import java.util.concurrent.TimeoutException
+
+fun Throwable.toHandleErrorMessage(): String {
+ return when (this) {
+ is TimeoutException -> "네트워크 시간이 초과되었습니다. 다시 시도해주세요."
+ is SocketTimeoutException -> "네트워크 시간이 초과되었습니다. 다시 시도해주세요"
+ is UnknownHostException -> "서버에 연결할 수 없습니다. 인터넷 연결을 확인하세요."
+ is IOException -> "네트워크 연결에 문제가 발생했습니다. 다시 시도하세요."
+ else -> "알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도하세요."
+ }
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/model/DialogState.kt b/core/common/src/main/java/com/focpet/core/common/model/DialogState.kt
new file mode 100644
index 0000000..d7cca53
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/model/DialogState.kt
@@ -0,0 +1,17 @@
+package com.focpet.core.common.model
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+
+@Immutable
+data class DialogState(
+ val isVisible: Boolean = false,
+ val onClickAction: () -> Unit = {}
+)
+
+@Stable
+class DialogTrigger(
+ val show: (() -> Unit) -> Unit,
+ val dismiss: () -> Unit
+)
+
diff --git a/core/common/src/main/java/com/focpet/core/common/model/GlobalUiEventHolder.kt b/core/common/src/main/java/com/focpet/core/common/model/GlobalUiEventHolder.kt
new file mode 100644
index 0000000..2e81221
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/model/GlobalUiEventHolder.kt
@@ -0,0 +1,10 @@
+package com.focpet.core.common.model
+
+import androidx.compose.runtime.Stable
+
+@Stable
+class GlobalUiEventHolder(
+ val dialogTrigger: DialogTrigger,
+ val showToast: (String) -> Unit,
+ val showSnackbar: (SnackbarState) -> Unit,
+)
diff --git a/core/common/src/main/java/com/focpet/core/common/model/SnackbarState.kt b/core/common/src/main/java/com/focpet/core/common/model/SnackbarState.kt
new file mode 100644
index 0000000..efe64ca
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/model/SnackbarState.kt
@@ -0,0 +1,9 @@
+package com.focpet.core.common.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class SnackbarState(
+ val message: String = "",
+ val bottomPadding: Int = 30
+)
diff --git a/core/common/src/main/java/com/focpet/core/common/trigger/LocalGlobalUiEventTrigger.kt b/core/common/src/main/java/com/focpet/core/common/trigger/LocalGlobalUiEventTrigger.kt
new file mode 100644
index 0000000..e4e220c
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/trigger/LocalGlobalUiEventTrigger.kt
@@ -0,0 +1,8 @@
+package com.focpet.core.common.trigger
+
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.focpet.core.common.model.GlobalUiEventHolder
+
+val LocalGlobalUiEventTrigger = staticCompositionLocalOf {
+ error("No GlobalUiEvent Trigger provided")
+}
diff --git a/core/common/src/main/java/com/focpet/core/common/util/SuspendRunCatching.kt b/core/common/src/main/java/com/focpet/core/common/util/SuspendRunCatching.kt
new file mode 100644
index 0000000..64d239c
--- /dev/null
+++ b/core/common/src/main/java/com/focpet/core/common/util/SuspendRunCatching.kt
@@ -0,0 +1,19 @@
+package com.focpet.core.common.util
+
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.ensureActive
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.coroutines.coroutineContext
+
+suspend inline fun suspendRunCatching(block: suspend () -> R): Result {
+ return try {
+ Result.success(block())
+ } catch (t: TimeoutCancellationException) {
+ Result.failure(t)
+ } catch (c: CancellationException) {
+ throw c
+ } catch (e: Throwable) {
+ coroutineContext.ensureActive()
+ Result.failure(e)
+ }
+}
diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/designsystem/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts
new file mode 100644
index 0000000..0a41800
--- /dev/null
+++ b/core/designsystem/build.gradle.kts
@@ -0,0 +1,10 @@
+import com.focpet.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.focpet.android.library)
+ alias(libs.plugins.focpet.android.compose)
+}
+
+android {
+ setNamespace("core.designsystem")
+}
diff --git a/core/designsystem/src/main/AndroidManifest.xml b/core/designsystem/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/designsystem/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/java/com/focpet/ui/theme/Color.kt b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Color.kt
similarity index 74%
rename from app/src/main/java/com/focpet/ui/theme/Color.kt
rename to core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Color.kt
index ef9d894..441e2c4 100644
--- a/app/src/main/java/com/focpet/ui/theme/Color.kt
+++ b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Color.kt
@@ -1,4 +1,4 @@
-package com.focpet.ui.theme
+package com.focpet.core.designsystem.theme
import androidx.compose.ui.graphics.Color
@@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
+val Pink40 = Color(0xFF7D5260)
diff --git a/app/src/main/java/com/focpet/ui/theme/Theme.kt b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Theme.kt
similarity index 97%
rename from app/src/main/java/com/focpet/ui/theme/Theme.kt
rename to core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Theme.kt
index d9db1ed..e895863 100644
--- a/app/src/main/java/com/focpet/ui/theme/Theme.kt
+++ b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Theme.kt
@@ -1,4 +1,4 @@
-package com.focpet.ui.theme
+package com.focpet.core.designsystem.theme
import android.app.Activity
import android.os.Build
diff --git a/app/src/main/java/com/focpet/ui/theme/Type.kt b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Type.kt
similarity index 95%
rename from app/src/main/java/com/focpet/ui/theme/Type.kt
rename to core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Type.kt
index 329f7a0..898e1b8 100644
--- a/app/src/main/java/com/focpet/ui/theme/Type.kt
+++ b/core/designsystem/src/main/java/com/focpet/core/designsystem/theme/Type.kt
@@ -1,4 +1,4 @@
-package com.focpet.ui.theme
+package com.focpet.core.designsystem.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
@@ -31,4 +31,4 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
*/
-)
\ No newline at end of file
+)
diff --git a/core/designsystem/src/main/res/font/pretendard_bold.otf b/core/designsystem/src/main/res/font/pretendard_bold.otf
new file mode 100644
index 0000000..8e5e30a
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_bold.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_medium.otf b/core/designsystem/src/main/res/font/pretendard_medium.otf
new file mode 100644
index 0000000..0575069
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_medium.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_regular.otf b/core/designsystem/src/main/res/font/pretendard_regular.otf
new file mode 100644
index 0000000..08bf4cf
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_regular.otf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_semibold.otf b/core/designsystem/src/main/res/font/pretendard_semibold.otf
new file mode 100644
index 0000000..e7e36ab
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_semibold.otf differ
diff --git a/core/localstorage/.gitignore b/core/localstorage/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/localstorage/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/localstorage/build.gradle.kts b/core/localstorage/build.gradle.kts
new file mode 100644
index 0000000..e69de29
diff --git a/core/localstorage/src/main/AndroidManifest.xml b/core/localstorage/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/localstorage/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/navigation/.gitignore b/core/navigation/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/navigation/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
new file mode 100644
index 0000000..8c8b8a4
--- /dev/null
+++ b/core/navigation/build.gradle.kts
@@ -0,0 +1,9 @@
+import com.focpet.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.focpet.android.library)
+}
+
+android {
+ setNamespace("core.navigation")
+}
diff --git a/core/navigation/src/main/java/com/focpet/core/navigation/MainTabRoute.kt b/core/navigation/src/main/java/com/focpet/core/navigation/MainTabRoute.kt
new file mode 100644
index 0000000..5c31a92
--- /dev/null
+++ b/core/navigation/src/main/java/com/focpet/core/navigation/MainTabRoute.kt
@@ -0,0 +1,3 @@
+package com.focpet.core.navigation
+
+interface MainTabRoute : Route
diff --git a/core/navigation/src/main/java/com/focpet/core/navigation/Route.kt b/core/navigation/src/main/java/com/focpet/core/navigation/Route.kt
new file mode 100644
index 0000000..ce989bc
--- /dev/null
+++ b/core/navigation/src/main/java/com/focpet/core/navigation/Route.kt
@@ -0,0 +1,3 @@
+package com.focpet.core.navigation
+
+interface Route
diff --git a/core/network/.gitignore b/core/network/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/network/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
new file mode 100644
index 0000000..9fec6e9
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,20 @@
+import com.focpet.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.focpet.android.library)
+ alias(libs.plugins.focpet.hilt)
+ alias(libs.plugins.focpet.serialization)
+ alias(libs.plugins.focpet.android.buildconfig)
+}
+
+android {
+ setNamespace("core.network")
+}
+
+dependencies {
+ implementation(platform(libs.okhttp.bom))
+ implementation(libs.bundles.okhttp)
+ implementation(libs.bundles.retrofit)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.timber)
+}
diff --git a/core/network/consumer-rules.pro b/core/network/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/core/network/proguard-rules.pro b/core/network/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/core/network/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/core/network/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/network/src/main/java/com/focpet/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/focpet/core/network/di/NetworkModule.kt
new file mode 100644
index 0000000..e46d0f5
--- /dev/null
+++ b/core/network/src/main/java/com/focpet/core/network/di/NetworkModule.kt
@@ -0,0 +1,55 @@
+package com.focpet.core.network.di
+
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+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.Converter
+import retrofit2.Retrofit
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun providesLoggingInterceptor() = HttpLoggingInterceptor().apply {
+ level = if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ }
+
+ @Provides
+ @Singleton
+ fun providesOkHttpClient(
+ loggingInterceptor: HttpLoggingInterceptor,
+ ): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(loggingInterceptor)
+ .build()
+
+ @Provides
+ @Singleton
+ fun providesConverterFactory(): Converter.Factory = Json.Default.asConverterFactory("application/json".toMediaType())
+
+ @Provides
+ @Singleton
+ fun providesRetrofit(
+ client: OkHttpClient,
+ converterFactory: Converter.Factory
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl(BuildConfig.BASE_URL)
+ .addConverterFactory(converterFactory)
+ .client(client)
+ .build()
+}
diff --git a/core/network/src/main/java/com/focpet/core/network/model/BaseResponse.kt b/core/network/src/main/java/com/focpet/core/network/model/BaseResponse.kt
new file mode 100644
index 0000000..3a7b3d4
--- /dev/null
+++ b/core/network/src/main/java/com/focpet/core/network/model/BaseResponse.kt
@@ -0,0 +1,17 @@
+package com.focpet.core.network.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/*
+@Serializable
+data class BaseResponse(
+ @SerialName("status")
+ val status: Int,
+ @SerialName("message")
+ val message: String,
+ @SerialName("data")
+ val data: T? = null,
+)
+*/
+
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/domain/.gitkeep b/domain/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/gradle.properties b/gradle.properties
index 132244e..3e88ee2 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,23 +1,29 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. For more details, visit
-# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
-# org.gradle.parallel=true
-# AndroidX package structure to make it clearer which packages are bundled with the
-# Android operating system, and which are packaged with your app's APK
-# https://developer.android.com/topic/libraries/support-library/androidx-rn
+# Gradle Daemon JVM arguments and file encoding settings
+org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
+
+# Enable parallel task execution
+org.gradle.parallel=true
+
+# Enable build cache
+org.gradle.caching=true
+
+# Enable configuration cache
+org.gradle.configuration-cache=true
+
+# Enable parallel configuration cache
+org.gradle.configuration-cache.parallel=true
+
+# Use AndroidX libraries
android.useAndroidX=true
-# Kotlin code style for this project: "official" or "obsolete":
+
+# Kotlin code style settings
kotlin.code.style=official
-# Enables namespacing of each library's R class so that its R class includes only the
-# resources declared in the library itself and none from the library's dependencies,
-# thereby reducing the size of the R class for that library
+
+# Use non-transitive R classes (improves build speed)
android.nonTransitiveRClass=true
+
+# Use AGP built-in Kotlin support
+android.builtInKotlin=true
+
+# Enforce unique package names
+android.uniquePackageNames=true
diff --git a/presentation/.gitignore b/presentation/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/presentation/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts
new file mode 100644
index 0000000..f3bd531
--- /dev/null
+++ b/presentation/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("java-library")
+ alias(libs.plugins.jetbrains.kotlin.jvm)
+}
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
+ }
+}
diff --git a/presentation/main/.gitignore b/presentation/main/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/presentation/main/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/presentation/main/build.gradle.kts b/presentation/main/build.gradle.kts
new file mode 100644
index 0000000..8793dfd
--- /dev/null
+++ b/presentation/main/build.gradle.kts
@@ -0,0 +1,16 @@
+import com.focpet.buildlogic.setNamespace
+
+plugins {
+ alias(libs.plugins.focpet.android.presentation)
+}
+
+android {
+ setNamespace("presentation.main")
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+}
diff --git a/presentation/main/consumer-rules.pro b/presentation/main/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/presentation/main/proguard-rules.pro b/presentation/main/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/presentation/main/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/presentation/main/src/main/AndroidManifest.xml b/presentation/main/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8bdb7e1
--- /dev/null
+++ b/presentation/main/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/presentation/main/src/main/java/com/focpet/presentation/main/MainActivity.kt b/presentation/main/src/main/java/com/focpet/presentation/main/MainActivity.kt
new file mode 100644
index 0000000..166c189
--- /dev/null
+++ b/presentation/main/src/main/java/com/focpet/presentation/main/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.focpet.presentation.main
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.focpet.core.designsystem.theme.FocpetTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ FocpetTheme {
+ MainScreen()
+ }
+ }
+ }
+}
+
diff --git a/presentation/main/src/main/java/com/focpet/presentation/main/MainScreen.kt b/presentation/main/src/main/java/com/focpet/presentation/main/MainScreen.kt
new file mode 100644
index 0000000..2df1b00
--- /dev/null
+++ b/presentation/main/src/main/java/com/focpet/presentation/main/MainScreen.kt
@@ -0,0 +1,84 @@
+package com.focpet.presentation.main
+
+import android.widget.Toast
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.focpet.core.common.model.DialogTrigger
+import com.focpet.core.common.model.GlobalUiEventHolder
+import com.focpet.core.common.model.SnackbarState
+import com.focpet.core.common.trigger.LocalGlobalUiEventTrigger
+import com.focpet.presentation.main.state.MainAppState
+import com.focpet.presentation.main.state.rememberDialogStateHolder
+import com.focpet.presentation.main.state.rememberMainAppState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+internal fun MainScreen(
+ appState: MainAppState = rememberMainAppState(),
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ val dialogState = rememberDialogStateHolder()
+
+ val snackBarHostState = remember { SnackbarHostState() }
+ var currentSnackbarState by remember { mutableStateOf(null) }
+
+ val onShowToast: (String) -> Unit = remember {
+ { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ val onShowSnackbar: (SnackbarState) -> Unit = remember(scope, snackBarHostState) {
+ { state ->
+ currentSnackbarState = state
+ scope.launch {
+ snackBarHostState.currentSnackbarData?.dismiss()
+
+ val job = launch {
+ snackBarHostState.showSnackbar(
+ message = state.message,
+ )
+ }
+ job.invokeOnCompletion {
+ if (currentSnackbarState == state) {
+ currentSnackbarState = null
+ }
+ }
+ delay(2000L)
+ job.cancel()
+ }
+ }
+ }
+
+ val eventHolder = remember(dialogState, onShowToast, onShowSnackbar) {
+ GlobalUiEventHolder(
+ dialogTrigger = DialogTrigger(
+ show = { onConfirm ->
+ dialogState.showDialog(onConfirm)
+ },
+ dismiss = {
+ dialogState.dismissDialog()
+ }
+ ),
+ showToast = onShowToast,
+ showSnackbar = onShowSnackbar,
+ )
+ }
+
+ CompositionLocalProvider(
+ LocalGlobalUiEventTrigger provides eventHolder,
+ ) {
+
+ }
+}
diff --git a/presentation/main/src/main/java/com/focpet/presentation/main/MainTab.kt b/presentation/main/src/main/java/com/focpet/presentation/main/MainTab.kt
new file mode 100644
index 0000000..27a5570
--- /dev/null
+++ b/presentation/main/src/main/java/com/focpet/presentation/main/MainTab.kt
@@ -0,0 +1,19 @@
+package com.focpet.presentation.main
+
+import androidx.annotation.DrawableRes
+import com.focpet.core.navigation.MainTabRoute
+import com.focpet.core.navigation.Route
+
+internal enum class MainTab(
+ @param:DrawableRes val iconRes: Int,
+ val route: MainTabRoute,
+ val label: String,
+) {
+ ;
+
+ companion object {
+ fun find(predicate: (MainTabRoute) -> Boolean): MainTab? = entries.find { predicate(it.route) }
+
+ fun contains(predicate: (Route) -> Boolean): Boolean = entries.map { it.route }.any { predicate(it) }
+ }
+}
diff --git a/presentation/main/src/main/java/com/focpet/presentation/main/state/DialogStateHolder.kt b/presentation/main/src/main/java/com/focpet/presentation/main/state/DialogStateHolder.kt
new file mode 100644
index 0000000..9462bf7
--- /dev/null
+++ b/presentation/main/src/main/java/com/focpet/presentation/main/state/DialogStateHolder.kt
@@ -0,0 +1,28 @@
+package com.focpet.presentation.main.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.focpet.core.common.model.DialogState
+
+@Stable
+class DialogStateHolder {
+ var dialogState by mutableStateOf(DialogState())
+ private set
+
+ fun showDialog(onClick: () -> Unit) {
+ dialogState = DialogState(isVisible = true, onClickAction = onClick)
+ }
+
+ fun dismissDialog() {
+ dialogState = dialogState.copy(isVisible = false)
+ }
+}
+
+@Composable
+fun rememberDialogStateHolder(): DialogStateHolder = remember {
+ DialogStateHolder()
+}
diff --git a/presentation/main/src/main/java/com/focpet/presentation/main/state/MainAppState.kt b/presentation/main/src/main/java/com/focpet/presentation/main/state/MainAppState.kt
new file mode 100644
index 0000000..1af99ab
--- /dev/null
+++ b/presentation/main/src/main/java/com/focpet/presentation/main/state/MainAppState.kt
@@ -0,0 +1,110 @@
+package com.focpet.presentation.main.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.navigation.NavDestination.Companion.hasRoute
+import androidx.navigation.NavHostController
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navOptions
+import com.focpet.presentation.main.MainTab
+import java.time.LocalDate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+
+@Stable
+internal class MainAppState(
+ val navController: NavHostController,
+ coroutineScope: CoroutineScope,
+) {
+ /*val startDestination = Splash
+
+ val isOffline: StateFlow = networkMonitor.isOnline
+ .map(Boolean::not)
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = null
+ )*/
+
+ private val currentDestination = navController.currentBackStackEntryFlow
+ .map { it.destination }
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = null
+ )
+
+ val currentTab: StateFlow = currentDestination
+ .map { destination ->
+ MainTab.find { tab ->
+ destination?.hasRoute(tab::class) == true
+ }
+ }
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = null
+ )
+
+ val isBottomBarVisible: StateFlow = currentDestination
+ .map { destination ->
+ MainTab.contains { tab ->
+ destination?.hasRoute(tab::class) == true
+ }
+ }
+ .stateIn(
+ scope = coroutineScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = null
+ )
+
+ fun navigate(tab: MainTab) {
+ val navOptions = navOptions {
+ navController.currentDestination?.route?.let {
+ popUpTo(it) {
+ inclusive = true
+ saveState = true
+ }
+ restoreState = true
+ launchSingleTop = true
+ }
+ }
+
+ val refreshNavOptions = navOptions {
+ popUpTo(0) {
+ inclusive = true
+ }
+ launchSingleTop
+ }
+
+ when (tab) {
+
+ }
+ }
+
+ private val clearStackNavOptions = navOptions {
+ popUpTo(0) {
+ inclusive = true
+ }
+ launchSingleTop = true
+ }
+
+ fun navigateUp() {
+ navController.navigateUp()
+ }
+}
+
+@Composable
+internal fun rememberMainAppState(
+ //networkMonitor: NetworkMonitor,
+ navController: NavHostController = rememberNavController(),
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+): MainAppState = remember(navController, coroutineScope) {
+ MainAppState(navController, coroutineScope)
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ae7281f..c1c943e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -22,3 +22,20 @@ dependencyResolutionManagement {
rootProject.name = "Focpet"
include(":app")
+
+// core
+include(":core:designsystem")
+include(":core:common")
+include(":core:navigation")
+include(":core:network")
+include(":core:localstorage")
+
+// data
+include(":data")
+
+// domain
+include(":domain")
+
+// presentation
+include(":presentation:main")
+