diff --git a/.editorconfig b/.editorconfig
index 6e889956..4f9503c7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,7 +1,21 @@
-# https://editorconfig.org/
-# This configuration is used by ktlint when spotless invokes it
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = unset
+indent_style = space
+trim_trailing_whitespace = true
+insert_final_newline = true
+max_line_length = 120
+tab_width = unset
+
+[*.{yaml,yml}]
+tab_width = 2
+indent_size = 2
[*.{kt,kts}]
-ij_kotlin_allow_trailing_comma=true
-ij_kotlin_allow_trailing_comma_on_call_site=true
+indent_size = 4
+tab_width = 4
+ktlint_code_style = android_studio
ktlint_function_naming_ignore_when_annotated_with=Composable, Test
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md
new file mode 100644
index 00000000..0db631f9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue-template.md
@@ -0,0 +1,14 @@
+---
+name: Issue Template
+about: 이슈 템플릿
+title: "[Type] 이슈 내용"
+labels: ''
+assignees: ''
+
+---
+
+## ISSUE
+-
+
+## To-Do
+- [ ] 작업할 내용
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..a3340d77
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,10 @@
+## 관련 이슈
+- closed #이슈넘버
+
+## 작업한 내용
+-
+
+## PR 포인트
+-
+
+## 🚀Next Feature
diff --git a/.github/workflows/PR_Builder.yml b/.github/workflows/PR_Builder.yml
new file mode 100644
index 00000000..834a6091
--- /dev/null
+++ b/.github/workflows/PR_Builder.yml
@@ -0,0 +1,72 @@
+name: Hous PR Builder
+
+on:
+ pull_request:
+ branches: [ develop, master ]
+
+jobs:
+ build:
+ name: PR Checker
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Gradle cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ distribution: 'temurin'
+ java-version: 17
+
+ # - name: Create Google-Services.json
+ # env:
+ # GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }}
+ # run: |
+ # touch ./app/google-services.json
+ # echo $GOOGLE_SERVICES >> ./app/google-services.json
+ # cat ./app/google-services.json
+ #
+ - name: Create Local Properties
+ run: touch local.properties
+
+ - name: Access Local Properties
+ env:
+ FUNCH_DEBUG_BASE_URL: ${{ secrets.FUNCH_DEBUG_BASE_URL }}
+ STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ STORE_FILE: ${{ secrets.STORE_FILE }}
+ run: |
+ echo FUNCH_DEBUG_BASE_URL=\"FUNCH_DEBUG_BASE_URL\" >> local.properties
+ echo STORE_PASSWORD= $STORE_PASSWORD >> local.properties
+ echo KEY_PASSWORD= $KEY_PASSWORD >> local.properties
+ echo KEY_ALIAS= $KEY_ALIAS >> local.properties
+ echo STORE_FILE= $STORE_FILE >> local.properties
+
+ - name: Create Key Store
+ env:
+ KEY_STORE_BASE_64: ${{secrets.KEY_STORE_BASE_64}}
+ run: |
+ echo "$KEY_STORE_BASE_64" | base64 -d > ./funch_key_store.jks
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Lint Check
+ run: ./gradlew ktlintCheck
+
+ - name: run rest
+ run: ./gradlew test
+
+ - name: Build with Gradle
+ run: ./gradlew build
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 44d96aeb..e691c560 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,21 +1,37 @@
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+import java.io.FileInputStream
+import org.jetbrains.kotlin.konan.properties.Properties
+
plugins {
- alias(libs.plugins.androidApplication)
- alias(libs.plugins.kotlinAndroid)
+ alias(libs.plugins.funch.application)
+ alias(libs.plugins.funch.compose)
+// alias(libs.plugins.google.services)
+// alias(libs.plugins.app.distribution)
+// alias(libs.plugins.crashlytics)
}
android {
- namespace = "com.moya.punch"
- compileSdk = 34
+ namespace = "com.moya.funch"
+
+ packaging {
+ resources.excludes.add("META-INF/LICENSE*")
+ }
defaultConfig {
- applicationId = "com.moya.punch"
- minSdk = 28
- targetSdk = 34
- versionCode = 1
- versionName = "1.0"
+ applicationId = "com.moya.funch"
+ versionCode = libs.versions.versionCode.get().toInt()
+ versionName = libs.versions.appVersion.get()
+ }
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ signingConfigs {
+ create("release") {
+ Properties().apply {
+ load(FileInputStream(rootProject.file("local.properties")))
+ storeFile = rootProject.file(this["STORE_FILE"] as String)
+ keyAlias = this["KEY_ALIAS"] as String
+ keyPassword = this["KEY_PASSWORD"] as String
+ storePassword = this["STORE_PASSWORD"] as String
+ }
+ }
}
buildTypes {
@@ -25,23 +41,48 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ signingConfig = signingConfigs.getByName("release")
}
}
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = "1.8"
- }
}
dependencies {
+ // core
+ implementation(projects.core.designsystem)
+ implementation(projects.core.domain)
+ implementation(projects.core.data)
+ implementation(projects.core.datastore)
+ // feature
+ implementation(projects.feature.profile)
+ implementation(projects.feature.home)
+ implementation(projects.feature.match)
+ implementation(projects.feature.onboarding)
+// implementation(libs.coil.core)
+ implementation(libs.startup)
+// implementation(libs.security)
+ implementation(libs.splash.screen)
+
+ // Google
+// implementation(libs.google.android.gms)
- implementation(libs.core.ktx)
- implementation(libs.appcompat)
- implementation(libs.material)
- testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.espresso.core)
-}
\ No newline at end of file
+ // Third Party
+ implementation(libs.compose.lottie)
+ implementation(libs.coil.core)
+// implementation(libs.kakao.login)
+ implementation(libs.bundles.retrofit)
+ implementation(libs.lifecycle)
+ implementation(libs.activity.compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.ui)
+ implementation(libs.ui.graphics)
+ implementation(libs.ui.tooling.preview)
+ implementation(libs.material3.compose)
+ androidTestImplementation(platform(libs.compose.bom))
+ androidTestImplementation(libs.ui.test.junit4)
+ debugImplementation(libs.ui.tooling)
+ debugImplementation(libs.ui.test.manifest)
+
+ // Firebase
+// implementation(platform(libs.firebase))
+// implementation(libs.bundles.firebase)
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1b94fcee..4aee9d90 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,15 +2,31 @@
+
+
+
+ android:theme="@style/Theme.FunchAOS.Splash"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="34">
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/ic_funch_logo-playstore.png b/app/src/main/ic_funch_logo-playstore.png
new file mode 100644
index 00000000..0518c993
Binary files /dev/null and b/app/src/main/ic_funch_logo-playstore.png differ
diff --git a/app/src/main/java/com/moya/funch/FunchApplication.kt b/app/src/main/java/com/moya/funch/FunchApplication.kt
new file mode 100644
index 00000000..97931a1f
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/FunchApplication.kt
@@ -0,0 +1,25 @@
+package com.moya.funch
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+
+@HiltAndroidApp
+class FunchApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ initTimber()
+ }
+
+ private fun initTimber() {
+ if (BuildConfig.DEBUG) {
+ Timber.plant(
+ object : Timber.DebugTree() {
+ override fun createStackElementTag(element: StackTraceElement): String {
+ return "${element.fileName} : ${element.lineNumber} - ${element.methodName}"
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/MainActivity.kt b/app/src/main/java/com/moya/funch/MainActivity.kt
new file mode 100644
index 00000000..7867ebe2
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/MainActivity.kt
@@ -0,0 +1,46 @@
+package com.moya.funch
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.splash.LoadingScreen
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.ui.FunchApp
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import kotlinx.coroutines.delay
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var dataStore: UserDataStore
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ installSplashScreen()
+ setContent {
+ FunchTheme {
+ var showLoading by remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ delay(1500)
+ showLoading = false
+ }
+
+ if (showLoading) {
+ LoadingScreen()
+ } else {
+ FunchApp(dataStore = dataStore)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/di/MatchModule.kt b/app/src/main/java/com/moya/funch/di/MatchModule.kt
new file mode 100644
index 00000000..9f4239bc
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/di/MatchModule.kt
@@ -0,0 +1,38 @@
+package com.moya.funch.di
+
+import com.moya.funch.repository.MatchingRepository
+import com.moya.funch.repository.MatchingRepositoryImpl
+import com.moya.funch.usecase.CanMatchProfileUseCase
+import com.moya.funch.usecase.CanMatchProfileUseCaseImpl
+import com.moya.funch.usecase.MatchProfileUseCase
+import com.moya.funch.usecase.MatchProfileUseCaseImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object MatchModule {
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface UseCaseBinder {
+ @Binds
+ @Singleton
+ fun bindCanMatchUseCase(useCase: CanMatchProfileUseCaseImpl): CanMatchProfileUseCase
+
+ @Binds
+ @Singleton
+ fun bindMatchProfileUseCase(useCase: MatchProfileUseCaseImpl): MatchProfileUseCase
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface RepositoryBinder {
+ @Binds
+ @Singleton
+ fun bindMatchingRepository(repository: MatchingRepositoryImpl): MatchingRepository
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/di/MemberModule.kt b/app/src/main/java/com/moya/funch/di/MemberModule.kt
new file mode 100644
index 00000000..5e17b5f2
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/di/MemberModule.kt
@@ -0,0 +1,44 @@
+package com.moya.funch.di
+
+import com.moya.funch.repository.MemberRepository
+import com.moya.funch.repository.MemberRepositoryImpl
+import com.moya.funch.usecase.CreateUserProfileUseCase
+import com.moya.funch.usecase.CreateUserProfileUseCaseImpl
+import com.moya.funch.usecase.LoadUserProfileUseCase
+import com.moya.funch.usecase.LoadUserProfileUseCaseImpl
+import com.moya.funch.usecase.LoadViewCountUseCase
+import com.moya.funch.usecase.LoadViewCountUseCaseImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object MemberModule {
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface UseCaseBinder {
+ @Binds
+ @Singleton
+ fun bindLoadUserProfileUseCase(useCase: LoadUserProfileUseCaseImpl): LoadUserProfileUseCase
+
+ @Binds
+ @Singleton
+ fun bindLoadViewCountUseCase(useCase: LoadViewCountUseCaseImpl): LoadViewCountUseCase
+
+ @Binds
+ @Singleton
+ fun bindCreateUserProfileUseCase(useCase: CreateUserProfileUseCaseImpl): CreateUserProfileUseCase
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface RepositoryBinder {
+ @Binds
+ @Singleton
+ fun bindMemberRepository(repository: MemberRepositoryImpl): MemberRepository
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/di/SubwayModule.kt b/app/src/main/java/com/moya/funch/di/SubwayModule.kt
new file mode 100644
index 00000000..66ba5b26
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/di/SubwayModule.kt
@@ -0,0 +1,32 @@
+package com.moya.funch.di
+
+import com.moya.funch.repository.SubwayRepository
+import com.moya.funch.repository.SubwayRepositoryImpl
+import com.moya.funch.usecase.LoadSubwayStationsUseCase
+import com.moya.funch.usecase.LoadSubwayStationsUseCaseImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object SubwayModule {
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface UseCaseBinder {
+ @Binds
+ @Singleton
+ fun bindLoadSubwayUseCase(useCase: LoadSubwayStationsUseCaseImpl): LoadSubwayStationsUseCase
+ }
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ interface RepositoryBinder {
+ @Binds
+ @Singleton
+ fun bindSubwayRepository(repository: SubwayRepositoryImpl): SubwayRepository
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/navigation/FunchNavHost.kt b/app/src/main/java/com/moya/funch/navigation/FunchNavHost.kt
new file mode 100644
index 00000000..0e0355ba
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/navigation/FunchNavHost.kt
@@ -0,0 +1,46 @@
+package com.moya.funch.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavController
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navOptions
+import com.moya.funch.match.navigation.matchingScreen
+import com.moya.funch.match.navigation.navigateToMatching
+import com.moya.funch.onboarding.navigation.ON_BOARDING_ROUTE
+import com.moya.funch.onboarding.navigation.onBoardingScreen
+
+@Composable
+fun FunchNavHost(hasProfile: Boolean, navController: NavHostController = rememberNavController()) {
+ NavHost(
+ navController = navController,
+ startDestination = determineStartDestination(hasProfile)
+ ) {
+ with(navController) {
+ profileGraph(
+ onNavigateToHome = ::onNavigateToHome,
+ onCloseMyProfile = ::onCloseMyProfile
+ )
+ homeScreen(
+ onNavigateToMatching = ::onNavigateToMatching,
+ onNavigateToMyProfile = ::onNavigateToMyProfile
+ )
+ matchingScreen(onClose = { popBackStack(HOME_ROUTE, false) })
+ onBoardingScreen(onNavigateToCreateProfile = ::navigateToCreateProfile)
+ }
+ }
+}
+
+private fun NavController.onNavigateToMyProfile() = navigateToMyProfile(singleTopNavOptions)
+
+private fun NavController.onNavigateToMatching(route: String) = navigateToMatching(route, singleTopNavOptions)
+
+private val singleTopNavOptions = navOptions {
+ launchSingleTop = true
+ popUpTo(HOME_ROUTE)
+}
+
+private fun determineStartDestination(hasProfile: Boolean): String {
+ return if (hasProfile) HOME_ROUTE else ON_BOARDING_ROUTE
+}
diff --git a/app/src/main/java/com/moya/funch/splash/SplashScreen.kt b/app/src/main/java/com/moya/funch/splash/SplashScreen.kt
new file mode 100644
index 00000000..4b096ce9
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/splash/SplashScreen.kt
@@ -0,0 +1,119 @@
+package com.moya.funch.splash
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.offset
+import androidx.compose.material3.Surface
+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.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.moya.funch.R
+import com.moya.funch.theme.FunchTheme
+
+// TODO : 나중에 splash 모듈로 분리
+
+@Composable
+fun LoadingScreen() {
+ val splashIcon by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(
+ R.raw.funch_splash_icon_lottie
+ )
+ )
+
+ val splashBackground by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(
+ R.raw.funch_splash_background
+ )
+ )
+ Surface(
+ color = FunchTheme.colors.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ LottieAnimation(
+ composition = splashIcon,
+ iterations = LottieConstants.IterateForever
+ )
+ LottieAnimation(
+ composition = splashBackground,
+ iterations = LottieConstants.IterateForever
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(painter = painterResource(id = R.drawable.ic_splash_logo), contentDescription = "Splash Logo")
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.TopStart
+ ) {
+ val configuration = LocalConfiguration.current
+ val screenWidthPx = configuration.screenWidthDp
+
+ val xOffset = with(LocalDensity.current) { -screenWidthPx / 2 }
+
+ val aspectRatio = 115f / 99f
+ Image(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(aspectRatio)
+ .offset(x = xOffset.dp, y = 0.dp),
+ painter = painterResource(id = R.drawable.ic_splash_bg_top),
+ contentDescription = "Splash Top icon"
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.BottomEnd
+ ) {
+ val configuration = LocalConfiguration.current
+ val screenWidthPx = configuration.screenWidthDp
+ val xOffset = with(LocalDensity.current) { screenWidthPx / 2 }
+ val aspectRatio = 108f / 93f
+ Image(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(aspectRatio)
+ .offset(x = xOffset.dp, y = 0.dp),
+ painter = painterResource(id = R.drawable.ic_splash_bg_bottom),
+ contentDescription = "Splash Bottom icon"
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun Preview() {
+ FunchTheme {
+ Surface(
+ color = FunchTheme.colors.background
+ ) {
+ LoadingScreen()
+ }
+ }
+}
diff --git a/app/src/main/java/com/moya/funch/ui/FunchApp.kt b/app/src/main/java/com/moya/funch/ui/FunchApp.kt
new file mode 100644
index 00000000..c6bc4f95
--- /dev/null
+++ b/app/src/main/java/com/moya/funch/ui/FunchApp.kt
@@ -0,0 +1,23 @@
+package com.moya.funch.ui
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.navigation.FunchNavHost
+import com.moya.funch.theme.LocalBackgroundTheme
+
+@Composable
+fun FunchApp(dataStore: UserDataStore) {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ FunchNavHost(
+ hasProfile = dataStore.hasUserId()
+ )
+ }
+}
diff --git a/app/src/main/res/drawable/ic_app_icon.xml b/app/src/main/res/drawable/ic_app_icon.xml
new file mode 100644
index 00000000..5bd045d5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_app_icon.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_funch_logo_foreground.xml b/app/src/main/res/drawable/ic_funch_logo_foreground.xml
new file mode 100644
index 00000000..70f31720
--- /dev/null
+++ b/app/src/main/res/drawable/ic_funch_logo_foreground.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_splash_bg_bottom.xml b/app/src/main/res/drawable/ic_splash_bg_bottom.xml
new file mode 100644
index 00000000..44a81bf7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_splash_bg_bottom.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_splash_bg_top.xml b/app/src/main/res/drawable/ic_splash_bg_top.xml
new file mode 100644
index 00000000..02495192
--- /dev/null
+++ b/app/src/main/res/drawable/ic_splash_bg_top.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_splash_logo.xml b/app/src/main/res/drawable/ic_splash_logo.xml
new file mode 100644
index 00000000..76bf5e2e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_splash_logo.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo.xml b/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo.xml
new file mode 100644
index 00000000..e7cb1ecb
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo_round.xml
new file mode 100644
index 00000000..e7cb1ecb
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_funch_logo_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_funch_logo.webp b/app/src/main/res/mipmap-hdpi/ic_funch_logo.webp
new file mode 100644
index 00000000..e17e53c4
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_funch_logo.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_funch_logo_round.webp b/app/src/main/res/mipmap-hdpi/ic_funch_logo_round.webp
new file mode 100644
index 00000000..7100c4d4
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_funch_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_funch_logo.webp b/app/src/main/res/mipmap-mdpi/ic_funch_logo.webp
new file mode 100644
index 00000000..0a31bc41
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_funch_logo.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_funch_logo_round.webp b/app/src/main/res/mipmap-mdpi/ic_funch_logo_round.webp
new file mode 100644
index 00000000..e2880fee
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_funch_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_funch_logo.webp b/app/src/main/res/mipmap-xhdpi/ic_funch_logo.webp
new file mode 100644
index 00000000..8190f7c9
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_funch_logo.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_funch_logo_round.webp b/app/src/main/res/mipmap-xhdpi/ic_funch_logo_round.webp
new file mode 100644
index 00000000..ca96bc21
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_funch_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_funch_logo.webp b/app/src/main/res/mipmap-xxhdpi/ic_funch_logo.webp
new file mode 100644
index 00000000..aa72e3a2
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_funch_logo.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_funch_logo_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_funch_logo_round.webp
new file mode 100644
index 00000000..351b7bea
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_funch_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo.webp b/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo.webp
new file mode 100644
index 00000000..ef6d6fd0
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo_round.webp
new file mode 100644
index 00000000..b2ca648c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_funch_logo_round.webp differ
diff --git a/app/src/main/res/raw/funch_splash_background.json b/app/src/main/res/raw/funch_splash_background.json
new file mode 100644
index 00000000..a24e0315
--- /dev/null
+++ b/app/src/main/res/raw/funch_splash_background.json
@@ -0,0 +1 @@
+{"nm":"Flow 2","ddd":0,"h":350,"w":350,"meta":{"g":"LottieFiles Figma v51"},"layers":[{"ty":4,"nm":"boom","sr":1,"st":0,"op":46.06,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0.63,0.5],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0.63,0.5],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.36,67.99],"t":27},{"s":[65.66,55.56],"t":45}]},"s":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100,100],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100,100],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100,100],"t":27},{"s":[100,100],"t":45}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[204.63,175.5],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[204.63,175.5],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[207.19,174.5],"t":27},{"s":[207.19,174.5],"t":45}]},"r":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[-5.14],"t":27},{"s":[-5.14],"t":45}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100],"t":27},{"s":[100],"t":45}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.04,-0.02],[0,0],[0,0],[-0.03,-0.03],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.04,-0.03],[0,0],[0,0],[0.05,0.01],[0,0],[0,0],[0.03,0.02],[0,0],[0,0],[0.01,0.04],[0,0],[0,0],[-0.03,0.03]],"o":[[0,0],[0,0],[0,0],[-0.03,-0.04],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.03,-0.02],[0,0],[0,0],[0.05,0],[0,0],[0,0],[0.04,0.03],[0,0],[0,0],[0,0.03],[0,0],[0,0],[-0.02,0.03],[0,0],[0,0],[-0.04,0],[0,0]],"v":[[0.18,0.52],[0.18,0.52],[0,0.49],[0.22,0.47],[0.22,0.36],[0.31,0.33],[0.31,0.27],[0.43,0.17],[0.59,0],[0.85,0.1],[1.05,0.17],[1.12,0.21],[1.15,0.23],[1.09,0.49],[1.25,0.52],[1.12,0.58],[1.11,0.73],[1.15,0.87],[1.05,0.76],[0.94,0.83],[0.77,0.87],[0.59,0.83],[0.59,0.99],[0.22,0.88],[0.09,0.49],[0.18,0.52]]}],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.04,-0.02],[0,0],[0,0],[-0.03,-0.03],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.04,-0.03],[0,0],[0,0],[0.05,0.01],[0,0],[0,0],[0.03,0.02],[0,0],[0,0],[0.01,0.04],[0,0],[0,0],[-0.03,0.03]],"o":[[0,0],[0,0],[0,0],[-0.03,-0.04],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.03,-0.02],[0,0],[0,0],[0.05,0],[0,0],[0,0],[0.04,0.03],[0,0],[0,0],[0,0.03],[0,0],[0,0],[-0.02,0.03],[0,0],[0,0],[-0.04,0],[0,0]],"v":[[0.18,0.52],[0.18,0.52],[0,0.49],[0.22,0.47],[0.22,0.36],[0.31,0.33],[0.31,0.27],[0.43,0.17],[0.59,0],[0.85,0.1],[1.05,0.17],[1.12,0.21],[1.15,0.23],[1.09,0.49],[1.25,0.52],[1.12,0.58],[1.11,0.73],[1.15,0.87],[1.05,0.76],[0.94,0.83],[0.77,0.87],[0.59,0.83],[0.59,0.99],[0.22,0.88],[0.09,0.49],[0.18,0.52]]}],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[-3.04,-1],[0,0],[0,0],[-2.12,-2.13],[0,0],[0,0],[0.33,-2.68],[0,0],[0,0],[2.58,-1.94],[0,0],[0,0],[3.53,0.36],[0,0],[0,0],[2.03,1.16],[0,0],[0,0],[0.52,2.65],[0,0],[0,0],[-2.48,1.97],[0,0]],"o":[[0,0],[0,0],[-1.96,-2.52],[0,0],[0,0],[-0.2,-2.98],[0,0],[0,0],[2.35,-1.36],[0,0],[0,0],[3.24,-0.19],[0,0],[0,0],[3.1,1.71],[0,0],[0,0],[-0.29,2.3],[0,0],[0,0],[-1.76,2.07],[0,0],[0,0],[-3.17,0.13],[0,0],[0,0]],"v":[[1.18,88.89],[29.27,66.47],[12.9,45.22],[16.53,40.18],[35.87,46.55],[33.16,3.43],[38.74,0.98],[71.35,33.77],[108.36,12.13],[113.26,15.34],[109.15,47.58],[138.55,45.87],[140.7,51.69],[118.36,68.28],[159.03,90.57],[157.1,96.62],[111.04,91.83],[108.39,112.89],[103.53,115.28],[84.91,104.74],[58.97,134.85],[53.28,133.36],[45.21,92.64],[3.43,94.55],[1.24,88.79],[1.18,88.89]]}],"t":27},{"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[-2.48,-0.82],[0,0],[0,0],[-1.74,-1.74],[0,0],[0,0],[0.27,-2.19],[0,0],[0,0],[2.11,-1.59],[0,0],[0,0],[2.88,0.29],[0,0],[0,0],[1.66,0.95],[0,0],[0,0],[0.43,2.17],[0,0],[0,0],[-2.03,1.61],[0,0]],"o":[[0,0],[0,0],[-1.6,-2.06],[0,0],[0,0],[-0.16,-2.43],[0,0],[0,0],[1.92,-1.11],[0,0],[0,0],[2.64,-0.16],[0,0],[0,0],[2.54,1.4],[0,0],[0,0],[-0.24,1.88],[0,0],[0,0],[-1.44,1.69],[0,0],[0,0],[-2.59,0.11],[0,0],[0,0]],"v":[[0.96,72.63],[23.92,54.32],[10.54,36.95],[13.51,32.83],[29.31,38.04],[27.09,2.81],[31.66,0.8],[58.3,27.6],[88.54,9.92],[92.55,12.53],[89.19,38.88],[113.21,37.48],[114.97,42.24],[96.71,55.8],[129.95,74.01],[128.37,78.95],[90.73,75.04],[88.57,92.24],[84.59,94.2],[69.38,85.58],[48.18,110.19],[43.54,108.97],[36.94,75.7],[2.8,77.26],[1.01,72.55],[0.96,72.63]]}],"t":45}]}},{"ty":"gf","bm":0,"hd":false,"nm":"","e":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,2.04],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,2.04],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.35679626464844,135.98800659179688],"t":27},{"s":[65.6614990234375,111.11900329589844],"t":45}]},"g":{"p":2,"k":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0,1,0.9375764685892591,0.3721242803358564,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0,1,0.9375764685892591,0.3721242803358564,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[0,1,0.9372941213869581,0.37258824289078807,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":27},{"s":[0,1,0.9372941213869581,0.37258824289078807,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":45}]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.35679626464844,0],"t":27},{"s":[65.6614990234375,0],"t":45}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100],"t":27},{"s":[100],"t":45}]}}],"ind":1}],"v":"5.7.0","fr":60,"op":45.06,"ip":0,"assets":[]}
diff --git a/app/src/main/res/raw/funch_splash_icon_lottie.json b/app/src/main/res/raw/funch_splash_icon_lottie.json
new file mode 100644
index 00000000..a24e0315
--- /dev/null
+++ b/app/src/main/res/raw/funch_splash_icon_lottie.json
@@ -0,0 +1 @@
+{"nm":"Flow 2","ddd":0,"h":350,"w":350,"meta":{"g":"LottieFiles Figma v51"},"layers":[{"ty":4,"nm":"boom","sr":1,"st":0,"op":46.06,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0.63,0.5],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0.63,0.5],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.36,67.99],"t":27},{"s":[65.66,55.56],"t":45}]},"s":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100,100],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100,100],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100,100],"t":27},{"s":[100,100],"t":45}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[204.63,175.5],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[204.63,175.5],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[207.19,174.5],"t":27},{"s":[207.19,174.5],"t":45}]},"r":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[-5.14],"t":27},{"s":[-5.14],"t":45}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100],"t":27},{"s":[100],"t":45}]}},"shapes":[{"ty":"sh","bm":0,"hd":false,"nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.04,-0.02],[0,0],[0,0],[-0.03,-0.03],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.04,-0.03],[0,0],[0,0],[0.05,0.01],[0,0],[0,0],[0.03,0.02],[0,0],[0,0],[0.01,0.04],[0,0],[0,0],[-0.03,0.03]],"o":[[0,0],[0,0],[0,0],[-0.03,-0.04],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.03,-0.02],[0,0],[0,0],[0.05,0],[0,0],[0,0],[0.04,0.03],[0,0],[0,0],[0,0.03],[0,0],[0,0],[-0.02,0.03],[0,0],[0,0],[-0.04,0],[0,0]],"v":[[0.18,0.52],[0.18,0.52],[0,0.49],[0.22,0.47],[0.22,0.36],[0.31,0.33],[0.31,0.27],[0.43,0.17],[0.59,0],[0.85,0.1],[1.05,0.17],[1.12,0.21],[1.15,0.23],[1.09,0.49],[1.25,0.52],[1.12,0.58],[1.11,0.73],[1.15,0.87],[1.05,0.76],[0.94,0.83],[0.77,0.87],[0.59,0.83],[0.59,0.99],[0.22,0.88],[0.09,0.49],[0.18,0.52]]}],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[0,0],[-0.04,-0.02],[0,0],[0,0],[-0.03,-0.03],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.04,-0.03],[0,0],[0,0],[0.05,0.01],[0,0],[0,0],[0.03,0.02],[0,0],[0,0],[0.01,0.04],[0,0],[0,0],[-0.03,0.03]],"o":[[0,0],[0,0],[0,0],[-0.03,-0.04],[0,0],[0,0],[0,-0.04],[0,0],[0,0],[0.03,-0.02],[0,0],[0,0],[0.05,0],[0,0],[0,0],[0.04,0.03],[0,0],[0,0],[0,0.03],[0,0],[0,0],[-0.02,0.03],[0,0],[0,0],[-0.04,0],[0,0]],"v":[[0.18,0.52],[0.18,0.52],[0,0.49],[0.22,0.47],[0.22,0.36],[0.31,0.33],[0.31,0.27],[0.43,0.17],[0.59,0],[0.85,0.1],[1.05,0.17],[1.12,0.21],[1.15,0.23],[1.09,0.49],[1.25,0.52],[1.12,0.58],[1.11,0.73],[1.15,0.87],[1.05,0.76],[0.94,0.83],[0.77,0.87],[0.59,0.83],[0.59,0.99],[0.22,0.88],[0.09,0.49],[0.18,0.52]]}],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[-3.04,-1],[0,0],[0,0],[-2.12,-2.13],[0,0],[0,0],[0.33,-2.68],[0,0],[0,0],[2.58,-1.94],[0,0],[0,0],[3.53,0.36],[0,0],[0,0],[2.03,1.16],[0,0],[0,0],[0.52,2.65],[0,0],[0,0],[-2.48,1.97],[0,0]],"o":[[0,0],[0,0],[-1.96,-2.52],[0,0],[0,0],[-0.2,-2.98],[0,0],[0,0],[2.35,-1.36],[0,0],[0,0],[3.24,-0.19],[0,0],[0,0],[3.1,1.71],[0,0],[0,0],[-0.29,2.3],[0,0],[0,0],[-1.76,2.07],[0,0],[0,0],[-3.17,0.13],[0,0],[0,0]],"v":[[1.18,88.89],[29.27,66.47],[12.9,45.22],[16.53,40.18],[35.87,46.55],[33.16,3.43],[38.74,0.98],[71.35,33.77],[108.36,12.13],[113.26,15.34],[109.15,47.58],[138.55,45.87],[140.7,51.69],[118.36,68.28],[159.03,90.57],[157.1,96.62],[111.04,91.83],[108.39,112.89],[103.53,115.28],[84.91,104.74],[58.97,134.85],[53.28,133.36],[45.21,92.64],[3.43,94.55],[1.24,88.79],[1.18,88.89]]}],"t":27},{"s":[{"c":true,"i":[[0,0],[0,0],[0,0],[-2.48,-0.82],[0,0],[0,0],[-1.74,-1.74],[0,0],[0,0],[0.27,-2.19],[0,0],[0,0],[2.11,-1.59],[0,0],[0,0],[2.88,0.29],[0,0],[0,0],[1.66,0.95],[0,0],[0,0],[0.43,2.17],[0,0],[0,0],[-2.03,1.61],[0,0]],"o":[[0,0],[0,0],[-1.6,-2.06],[0,0],[0,0],[-0.16,-2.43],[0,0],[0,0],[1.92,-1.11],[0,0],[0,0],[2.64,-0.16],[0,0],[0,0],[2.54,1.4],[0,0],[0,0],[-0.24,1.88],[0,0],[0,0],[-1.44,1.69],[0,0],[0,0],[-2.59,0.11],[0,0],[0,0]],"v":[[0.96,72.63],[23.92,54.32],[10.54,36.95],[13.51,32.83],[29.31,38.04],[27.09,2.81],[31.66,0.8],[58.3,27.6],[88.54,9.92],[92.55,12.53],[89.19,38.88],[113.21,37.48],[114.97,42.24],[96.71,55.8],[129.95,74.01],[128.37,78.95],[90.73,75.04],[88.57,92.24],[84.59,94.2],[69.38,85.58],[48.18,110.19],[43.54,108.97],[36.94,75.7],[2.8,77.26],[1.01,72.55],[0.96,72.63]]}],"t":45}]}},{"ty":"gf","bm":0,"hd":false,"nm":"","e":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,2.04],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,2.04],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.35679626464844,135.98800659179688],"t":27},{"s":[65.6614990234375,111.11900329589844],"t":45}]},"g":{"p":2,"k":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0,1,0.9375764685892591,0.3721242803358564,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[0,1,0.9375764685892591,0.3721242803358564,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[0,1,0.9372941213869581,0.37258824289078807,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":27},{"s":[0,1,0.9372941213869581,0.37258824289078807,1,0.9686666685366163,0.8314117747568617,0.3686666744970808],"t":45}]}},"t":1,"a":{"a":0,"k":0},"h":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,0],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[-1.13,0],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[80.35679626464844,0],"t":27},{"s":[65.6614990234375,0],"t":45}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100],"t":0},{"o":{"x":0.65,"y":0},"i":{"x":0.35,"y":0},"s":[100],"t":9},{"o":{"x":0,"y":0.88},"i":{"x":0,"y":0.95},"s":[100],"t":27},{"s":[100],"t":45}]}}],"ind":1}],"v":"5.7.0","fr":60,"op":45.06,"ip":0,"assets":[]}
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
deleted file mode 100644
index 52ed88e8..00000000
--- a/app/src/main/res/values-night/themes.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_funch_logo_background.xml b/app/src/main/res/values/ic_funch_logo_background.xml
new file mode 100644
index 00000000..eb149bdf
--- /dev/null
+++ b/app/src/main/res/values/ic_funch_logo_background.xml
@@ -0,0 +1,4 @@
+
+
+ #151515
+
diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml
new file mode 100644
index 00000000..02380b4a
--- /dev/null
+++ b/app/src/main/res/values/splash.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f52a3e67..3689feb5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
+
- Punch-AOS
-
\ No newline at end of file
+ Funch
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 201deacc..29506206 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,16 +1,3 @@
-
-
-
-
\ 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 00000000..dfb10801
--- /dev/null
+++ b/build-logic/convention/build.gradle.kts
@@ -0,0 +1,59 @@
+plugins {
+ `kotlin-dsl`
+}
+
+group = "com.moya.funch.buildlogic"
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
+
+dependencies {
+ compileOnly(libs.agp)
+ compileOnly(libs.kotlin.gradleplugin)
+ compileOnly(libs.ksp.gradlePlugin)
+}
+
+gradlePlugin {
+ plugins {
+ create("android-application") {
+ id = "com.moya.funch.application" // id는 다른 모듈에서 실제 사용될 때 사용된다
+ implementationClass =
+ "com.moya.funch.plugins.AndroidApplicationPlugin"
+ }
+ create("android-library") {
+ id = "com.moya.funch.library"
+ implementationClass = "com.moya.funch.plugins.AndroidLibraryPlugin"
+ }
+ create("android-feature") {
+ id = "com.moya.funch.feature"
+ implementationClass = "com.moya.funch.plugins.AndroidFeaturePlugin"
+ }
+ create("android-hilt") {
+ id = "com.moya.funch.hilt"
+ implementationClass = "com.moya.funch.plugins.AndroidHiltPlugin"
+ }
+ create("kotlin-serialization") {
+ id = "com.moya.funch.kotlinx_serialization"
+ implementationClass =
+ "com.moya.funch.plugins.KotlinSerializationPlugin"
+ }
+ create("junit5") {
+ id = "com.moya.funch.junit5"
+ implementationClass = "com.moya.funch.plugins.JUnit5Plugin"
+ }
+ create("android-test") {
+ id = "com.moya.funch.android.test"
+ implementationClass = "com.moya.funch.plugins.AndroidTestPlugin"
+ }
+ create("compose") {
+ id = "com.moya.funch.compose"
+ implementationClass = "com.moya.funch.plugins.ComposePlugin"
+ }
+ register("jvm-library") {
+ id = "com.moya.funch.jvm.library"
+ implementationClass = "com.moya.funch.plugins.JvmLibraryPlugin"
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidApplicationPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidApplicationPlugin.kt
new file mode 100644
index 00000000..ee5c04a1
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidApplicationPlugin.kt
@@ -0,0 +1,36 @@
+package com.moya.funch.plugins
+
+import com.android.build.api.dsl.ApplicationExtension
+import com.moya.funch.plugins.utils.configureAndroidCommonPlugin
+import com.moya.funch.plugins.utils.configureKotlinAndroid
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.kotlin
+
+class AndroidApplicationPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("com.android.application")
+ apply("kotlin-android")
+ }
+ configureAndroidCommonPlugin()
+
+ extensions.configure {
+ configureKotlinAndroid(this)
+ defaultConfig.targetSdk = libs.findVersion("targetSdk").get().requiredVersion.toInt()
+ }
+
+ dependencies {
+ add("implementation", libs.findLibrary("core.ktx").get())
+ add("implementation", libs.findLibrary("appcompat").get())
+ add("implementation", libs.findBundle("lifecycle").get())
+ add("implementation", libs.findLibrary("material").get())
+ add("testImplementation", kotlin("test"))
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidFeaturePlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidFeaturePlugin.kt
new file mode 100644
index 00000000..2f109b16
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidFeaturePlugin.kt
@@ -0,0 +1,25 @@
+package com.moya.funch.plugins
+
+import com.moya.funch.plugins.utils.configureAndroidCommonPlugin
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.kotlin
+
+class AndroidFeaturePlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ apply()
+
+ dependencies {
+ add("implementation", libs.findLibrary("core.ktx").get())
+ add("implementation", libs.findLibrary("appcompat").get())
+ add("implementation", libs.findBundle("lifecycle").get())
+ add("implementation", libs.findLibrary("material").get())
+ add("testImplementation", kotlin("test"))
+ }
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidHiltPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidHiltPlugin.kt
new file mode 100644
index 00000000..befc6510
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidHiltPlugin.kt
@@ -0,0 +1,22 @@
+package com.moya.funch.plugins
+
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+class AndroidHiltPlugin : Plugin {
+ override fun apply(target: Project) =
+ with(target) {
+ with(plugins) {
+ apply("com.google.devtools.ksp")
+ apply("dagger.hilt.android.plugin")
+ }
+
+ dependencies {
+ add("implementation", libs.findLibrary("hilt.navigation.compose").get())
+ add("implementation", libs.findLibrary("hilt.android").get())
+ add("ksp", libs.findLibrary("hilt.compiler").get())
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidLibraryPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidLibraryPlugin.kt
new file mode 100644
index 00000000..08ef2120
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidLibraryPlugin.kt
@@ -0,0 +1,27 @@
+package com.moya.funch.plugins
+
+import com.android.build.gradle.LibraryExtension
+import com.moya.funch.plugins.utils.configureAndroidCommonPlugin
+import com.moya.funch.plugins.utils.configureKotlinAndroid
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.kotlin
+
+class AndroidLibraryPlugin : Plugin {
+ override fun apply(target: Project) =
+ with(target) {
+ with(pluginManager) {
+ apply("com.android.library")
+ apply("kotlin-android")
+ }
+ configureAndroidCommonPlugin()
+
+ extensions.configure {
+ configureKotlinAndroid(this)
+ defaultConfig.targetSdk = libs.findVersion("targetSdk").get().requiredVersion.toInt()
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidTestPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidTestPlugin.kt
new file mode 100644
index 00000000..7531d987
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/AndroidTestPlugin.kt
@@ -0,0 +1,45 @@
+package com.moya.funch.plugins
+
+import com.android.build.gradle.BaseExtension
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.getByType
+
+class AndroidTestPlugin : Plugin {
+ override fun apply(target: Project): Unit =
+ with(target) {
+ apply()
+ apply("de.mannodermaus.android-junit5")
+
+ extensions.getByType().apply {
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["runnerBuilder"] =
+ "de.mannodermaus.junit5.AndroidJUnit5Builder"
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+
+ packagingOptions {
+ resources.excludes.add("META-INF/LICENSE*")
+ }
+ }
+
+ dependencies {
+ add("testImplementation", libs.findLibrary("junit").get())
+ add("debugImplementation", libs.findLibrary("truth").get())
+ add("testImplementation", libs.findLibrary("robolectric").get())
+ add("androidTestRuntimeOnly", libs.findLibrary("junit5-engine").get())
+ add("androidTestImplementation", libs.findLibrary("junit5-android-test-core").get())
+ add("androidTestRuntimeOnly", libs.findLibrary("junit5-android-test-runner").get())
+ add("androidTestImplementation", libs.findBundle("androidx.android.test").get())
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/ComposePlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/ComposePlugin.kt
new file mode 100644
index 00000000..27b4ffa9
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/ComposePlugin.kt
@@ -0,0 +1,28 @@
+package com.moya.funch.plugins
+
+import com.android.build.gradle.BaseExtension
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.getByType
+
+class ComposePlugin : Plugin {
+ override fun apply(target: Project) =
+ with(target) {
+ extensions.getByType().apply {
+ buildFeatures.apply {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion =
+ libs.findVersion("composeCompiler").get().toString()
+ }
+ }
+
+ dependencies {
+ add("implementation", platform(libs.findLibrary("compose.bom").get()))
+ add("implementation", libs.findBundle("compose").get())
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JUnit5Plugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JUnit5Plugin.kt
new file mode 100644
index 00000000..2681015c
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JUnit5Plugin.kt
@@ -0,0 +1,35 @@
+package com.moya.funch.plugins
+
+import com.android.build.gradle.BaseExtension
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.tasks.testing.Test
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.kotlin
+import org.gradle.kotlin.dsl.withType
+
+class JUnit5Plugin : Plugin {
+ override fun apply(target: Project): Unit =
+ with(target) {
+
+ extensions.getByType().apply {
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+ }
+
+ tasks.withType {
+ useJUnitPlatform()
+ }
+
+ dependencies {
+ add("testImplementation", kotlin("test"))
+ add("testImplementation", libs.findBundle("junit5").get())
+ add("testImplementation", libs.findLibrary("truth").get())
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JvmLibraryPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JvmLibraryPlugin.kt
new file mode 100644
index 00000000..d42d2c87
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/JvmLibraryPlugin.kt
@@ -0,0 +1,19 @@
+package com.moya.funch.plugins
+
+import com.moya.funch.plugins.utils.configureKotlinJvm
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+
+/**
+ * 순수 JVM 라이브러리를 위한 플러그인 ex) domain
+ * */
+class JvmLibraryPlugin : Plugin {
+ override fun apply(target: Project) {
+ with(target) {
+ with(pluginManager) {
+ apply("org.jetbrains.kotlin.jvm")
+ }
+ configureKotlinJvm()
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/KotlinSerializationPlugin.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/KotlinSerializationPlugin.kt
new file mode 100644
index 00000000..6306b24b
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/KotlinSerializationPlugin.kt
@@ -0,0 +1,19 @@
+package com.moya.funch.plugins
+
+import com.moya.funch.plugins.utils.libs
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.dependencies
+
+class KotlinSerializationPlugin : Plugin {
+ override fun apply(target: Project) =
+ with(target) {
+ with(plugins) {
+ apply("org.jetbrains.kotlin.plugin.serialization")
+ }
+
+ dependencies {
+ add("implementation", libs.findLibrary("kotlin.serialization.json").get())
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/AndroidCommonConfigs.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/AndroidCommonConfigs.kt
new file mode 100644
index 00000000..28e27469
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/AndroidCommonConfigs.kt
@@ -0,0 +1,22 @@
+package com.moya.funch.plugins.utils
+
+import com.android.build.gradle.BaseExtension
+import com.moya.funch.plugins.AndroidHiltPlugin
+import org.gradle.api.Project
+import org.gradle.kotlin.dsl.apply
+import org.gradle.kotlin.dsl.dependencies
+import org.gradle.kotlin.dsl.getByType
+
+internal fun Project.configureAndroidCommonPlugin() {
+ apply()
+
+ extensions.getByType().apply {
+ buildFeatures.apply {
+ buildConfig = true
+ }
+ }
+
+ dependencies {
+ add("implementation", libs.findLibrary("timber").get())
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/KotlinConfigs.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/KotlinConfigs.kt
new file mode 100644
index 00000000..510ef124
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/KotlinConfigs.kt
@@ -0,0 +1,67 @@
+package com.moya.funch.plugins.utils
+
+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.dependencies
+import org.gradle.kotlin.dsl.withType
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+/**
+ * Configure base Kotlin with Android options
+ */
+internal fun Project.configureKotlinAndroid(
+ commonExtension: CommonExtension<*, *, *, *, *>,
+) {
+ commonExtension.apply {
+ compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
+
+ defaultConfig {
+ minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
+ }
+
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ }
+
+ configureKotlin()
+
+ dependencies {
+ add("coreLibraryDesugaring", libs.findLibrary("desugarLibs").get())
+ add("implementation", libs.findLibrary("kotlin").get())
+ add("implementation", libs.findLibrary("kotlin.coroutines.android").get())
+ }
+}
+
+/**
+ * Configure base Kotlin options for JVM (non-Android)
+ */
+internal fun Project.configureKotlinJvm() {
+ extensions.configure {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ configureKotlin()
+
+ dependencies {
+ add("implementation", libs.findLibrary("kotlin").get())
+ add("implementation", libs.findLibrary("kotlin.coroutines.core").get())
+ }
+}
+
+/**
+ * Configure base Kotlin options
+ */
+private fun Project.configureKotlin() {
+ tasks.withType().configureEach {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+ }
+}
diff --git a/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/ProjectExts.kt b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/ProjectExts.kt
new file mode 100644
index 00000000..0438f162
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/moya/funch/plugins/utils/ProjectExts.kt
@@ -0,0 +1,9 @@
+package com.moya.funch.plugins.utils
+
+import org.gradle.api.Project
+import org.gradle.api.artifacts.VersionCatalog
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.kotlin.dsl.getByType
+
+val Project.libs
+ get(): VersionCatalog = extensions.getByType().named("libs")
diff --git a/build-logic/gradle.properties b/build-logic/gradle.properties
new file mode 100644
index 00000000..1c9073eb
--- /dev/null
+++ b/build-logic/gradle.properties
@@ -0,0 +1,4 @@
+# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.configureondemand=true
diff --git a/build-logic/gradle/wrapper/gradle-wrapper.properties b/build-logic/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..712b8b4d
--- /dev/null
+++ b/build-logic/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jan 13 12:46:38 KST 2023
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
new file mode 100644
index 00000000..62257853
--- /dev/null
+++ b/build-logic/settings.gradle.kts
@@ -0,0 +1,14 @@
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "build-logic"
+include(":convention")
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 20d87a70..c987c1fa 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,53 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
+import org.jlleitschuh.gradle.ktlint.KtlintExtension
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+
+ dependencies {
+ classpath(libs.kotlin.gradleplugin)
+ classpath(libs.hilt.plugin)
+ classpath(libs.agp)
+ classpath(libs.ktlint)
+ }
+}
+
plugins {
- alias(libs.plugins.androidApplication) apply false
- alias(libs.plugins.kotlinAndroid) apply false
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.kapt) apply false
+ alias(libs.plugins.dagger.hilt) apply false
+ alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.kotlinx.serialization) apply false
+ alias(libs.plugins.junit5) apply false
+ alias(libs.plugins.ktlint) apply false
+// alias(libs.plugins.google.services) apply false
+// alias(libs.plugins.app.distribution) apply false
+// alias(libs.plugins.crashlytics) apply false
+}
+
+subprojects {
+ apply(plugin = "org.jlleitschuh.gradle.ktlint") // Version should be inherited from parent
+
+ configure {
+ filter {
+ exclude { element -> element.file.path.contains("generated/") }
+ }
+ android.set(true)
+ coloredOutput.set(true)
+ verbose.set(true)
+ outputToConsole.set(true)
+ reporters {
+ reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN)
+ reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
+ }
+ }
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.buildDir)
}
-true // Needed to make the Suppress annotation work for the plugins block
\ No newline at end of file
diff --git a/core/data/.gitignore b/core/data/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/data/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
new file mode 100644
index 00000000..e1288965
--- /dev/null
+++ b/core/data/build.gradle.kts
@@ -0,0 +1,19 @@
+plugins {
+ alias(libs.plugins.funch.android.library)
+ alias(libs.plugins.funch.junit5)
+}
+
+android {
+ namespace = "com.moja.funch.data"
+}
+
+dependencies {
+ implementation(projects.core.network)
+ implementation(projects.core.datastore)
+ implementation(projects.core.testing)
+ implementation(projects.core.domain)
+
+ // test
+ testImplementation(libs.kotlin.coroutines.test)
+ testImplementation(libs.mockk)
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/UserDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/UserDataSource.kt
new file mode 100644
index 00000000..4845adeb
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/UserDataSource.kt
@@ -0,0 +1,7 @@
+package com.moya.funch.datasource
+
+import com.moya.funch.model.ProfileModel
+
+fun interface UserDataSource {
+ suspend fun fetchUserProfile(): Result
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSource.kt
new file mode 100644
index 00000000..78224da4
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSource.kt
@@ -0,0 +1,8 @@
+package com.moya.funch.datasource.local
+
+import com.moya.funch.datasource.UserDataSource
+import com.moya.funch.model.ProfileModel
+
+interface LocalUserDataSource : UserDataSource {
+ suspend fun saveUserProfile(userModel: ProfileModel): Result
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSourceImpl.kt b/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSourceImpl.kt
new file mode 100644
index 00000000..f6127fa8
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/local/LocalUserDataSourceImpl.kt
@@ -0,0 +1,48 @@
+package com.moya.funch.datasource.local
+
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.model.ProfileModel
+import javax.inject.Inject
+import timber.log.Timber
+
+class LocalUserDataSourceImpl @Inject constructor(
+ private val userDataStore: UserDataStore
+) : LocalUserDataSource {
+
+ override suspend fun fetchUserProfile(): Result {
+ if (userDataStore.hasUserId()) {
+ return Result.success(
+ ProfileModel(
+ userCode = userDataStore.userCode,
+ userId = userDataStore.userId,
+ name = userDataStore.userName,
+ jobGroup = userDataStore.jobGroup,
+ bloodType = userDataStore.bloodType,
+ clubs = userDataStore.clubs,
+ subwayName = userDataStore.subwayName,
+ subwayLines = userDataStore.subwayLines,
+ mbti = userDataStore.mbti
+ )
+ )
+ }
+ Timber.e("cannot find User Information")
+ return Result.failure(IllegalArgumentException("cannot find User Information"))
+ }
+
+ override suspend fun saveUserProfile(profile: ProfileModel): Result {
+ return runCatching {
+ userDataStore.clear()
+ profile.run {
+ userDataStore.userCode = userCode
+ userDataStore.userId = userId
+ userDataStore.userName = name
+ userDataStore.jobGroup = jobGroup
+ userDataStore.bloodType = bloodType
+ userDataStore.clubs = clubs
+ userDataStore.subwayName = subwayName
+ userDataStore.subwayLines = subwayLines
+ userDataStore.mbti = mbti
+ }
+ }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSource.kt
new file mode 100644
index 00000000..09747031
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSource.kt
@@ -0,0 +1,7 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.network.dto.response.match.MatchingResponse
+
+fun interface RemoteMatchDataSource {
+ suspend fun matchProfile(targetCode: String): Result
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSourceImpl.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSourceImpl.kt
new file mode 100644
index 00000000..1caf2d59
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMatchDataSourceImpl.kt
@@ -0,0 +1,24 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.network.dto.request.MatchingRequest
+import com.moya.funch.network.dto.response.match.MatchingResponse
+import com.moya.funch.network.service.MatchingService
+import javax.inject.Inject
+
+class RemoteMatchDataSourceImpl @Inject constructor(
+ private val matchingService: MatchingService,
+ private val dataStore: UserDataStore
+) : RemoteMatchDataSource {
+
+ override suspend fun matchProfile(targetCode: String): Result {
+ return runCatching {
+ matchingService.matchProfile(
+ MatchingRequest(
+ userId = dataStore.userId,
+ targetCode = targetCode
+ )
+ )
+ }.mapCatching { it.data }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSource.kt
new file mode 100644
index 00000000..0ca3e28c
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSource.kt
@@ -0,0 +1,8 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.model.ProfileModel
+
+fun interface RemoteMemberDataSource {
+
+ suspend fun fetchMemberProfile(id: String): Result
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSourceImpl.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSourceImpl.kt
new file mode 100644
index 00000000..63397df2
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteMemberDataSourceImpl.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.model.ProfileModel
+import com.moya.funch.model.toModel
+import com.moya.funch.network.service.MemberService
+import javax.inject.Inject
+
+class RemoteMemberDataSourceImpl @Inject constructor(
+ private val memberService: MemberService
+) : RemoteMemberDataSource {
+ override suspend fun fetchMemberProfile(id: String): Result {
+ return runCatching {
+ memberService.findMemberById(id)
+ }.mapCatching { it.data.toModel() }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSource.kt
new file mode 100644
index 00000000..a2309e84
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSource.kt
@@ -0,0 +1,8 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.network.dto.response.subwaystation.SubwayStationsResponse
+
+fun interface RemoteSubwayDataSource {
+
+ suspend fun fetchSubwayStations(subwayStation: String): Result>
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSourceImpl.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSourceImpl.kt
new file mode 100644
index 00000000..6e5f72fa
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteSubwayDataSourceImpl.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.network.dto.response.subwaystation.SubwayStationsResponse
+import com.moya.funch.network.service.SubwayService
+import javax.inject.Inject
+
+class RemoteSubwayDataSourceImpl @Inject constructor(
+ private val subwayStationService: SubwayService
+) : RemoteSubwayDataSource {
+
+ override suspend fun fetchSubwayStations(subwayStation: String): Result> {
+ return runCatching {
+ subwayStationService.findSubwayStations(subwayStation = subwayStation).data
+ }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSource.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSource.kt
new file mode 100644
index 00000000..d231c548
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSource.kt
@@ -0,0 +1,8 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.datasource.UserDataSource
+import com.moya.funch.model.ProfileModel
+
+interface RemoteUserDataSource : UserDataSource {
+ suspend fun createUserProfile(userModel: ProfileModel): Result
+}
diff --git a/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImpl.kt b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImpl.kt
new file mode 100644
index 00000000..04d5fa9e
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImpl.kt
@@ -0,0 +1,36 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.model.ProfileModel
+import com.moya.funch.model.toModel
+import com.moya.funch.network.service.MemberService
+import javax.inject.Inject
+
+class RemoteUserDataSourceImpl @Inject constructor(
+ private val userDataStore: UserDataStore,
+ private val memberService: MemberService
+) : RemoteUserDataSource {
+ override suspend fun fetchUserProfile(): Result {
+ if (userDataStore.hasUserId()) {
+ return fetchUserProfileById()
+ }
+ return fetchUserProfileByDeviceNumber()
+ }
+
+ private suspend fun fetchUserProfileById(): Result {
+ return runCatching { memberService.findMemberById(userDataStore.userId).data }
+ .mapCatching { it.toModel() }
+ }
+
+ private suspend fun fetchUserProfileByDeviceNumber(): Result {
+ return runCatching { memberService.findMemberByDeviceNumber(userDataStore.deviceId).data }
+ .mapCatching { it.toModel() }
+ }
+
+ override suspend fun createUserProfile(userModel: ProfileModel): Result {
+ return runCatching {
+ val request = userModel.toRequest(userDataStore.deviceId)
+ memberService.createMember(request).data
+ }.mapCatching { it.toModel() }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/di/DataSourcesModule.kt b/core/data/src/main/java/com/moya/funch/di/DataSourcesModule.kt
new file mode 100644
index 00000000..af6f1b3b
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/di/DataSourcesModule.kt
@@ -0,0 +1,42 @@
+package com.moya.funch.di
+
+import com.moya.funch.datasource.local.LocalUserDataSource
+import com.moya.funch.datasource.local.LocalUserDataSourceImpl
+import com.moya.funch.datasource.remote.RemoteMatchDataSource
+import com.moya.funch.datasource.remote.RemoteMatchDataSourceImpl
+import com.moya.funch.datasource.remote.RemoteMemberDataSource
+import com.moya.funch.datasource.remote.RemoteMemberDataSourceImpl
+import com.moya.funch.datasource.remote.RemoteSubwayDataSource
+import com.moya.funch.datasource.remote.RemoteSubwayDataSourceImpl
+import com.moya.funch.datasource.remote.RemoteUserDataSource
+import com.moya.funch.datasource.remote.RemoteUserDataSourceImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class DataSourcesModule {
+
+ @Binds
+ @Singleton
+ abstract fun provideRemoteUserDataSource(remoteDataSource: RemoteUserDataSourceImpl): RemoteUserDataSource
+
+ @Binds
+ @Singleton
+ abstract fun provideLocalUserDataSource(localDataSource: LocalUserDataSourceImpl): LocalUserDataSource
+
+ @Binds
+ @Singleton
+ abstract fun provideMemberDataSource(remoteDataSource: RemoteMemberDataSourceImpl): RemoteMemberDataSource
+
+ @Binds
+ @Singleton
+ abstract fun provideRemoteMatchDataSource(remoteDataSource: RemoteMatchDataSourceImpl): RemoteMatchDataSource
+
+ @Binds
+ @Singleton
+ abstract fun provideRemoteSubwayDataSource(remoteDataSource: RemoteSubwayDataSourceImpl): RemoteSubwayDataSource
+}
diff --git a/core/data/src/main/java/com/moya/funch/mapper/MatchingMapper.kt b/core/data/src/main/java/com/moya/funch/mapper/MatchingMapper.kt
new file mode 100644
index 00000000..07106144
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/mapper/MatchingMapper.kt
@@ -0,0 +1,55 @@
+package com.moya.funch.mapper
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.match.Chemistry
+import com.moya.funch.entity.match.MatchInfo
+import com.moya.funch.entity.match.Matching
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.network.dto.response.match.ChemistryResponse
+import com.moya.funch.network.dto.response.match.MatchInfoResponse
+import com.moya.funch.network.dto.response.match.MatchingResponse
+import com.moya.funch.network.dto.response.match.ProfileResponse
+import com.moya.funch.network.dto.response.match.SubwayResponse
+
+fun MatchingResponse.toDomain(): Matching {
+ return Matching(
+ profile = profile.toDomain(),
+ similarity = similarity,
+ chemistrys = chemistryInfos.map { it.toDomain() },
+ matchInfos = matchInfos.map { it.toDomain() },
+ subwayChemistry = subwayChemistry?.toDomain()
+ )
+}
+
+private fun ProfileResponse.toDomain(): Profile {
+ return Profile(
+ name = name,
+ job = Job.of(jobGroup),
+ clubs = clubs.map { Club.of(it) },
+ mbti = Mbti.valueOf(mbti),
+ blood = Blood.valueOf(bloodType),
+ subways = this.subways.map { it.toDomain() }
+ )
+}
+
+private fun ChemistryResponse.toDomain(): Chemistry {
+ return Chemistry(
+ title = title,
+ description = description
+ )
+}
+
+private fun MatchInfoResponse.toDomain(): MatchInfo {
+ return MatchInfo(
+ title = title
+ )
+}
+
+private fun SubwayResponse.toDomain(): SubwayStation {
+ return SubwayStation(name = name, lines = lines.map { SubwayLine.valueOf(it) })
+}
diff --git a/core/data/src/main/java/com/moya/funch/mapper/SubwayStationMapper.kt b/core/data/src/main/java/com/moya/funch/mapper/SubwayStationMapper.kt
new file mode 100644
index 00000000..d04a5839
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/mapper/SubwayStationMapper.kt
@@ -0,0 +1,10 @@
+package com.moya.funch.mapper
+
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.network.dto.response.subwaystation.SubwayStationsResponse
+
+fun SubwayStationsResponse.toDomain() = SubwayStation(
+ name = name,
+ lines = lines.map { SubwayLine.valueOf(it) }
+)
diff --git a/core/data/src/main/java/com/moya/funch/model/ProfileModel.kt b/core/data/src/main/java/com/moya/funch/model/ProfileModel.kt
new file mode 100644
index 00000000..ea460c47
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/model/ProfileModel.kt
@@ -0,0 +1,85 @@
+package com.moya.funch.model
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.network.dto.request.MemberRequest
+import com.moya.funch.network.dto.response.member.MemberResponse
+
+data class ProfileModel(
+ val userCode: String,
+ val userId: String,
+ val name: String,
+ val jobGroup: String,
+ var bloodType: String,
+ val clubs: Set,
+ val subwayName: String,
+ val subwayLines: Set,
+ val mbti: String,
+ val viewCount: Int = 0
+) {
+ fun toDomain(): Profile {
+ return Profile(
+ code = userCode,
+ id = userId,
+ name = name,
+ job = Job.of(jobGroup),
+ blood = Blood.valueOf(bloodType),
+ clubs = clubs.map { club -> Club.of(club) },
+ subways = listOf(
+ SubwayStation(
+ name = subwayName,
+ lines = subwayLines.map { line -> SubwayLine.valueOf(line) }
+ )
+ ),
+ mbti = Mbti.valueOf(mbti),
+ viewCount = viewCount
+ )
+ }
+
+ fun toRequest(deviceNumber: String): MemberRequest {
+ return MemberRequest(
+ deviceNumber = deviceNumber,
+ name = name,
+ jobGroup = jobGroup,
+ bloodType = bloodType,
+ clubs = clubs.toList(),
+ subwayStations = listOf(subwayName),
+ mbti = mbti
+ )
+ }
+}
+
+fun Profile.toModel(): ProfileModel {
+ return ProfileModel(
+ userCode = code,
+ userId = id,
+ name = name,
+ jobGroup = job.name,
+ bloodType = blood.name,
+ clubs = clubs.map { it.name }.toSet(),
+ subwayName = subways.firstOrNull()?.name ?: "",
+ subwayLines = subways.firstOrNull()?.lines?.map { it.name }?.toSet() ?: emptySet(),
+ mbti = mbti.name,
+ viewCount = viewCount
+ )
+}
+
+fun MemberResponse.toModel(): ProfileModel {
+ return ProfileModel(
+ userCode = memberCode,
+ userId = id,
+ name = name,
+ jobGroup = jobGroup,
+ bloodType = bloodType,
+ clubs = clubs.toSet(),
+ subwayName = subwayInfos.firstOrNull()?.name ?: "",
+ subwayLines = subwayInfos.firstOrNull()?.lines?.toSet() ?: emptySet(),
+ mbti = mbti,
+ viewCount = viewCount
+ )
+}
diff --git a/core/data/src/main/java/com/moya/funch/repository/MatchingRepositoryImpl.kt b/core/data/src/main/java/com/moya/funch/repository/MatchingRepositoryImpl.kt
new file mode 100644
index 00000000..b0b78c02
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/repository/MatchingRepositoryImpl.kt
@@ -0,0 +1,14 @@
+package com.moya.funch.repository
+
+import com.moya.funch.datasource.remote.RemoteMatchDataSource
+import com.moya.funch.entity.match.Matching
+import com.moya.funch.mapper.toDomain
+import javax.inject.Inject
+
+class MatchingRepositoryImpl @Inject constructor(
+ private val remoteMatchDataSource: RemoteMatchDataSource
+) : MatchingRepository {
+ override suspend fun matchProfile(targetCode: String): Result {
+ return remoteMatchDataSource.matchProfile(targetCode).mapCatching { it.toDomain() }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/repository/MemberRepositoryImpl.kt b/core/data/src/main/java/com/moya/funch/repository/MemberRepositoryImpl.kt
new file mode 100644
index 00000000..d41cf2ae
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/repository/MemberRepositoryImpl.kt
@@ -0,0 +1,44 @@
+package com.moya.funch.repository
+
+import com.moya.funch.datasource.local.LocalUserDataSource
+import com.moya.funch.datasource.remote.RemoteMemberDataSource
+import com.moya.funch.datasource.remote.RemoteUserDataSource
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.model.toModel
+import javax.inject.Inject
+
+class MemberRepositoryImpl @Inject constructor(
+ private val remoteUserDataSource: RemoteUserDataSource,
+ private val localUserDataSource: LocalUserDataSource,
+ private val remoteMemberDataSource: RemoteMemberDataSource
+) : MemberRepository {
+ override suspend fun fetchUserProfile(): Result {
+ val profileResult = localUserDataSource.fetchUserProfile()
+ if (profileResult.isSuccess) {
+ return profileResult.mapCatching { it.toDomain() }
+ }
+
+ return remoteUserDataSource.fetchUserProfile().onSuccess {
+ localUserDataSource.saveUserProfile(it)
+ }.mapCatching { it.toDomain() }
+ }
+
+ override suspend fun createUserProfile(profile: Profile): Result {
+ return remoteUserDataSource.createUserProfile(profile.toModel())
+ .onSuccess {
+ localUserDataSource.saveUserProfile(it)
+ }.mapCatching { it.toDomain() }
+ }
+
+ override suspend fun fetchUserViewCount(): Result {
+ return remoteUserDataSource.fetchUserProfile().mapCatching {
+ it.viewCount
+ }
+ }
+
+ override suspend fun fetchMemberProfile(id: String): Result {
+ return remoteMemberDataSource.fetchMemberProfile(id).mapCatching {
+ it.toDomain()
+ }
+ }
+}
diff --git a/core/data/src/main/java/com/moya/funch/repository/SubwayRepositoryImpl.kt b/core/data/src/main/java/com/moya/funch/repository/SubwayRepositoryImpl.kt
new file mode 100644
index 00000000..a3ea89ea
--- /dev/null
+++ b/core/data/src/main/java/com/moya/funch/repository/SubwayRepositoryImpl.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.repository
+
+import com.moya.funch.datasource.remote.RemoteSubwayDataSource
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.mapper.toDomain
+import javax.inject.Inject
+
+class SubwayRepositoryImpl @Inject constructor(
+ private val remoteSubwayDataSource: RemoteSubwayDataSource
+) : SubwayRepository {
+
+ override suspend fun fetchSubwayStations(subwayStation: String): Result> {
+ return remoteSubwayDataSource.fetchSubwayStations(subwayStation = subwayStation)
+ .mapCatching { response ->
+ response.map { it.toDomain() }
+ }
+ }
+}
diff --git a/core/data/src/test/java/com/moya/funch/datasource/local/LocalUserDataSourceImplTest.kt b/core/data/src/test/java/com/moya/funch/datasource/local/LocalUserDataSourceImplTest.kt
new file mode 100644
index 00000000..4e4cd97d
--- /dev/null
+++ b/core/data/src/test/java/com/moya/funch/datasource/local/LocalUserDataSourceImplTest.kt
@@ -0,0 +1,114 @@
+package com.moya.funch.datasource.local
+
+import com.google.common.truth.Truth.assertThat
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.model.ProfileModel
+import com.moya.funch.rule.CoroutinesTestExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertAll
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MockKExtension::class)
+internal class LocalUserDataSourceImplTest {
+
+ @RelaxedMockK
+ lateinit var userDataStore: UserDataStore
+ private lateinit var localUserDataSource: LocalUserDataSource
+
+ @BeforeEach
+ fun setUp() {
+ localUserDataSource = LocalUserDataSourceImpl(
+ userDataStore
+ )
+ }
+
+ @Test
+ fun `Id가 있으면 내부 저장소에서 Profile을 가져온다`() = runTest {
+ // given
+ val expectedProfile = ProfileModel(
+ userCode = "userCode",
+ userId = "userId",
+ name = "userName",
+ jobGroup = "jobGroup",
+ bloodType = "bloodType",
+ clubs = setOf("clubs"),
+ subwayName = "subwayName",
+ subwayLines = setOf("subwayLines"),
+ mbti = "mbti"
+ )
+
+ coEvery { userDataStore.hasUserId() } returns true
+ coEvery { userDataStore.userCode } returns "userCode"
+ coEvery { userDataStore.userId } returns "userId"
+ coEvery { userDataStore.userName } returns "userName"
+ coEvery { userDataStore.jobGroup } returns "jobGroup"
+ coEvery { userDataStore.bloodType } returns "bloodType"
+ coEvery { userDataStore.clubs } returns setOf("clubs")
+ coEvery { userDataStore.subwayName } returns "subwayName"
+ coEvery { userDataStore.subwayLines } returns setOf("subwayLines")
+ coEvery { userDataStore.mbti } returns "mbti"
+ // when
+ val actualResult: Result = localUserDataSource.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { userDataStore.hasUserId() } },
+ { coVerify(exactly = 1) { userDataStore.userCode } },
+ { coVerify(exactly = 1) { userDataStore.mbti } },
+ { assertThat(actualResult.isSuccess).isTrue() },
+ { assertThat(actualResult.getOrNull()).isEqualTo(expectedProfile) }
+ )
+ }
+
+ @Test
+ fun `Id가 없으면 Profile 정보를 가져올 수 없다`() = runTest {
+ // given
+ coEvery { userDataStore.hasUserId() } returns false
+ // when
+ val actualResult: Result = localUserDataSource.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { userDataStore.hasUserId() } },
+ { assertThat(actualResult.isFailure).isTrue() }
+ )
+ }
+
+ @Test
+ fun `Profile을 내부 저장소에 저장한다`() = runTest {
+ // given
+ val profile = ProfileModel(
+ userCode = "userCode",
+ userId = "userId",
+ name = "userName",
+ jobGroup = "jobGroup",
+ bloodType = "bloodType",
+ clubs = setOf("clubs"),
+ subwayName = "subwayName",
+ subwayLines = setOf("subwayLines"),
+ mbti = "mbti"
+ )
+ // when
+ val actualResult: Result = localUserDataSource.saveUserProfile(profile)
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { userDataStore.clear() } },
+ { coVerify(exactly = 1) { userDataStore.userCode = profile.userCode } },
+ { coVerify(exactly = 1) { userDataStore.mbti = profile.mbti } },
+ { assertThat(actualResult.isSuccess).isTrue() }
+ )
+ }
+
+ companion object {
+ @JvmField
+ @RegisterExtension
+ val coroutineExtension = CoroutinesTestExtension()
+ }
+}
diff --git a/core/data/src/test/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImplTest.kt b/core/data/src/test/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImplTest.kt
new file mode 100644
index 00000000..bf213ec7
--- /dev/null
+++ b/core/data/src/test/java/com/moya/funch/datasource/remote/RemoteUserDataSourceImplTest.kt
@@ -0,0 +1,68 @@
+package com.moya.funch.datasource.remote
+
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.network.service.MemberService
+import com.moya.funch.rule.CoroutinesTestExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MockKExtension::class)
+internal class RemoteUserDataSourceImplTest {
+
+ @RelaxedMockK
+ lateinit var userDataStore: UserDataStore
+
+ @RelaxedMockK
+ lateinit var memberService: MemberService
+ private lateinit var remoteUserDataSource: RemoteUserDataSource
+
+ @BeforeEach
+ fun setUp() {
+ remoteUserDataSource = RemoteUserDataSourceImpl(
+ userDataStore,
+ memberService
+ )
+ }
+
+ @Test
+ fun `id가 있으면 id로 profile을 불러온다`() = runTest {
+ // given
+ coEvery { userDataStore.hasUserId() } returns true
+ // when
+ remoteUserDataSource.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { memberService.findMemberById(any()) } },
+ { coVerify(exactly = 0) { memberService.findMemberByDeviceNumber(any()) } }
+ )
+ }
+
+ @Test
+ fun `id가 없으면 device id로 profile을 불러온다`() = runTest {
+ // given
+ coEvery { userDataStore.hasUserId() } returns false
+ // when
+ remoteUserDataSource.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 0) { memberService.findMemberById(any()) } },
+ { coVerify(exactly = 1) { memberService.findMemberByDeviceNumber(any()) } }
+ )
+ }
+
+ companion object {
+ @JvmField
+ @RegisterExtension
+ val coroutineExtension = CoroutinesTestExtension()
+ }
+}
diff --git a/core/data/src/test/java/com/moya/funch/repository/MemberRepositoryImplTest.kt b/core/data/src/test/java/com/moya/funch/repository/MemberRepositoryImplTest.kt
new file mode 100644
index 00000000..704dd268
--- /dev/null
+++ b/core/data/src/test/java/com/moya/funch/repository/MemberRepositoryImplTest.kt
@@ -0,0 +1,136 @@
+package com.moya.funch.repository
+
+import com.google.common.truth.Truth.assertThat
+import com.moya.funch.datasource.local.LocalUserDataSource
+import com.moya.funch.datasource.remote.RemoteMemberDataSource
+import com.moya.funch.datasource.remote.RemoteUserDataSource
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.model.ProfileModel
+import com.moya.funch.rule.CoroutinesTestExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MockKExtension::class)
+internal class MemberRepositoryImplTest {
+ @RelaxedMockK
+ private lateinit var remoteUserDataSource: RemoteUserDataSource
+
+ @RelaxedMockK
+ private lateinit var localUserDataSource: LocalUserDataSource
+
+ @RelaxedMockK
+ private lateinit var remoteMemberDataSource: RemoteMemberDataSource
+ private lateinit var memberRepository: MemberRepository
+
+ @BeforeEach
+ fun setUp() {
+ memberRepository = MemberRepositoryImpl(
+ remoteUserDataSource,
+ localUserDataSource,
+ remoteMemberDataSource
+ )
+ }
+
+ @Test
+ fun `local에 User Profile이 있으면 불러온다`() = runTest {
+ // given
+ coEvery { localUserDataSource.fetchUserProfile() } returns Result.success(
+ ProfileModel(
+ userCode = "NONE",
+ userId = "userId",
+ name = "userName",
+ jobGroup = "개발자",
+ bloodType = "O",
+ clubs = setOf("SOPT"),
+ subwayName = "subwayName",
+ subwayLines = emptySet(),
+ mbti = "INTJ"
+ )
+ )
+ // when
+ val result = memberRepository.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { localUserDataSource.fetchUserProfile() } },
+ { coVerify(exactly = 0) { remoteUserDataSource.fetchUserProfile() } },
+ { coVerify(exactly = 0) { localUserDataSource.saveUserProfile(any()) } },
+ { assertThat(result.isSuccess).isTrue() }
+ )
+ }
+
+ @Test
+ fun `local에 User Profile이 없으면 remote에서 불러온다`() = runTest {
+ // given
+ coEvery { localUserDataSource.fetchUserProfile() } returns Result.failure(
+ Exception("User Profile not found")
+ )
+ coEvery { remoteUserDataSource.fetchUserProfile() } returns Result.success(
+ ProfileModel(
+ userCode = "NONE",
+ userId = "userId",
+ name = "userName",
+ jobGroup = "개발자",
+ bloodType = "O",
+ clubs = setOf("SOPT"),
+ subwayName = "subwayName",
+ subwayLines = emptySet(),
+ mbti = "INTJ"
+ )
+ )
+ coEvery { localUserDataSource.saveUserProfile(any()) } returns Result.success(
+ Unit
+ )
+ // when
+ val result = memberRepository.fetchUserProfile()
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { localUserDataSource.fetchUserProfile() } },
+ { coVerify(exactly = 1) { remoteUserDataSource.fetchUserProfile() } },
+ { coVerify(exactly = 1) { localUserDataSource.saveUserProfile(any()) } },
+ { assertThat(result.isSuccess).isTrue() }
+ )
+ }
+
+ @Test
+ fun `User Profile을 만들고 local에 저장한다`() = runTest {
+ // given
+ val profile = Profile()
+ coEvery { remoteUserDataSource.createUserProfile(any()) } returns Result.success(
+ ProfileModel(
+ userCode = "NONE",
+ userId = "userId",
+ name = "userName",
+ jobGroup = "개발자",
+ bloodType = "O",
+ clubs = setOf("SOPT"),
+ subwayName = "subwayName",
+ subwayLines = emptySet(),
+ mbti = "INTJ"
+ )
+ )
+ // when
+ val profileResult = memberRepository.createUserProfile(profile)
+ // then
+ assertAll(
+ { coVerify(exactly = 1) { remoteUserDataSource.createUserProfile(any()) } },
+ { coVerify(exactly = 1) { localUserDataSource.saveUserProfile(any()) } },
+ { assertThat(profileResult.isSuccess).isTrue() }
+ )
+ }
+
+ companion object {
+ @JvmField
+ @RegisterExtension
+ val coroutineExtension = CoroutinesTestExtension()
+ }
+}
diff --git a/core/datastore/.gitignore b/core/datastore/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/datastore/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
new file mode 100644
index 00000000..0cd666f2
--- /dev/null
+++ b/core/datastore/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ alias(libs.plugins.funch.android.library)
+}
+
+android {
+ namespace = "com.moja.funch.datastore"
+}
+
+dependencies {
+ implementation(libs.security)
+}
diff --git a/core/datastore/src/main/java/com/moya/funch/datastore/PreferenceFactory.kt b/core/datastore/src/main/java/com/moya/funch/datastore/PreferenceFactory.kt
new file mode 100644
index 00000000..94f9f5dd
--- /dev/null
+++ b/core/datastore/src/main/java/com/moya/funch/datastore/PreferenceFactory.kt
@@ -0,0 +1,58 @@
+package com.moya.funch.datastore
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.moja.funch.datastore.BuildConfig
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.inject.Inject
+
+class PreferenceFactory @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ fun create(): SharedPreferences {
+ return if (BuildConfig.DEBUG) {
+ context.getSharedPreferences(DEBUG_DATASTORE_KEY, Context.MODE_PRIVATE)
+ } else {
+ try {
+ createEncryptedSharedPreferences(DATASTORE_KEY, context)
+ } catch (e: GeneralSecurityException) {
+ deleteMasterKeyEntry()
+ deletePreference(DATASTORE_KEY, context)
+ createEncryptedSharedPreferences(DATASTORE_KEY, context)
+ }
+ }
+ }
+
+ private fun createEncryptedSharedPreferences(fileName: String, context: Context): SharedPreferences {
+ return EncryptedSharedPreferences.create(
+ context,
+ fileName,
+ MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ private fun deletePreference(fileName: String, context: Context) {
+ context.deleteSharedPreferences(fileName)
+ }
+
+ private fun deleteMasterKeyEntry() {
+ KeyStore.getInstance(ANDROID_KEY_STORE).apply {
+ load(null)
+ deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
+ }
+ }
+
+ companion object {
+ private const val DEBUG_DATASTORE_KEY = "DEBUG_DATASTORE_KEY"
+ private const val DATASTORE_KEY = "DATASTORE_KEY"
+ private const val ANDROID_KEY_STORE = "AndroidKeyStore"
+ }
+}
diff --git a/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStore.kt b/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStore.kt
new file mode 100644
index 00000000..c3313976
--- /dev/null
+++ b/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStore.kt
@@ -0,0 +1,20 @@
+package com.moya.funch.datastore
+
+interface UserDataStore {
+ var deviceId: String
+ var userCode: String
+ var userId: String
+ var userName: String
+ var jobGroup: String
+ var bloodType: String
+ var clubs: Set
+ var subwayName: String
+ var subwayLines: Set
+ var mbti: String
+
+ fun hasUserCode(): Boolean
+
+ fun hasUserId(): Boolean
+
+ fun clear()
+}
diff --git a/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStoreImpl.kt b/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStoreImpl.kt
new file mode 100644
index 00000000..1581e018
--- /dev/null
+++ b/core/datastore/src/main/java/com/moya/funch/datastore/UserDataStoreImpl.kt
@@ -0,0 +1,127 @@
+package com.moya.funch.datastore
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.SharedPreferences
+import android.provider.Settings
+import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+@SuppressLint("HardwareIds")
+class UserDataStoreImpl @Inject constructor(
+ private val preferences: SharedPreferences,
+ @ApplicationContext private val context: Context
+) : UserDataStore {
+ override var deviceId: String
+ get() {
+ initDeviceId()
+ return preferences.getString(DEVICE_ID, "").orEmpty()
+ }
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(DEVICE_ID, value)
+ }
+ }
+
+ override var userCode: String
+ get() = preferences.getString(USER_CODE, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(USER_CODE, value)
+ }
+ }
+ override var userId: String
+ get() = preferences.getString(USER_ID, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(USER_ID, value)
+ }
+ }
+
+ override var userName: String
+ get() = preferences.getString(USER_NAME, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(USER_NAME, value)
+ }
+ }
+ override var jobGroup: String
+ get() = preferences.getString(JOB_GROUP, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(JOB_GROUP, value)
+ }
+ }
+ override var bloodType: String
+ get() = preferences.getString(BLOOD_TYPE, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(BLOOD_TYPE, value)
+ }
+ }
+
+ override var clubs: Set
+ get() = preferences.getStringSet(CLUBS, setOf()).orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putStringSet(CLUBS, value)
+ }
+ }
+
+ override var subwayName: String
+ get() = preferences.getString(SUBWAY_NAME, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(SUBWAY_NAME, value)
+ }
+ }
+ override var subwayLines: Set
+ get() = preferences.getStringSet(SUBWAY_LINE, setOf()).orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putStringSet(SUBWAY_LINE, value)
+ }
+ }
+
+ override var mbti: String
+ get() = preferences.getString(MBTI, "").orEmpty()
+ set(value) {
+ preferences.edit(commit = true) {
+ putString(MBTI, value)
+ }
+ }
+
+ override fun hasUserCode(): Boolean {
+ return preferences.contains(USER_CODE)
+ }
+
+ override fun hasUserId(): Boolean {
+ return preferences.contains(USER_ID)
+ }
+
+ override fun clear() {
+ preferences.edit(commit = true) {
+ clear()
+ }
+ }
+
+ private fun initDeviceId() {
+ if (preferences.contains(DEVICE_ID).not()) {
+ deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
+ }
+ }
+
+ private companion object {
+ const val DEVICE_ID = "DEVICE_ID"
+ const val USER_CODE = "USER_CODE"
+ const val USER_ID = "USER_ID"
+ const val USER_NAME = "USER_NAME"
+ const val JOB_GROUP = "JOB_GROUP"
+ const val BLOOD_TYPE = "BLOOD_TYPE"
+ const val CLUBS = "CLUBS"
+ const val SUBWAY_NAME = "SUBWAY_NAME"
+ const val SUBWAY_LINE = "SUBWAY_LINE"
+ const val MBTI = "MBTI"
+ }
+}
diff --git a/core/datastore/src/main/java/com/moya/funch/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/moya/funch/datastore/di/DataStoreModule.kt
new file mode 100644
index 00000000..6fbab691
--- /dev/null
+++ b/core/datastore/src/main/java/com/moya/funch/datastore/di/DataStoreModule.kt
@@ -0,0 +1,28 @@
+package com.moya.funch.datastore.di
+
+import android.content.SharedPreferences
+import com.moya.funch.datastore.PreferenceFactory
+import com.moya.funch.datastore.UserDataStore
+import com.moya.funch.datastore.UserDataStoreImpl
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+ @Provides
+ @Singleton
+ fun provideAppPreferences(factory: PreferenceFactory): SharedPreferences = factory.create()
+
+ @Module
+ @InstallIn(SingletonComponent::class)
+ abstract class Binder {
+ @Binds
+ @Singleton
+ abstract fun bindUserDataStore(userDataStore: UserDataStoreImpl): UserDataStore
+ }
+}
diff --git a/core/designsystem/.gitignore b/core/designsystem/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /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 00000000..c6df68cc
--- /dev/null
+++ b/core/designsystem/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ alias(libs.plugins.funch.android.library)
+ alias(libs.plugins.funch.compose)
+}
+
+android {
+ namespace = "com.moya.funch.designsystem"
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/common/SearchPainter.kt b/core/designsystem/src/main/java/com/moya/funch/common/SearchPainter.kt
new file mode 100644
index 00000000..a1cf54a0
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/common/SearchPainter.kt
@@ -0,0 +1,49 @@
+package com.moya.funch.common
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import com.moya.funch.icon.FunchIconAsset
+
+@Composable
+fun jobPainter(value: String): Painter = when (value) {
+ "개발자" -> painterResource(id = FunchIconAsset.Job.developer_24)
+ "디자이너" -> painterResource(id = FunchIconAsset.Job.designer_24)
+ else -> throw IllegalArgumentException("Unknown Icon: $value")
+}
+
+@Composable
+fun clubPainter(value: String): Painter = when (value) {
+ "넥스터즈" -> painterResource(id = FunchIconAsset.Club.nexters_24)
+ "SOPT" -> painterResource(id = FunchIconAsset.Club.sopt_24)
+ "Depromeet" -> painterResource(id = FunchIconAsset.Club.depromeet_24)
+ else -> throw IllegalArgumentException("Unknown Icon: $value")
+}
+
+@Composable
+fun subwayLinePainter(value: String): Painter = when (value) {
+ "ONE" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_one)
+ "TWO" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_two)
+ "THREE" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_three)
+ "FOUR" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_four)
+ "FIVE" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_five)
+ "SIX" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_six)
+ "SEVEN" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_seven)
+ "EIGHT" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_eight)
+ "NINE" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_nine)
+ "SINBUNDANG" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_shinbundang)
+ "BUNDANG" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_suinbundang)
+ "AIRPORT" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_airport)
+ "YOUNGIN" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_youngin_ever)
+ "GYEONGCHUN" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_gyeongchun)
+ "SILLIM" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_sillim)
+ "GYEONGGANG" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_geonggang)
+ "SEOHAE" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_seohae)
+ "GYEONGUI" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_gyeongui_jungang)
+ "INCHEON" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_incheon_one)
+ "UIJEONGBU" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_uijeongbu)
+ "UI_SINSEOL" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_ui_sinseol)
+ "GIMPO" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_gimpo_goldline)
+ "INCHEON_TWO" -> painterResource(id = FunchIconAsset.SubwayLine.subway_line_incheon_two)
+ else -> throw IllegalArgumentException("Unknown Icon: $value")
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/component/Button.kt b/core/designsystem/src/main/java/com/moya/funch/component/Button.kt
new file mode 100644
index 00000000..3d83ba5f
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/component/Button.kt
@@ -0,0 +1,427 @@
+package com.moya.funch.component
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.Indication
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.modifier.clickableSingle
+import com.moya.funch.modifier.neonSign
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.Gray900
+import com.moya.funch.theme.Lemon500
+import com.moya.funch.theme.Lemon900
+import com.moya.funch.theme.White
+import com.moya.funch.theme.Yellow500
+import com.moya.funch.theme.funchTypography
+
+enum class FunchButtonType(val shape: Shape, val contentVerticalPadding: Dp, val textStyle: TextStyle) {
+ Full(RoundedCornerShape(16.dp), 20.5f.dp, funchTypography.sbt1),
+ Large(RoundedCornerShape(16.dp), 20.5f.dp, funchTypography.sbt1),
+ Medium(RoundedCornerShape(16.dp), 16.dp, funchTypography.sbt2),
+ Small(RoundedCornerShape(12.dp), 12.dp, funchTypography.b),
+ XSmall(RoundedCornerShape(12.dp), 8.dp, funchTypography.b)
+}
+
+@Immutable
+data class FunchIcon(
+ @DrawableRes val resId: Int,
+ val description: String,
+ val tint: Color
+)
+
+@Composable
+fun FunchMainButton(
+ buttonType: FunchButtonType,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ text: String,
+ contentHorizontalPadding: Dp = 0.dp,
+ icon: @Composable () -> Unit = {}
+) {
+ FunchMainButton(
+ modifier = modifier,
+ onClick = onClick,
+ enabled = enabled,
+ shape = buttonType.shape,
+ contentPadding = PaddingValues(contentHorizontalPadding, buttonType.contentVerticalPadding)
+ ) {
+ Text(text = text, color = Gray900, style = buttonType.textStyle)
+ icon()
+ }
+}
+
+@Composable
+fun FunchMainButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ shape: Shape = RoundedCornerShape(size = 16.dp),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ content: @Composable RowScope.() -> Unit
+) {
+ val brush = Brush.horizontalGradient(listOf(Lemon500, Yellow500))
+ val disabledColor = Lemon900
+ Box(
+ modifier =
+ modifier
+ .then(
+ if (enabled) {
+ Modifier.neonSign(
+ color = Lemon500.copy(alpha = 0.7f),
+ borderRadius = 16.dp,
+ blurRadius = 5.dp,
+ spread = PaddingValues(top = (-4).dp, start = 0.dp, end = 0.dp, bottom = 4.dp)
+ )
+ } else {
+ Modifier
+ }
+ )
+ .clip(shape = shape)
+ .then(
+ if (enabled) {
+ Modifier.background(brush = brush)
+ } else {
+ Modifier.background(color = disabledColor)
+ }
+ )
+ .clickableSingle(enabled = enabled, onClick = onClick)
+ .padding(contentPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ Row {
+ content()
+ }
+ }
+}
+
+@Composable
+fun FunchSubButton(
+ buttonType: FunchButtonType,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ text: String,
+ contentHorizontalPadding: Dp = 0.dp,
+ icon: @Composable () -> Unit = {}
+) {
+ val color =
+ if (enabled) {
+ White
+ } else {
+ Gray400
+ }
+ FunchSubButton(
+ modifier = modifier,
+ onClick = onClick,
+ enabled = enabled,
+ shape = buttonType.shape,
+ contentPadding = PaddingValues(contentHorizontalPadding, buttonType.contentVerticalPadding)
+ ) {
+ Text(text = text, color = color, style = buttonType.textStyle)
+ icon()
+ }
+}
+
+@Composable
+fun FunchSubButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ shape: Shape = RoundedCornerShape(size = 16.dp),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ content: @Composable RowScope.() -> Unit
+) {
+ Box(
+ modifier =
+ modifier
+ .clip(shape = shape)
+ .background(Gray800)
+ .clickableSingle(enabled = enabled, onClick = onClick)
+ .padding(contentPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ Row {
+ content()
+ }
+ }
+}
+
+@Composable
+fun FunchIconButton(
+ enabled: Boolean = true,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ funchIcon: FunchIcon,
+ backgroundColor: Color = Color.Transparent,
+ roundedCornerShape: RoundedCornerShape = RoundedCornerShape(0.dp),
+ indication: Indication? = LocalIndication.current,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Box(
+ modifier =
+ modifier
+ .background(
+ color = backgroundColor,
+ shape = roundedCornerShape
+ )
+ .clip(roundedCornerShape)
+ .clickable(
+ enabled = enabled,
+ onClick = onClick,
+ indication = indication,
+ interactionSource = interactionSource
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = funchIcon.resId),
+ contentDescription = funchIcon.description,
+ tint = funchIcon.tint
+ )
+ }
+}
+
+// ============================== Preview =================================
+
+@Preview(name = "main button size", showBackground = true, widthDp = 360)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Gray900)
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ FunchMainButton(
+ modifier = Modifier.fillMaxWidth(),
+ buttonType = FunchButtonType.Full,
+ onClick = { /*TODO*/ },
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchMainButton(
+ buttonType = FunchButtonType.Large,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 120.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchMainButton(
+ buttonType = FunchButtonType.Medium,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 60.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchMainButton(
+ buttonType = FunchButtonType.Small,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 16.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchMainButton(
+ buttonType = FunchButtonType.XSmall,
+ onClick = { /*TODO*/ },
+ text = "Button",
+ contentHorizontalPadding = 12.dp
+ )
+ }
+ }
+}
+
+@Preview(name = "subButton - size", showBackground = true, widthDp = 360)
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Gray900)
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ FunchSubButton(
+ modifier = Modifier.fillMaxWidth(),
+ buttonType = FunchButtonType.Full,
+ onClick = { /*TODO*/ },
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchSubButton(
+ buttonType = FunchButtonType.Large,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 120.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchSubButton(
+ buttonType = FunchButtonType.Medium,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 60.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchSubButton(
+ buttonType = FunchButtonType.Small,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 16.dp,
+ text = "Button"
+ )
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchSubButton(
+ buttonType = FunchButtonType.XSmall,
+ onClick = { /*TODO*/ },
+ text = "Button",
+ contentHorizontalPadding = 12.dp
+ )
+ }
+ }
+}
+
+@Preview(name = "main button - enabled, disabled", showBackground = true, widthDp = 360)
+@Composable
+private fun Preview3() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Color.Black)
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 20.dp)
+ ) {
+ FunchMainButton(contentPadding = PaddingValues(vertical = 16.dp, horizontal = 24.dp), onClick = { }) {
+ Text(
+ text = "Button",
+ style = FunchTheme.typography.sbt1,
+ color = Gray900,
+ textAlign = TextAlign.Center
+ )
+ }
+ Spacer(modifier = Modifier.padding(16.dp))
+
+ FunchMainButton(
+ enabled = false,
+ contentPadding = PaddingValues(vertical = 16.dp, horizontal = 24.dp),
+ onClick = { }
+ ) {
+ Text(
+ text = "Button",
+ style = FunchTheme.typography.sbt1,
+ color = Gray900,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+}
+
+@Preview(name = "subButton - enabled, disabled ", showBackground = true, widthDp = 360)
+@Composable
+private fun Preview4() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Gray900)
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 20.dp)
+ ) {
+ FunchSubButton(
+ buttonType = FunchButtonType.Medium,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 60.dp,
+ text = "Button"
+ )
+
+ Spacer(modifier = Modifier.padding(16.dp))
+ FunchSubButton(
+ enabled = false,
+ buttonType = FunchButtonType.Medium,
+ onClick = { /*TODO*/ },
+ contentHorizontalPadding = 60.dp,
+ text = "Button"
+ )
+ }
+ }
+}
+
+@Preview("Icon Large Button", showBackground = true, backgroundColor = 0xFFFFFF)
+@Composable
+private fun Preview5() {
+ FunchIconButton(
+ modifier = Modifier.size(40.dp),
+ roundedCornerShape = RoundedCornerShape(12.dp),
+ backgroundColor = Gray500,
+ onClick = { /*TODO*/ },
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "",
+ tint = Yellow500
+ )
+ )
+}
+
+@Preview("Icon Medium Button", showBackground = true)
+@Composable
+private fun Preview6() {
+ FunchIconButton(
+ onClick = { /*TODO*/ },
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "",
+ tint = Gray400
+ )
+ )
+}
+
+@Preview("Icon Small Button", showBackground = true)
+@Composable
+private fun Preview7() {
+ FunchIconButton(
+ onClick = { /*TODO*/ },
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_16,
+ description = "",
+ tint = Gray400
+ ),
+ indication = null
+ )
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/component/Chip.kt b/core/designsystem/src/main/java/com/moya/funch/component/Chip.kt
new file mode 100644
index 00000000..0564adbd
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/component/Chip.kt
@@ -0,0 +1,360 @@
+package com.moya.funch.component
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.modifier.neonSign
+import com.moya.funch.theme.FunchRadiusDefaults
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.Gray900
+import com.moya.funch.theme.Lemon500
+import com.moya.funch.theme.White
+import com.moya.funch.theme.Yellow500
+
+private val FunchChipContentMinHeight = 21.dp
+private val FunchMinHeight = 48.dp
+val MATCHED_BORDER_BRUSH =
+ Brush.horizontalGradient(
+ listOf(Lemon500, Yellow500)
+ )
+
+@Composable
+fun FunchChip(
+ modifier: Modifier = Modifier,
+ matched: Boolean = false,
+ selected: Boolean,
+ enabled: Boolean,
+ onSelected: () -> Unit = {},
+ shape: CornerBasedShape = FunchTheme.shapes.medium,
+ label: @Composable () -> Unit,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ colors: PunchChipColors = PunchChipColors(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ val indication = if (enabled) LocalIndication.current else null
+ Row(
+ modifier =
+ modifier
+ .defaultMinSize(minHeight = FunchMinHeight)
+ .then(
+ if (matched) {
+ Modifier
+ .sizeIn()
+ .neonSign(
+ color = Lemon500.copy(alpha = 0.6f),
+ borderRadius = FunchRadiusDefaults.Medium,
+ blurRadius = 5.dp,
+ spread = PaddingValues((-1).dp)
+ )
+ .border(1.dp, MATCHED_BORDER_BRUSH, shape)
+ } else {
+ Modifier
+ }
+ )
+ .background(
+ color = colors.provideContainerColor(enabled, selected),
+ shape = shape
+ )
+ .selectable(
+ selected = selected,
+ enabled = enabled,
+ onClick = onSelected,
+ interactionSource = interactionSource,
+ indication = indication
+ )
+ .padding(provideChipPadding(leadingIcon != null)),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ChipContent(
+ label = label,
+ labelColor = colors.provideLabelColor(enabled, selected),
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ paddingValues = PaddingValues(vertical = 4.dp)
+ )
+ }
+}
+
+@Composable
+private fun ChipContent(
+ label: @Composable () -> Unit,
+ labelTextStyle: TextStyle = FunchTheme.typography.b,
+ labelColor: Color,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ minHeight: Dp = FunchChipContentMinHeight,
+ paddingValues: PaddingValues
+) {
+ CompositionLocalProvider(
+ LocalContentColor provides labelColor,
+ LocalTextStyle provides labelTextStyle
+ ) {
+ Row(
+ Modifier
+ .defaultMinSize(minHeight = minHeight)
+ .padding(paddingValues),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (leadingIcon != null) {
+ leadingIcon()
+ Spacer(Modifier.width(8.dp))
+ }
+ label()
+ if (trailingIcon != null) {
+ Spacer(Modifier.width(4.dp))
+ trailingIcon()
+ }
+ }
+ }
+}
+
+private fun provideChipPadding(hasLeadingIcon: Boolean = false): PaddingValues {
+ val start = if (hasLeadingIcon) 8.dp else 16.dp
+ val end = 16.dp
+ return PaddingValues(start = start, end = end)
+}
+
+@Immutable
+data class PunchChipColors(
+ private val containerColor: Color = Gray800,
+ private val selectedContainerColor: Color = Gray500,
+ private val disabledContainerColor: Color = Gray800,
+ private val disabledSelectedContainerColor: Color = Gray500,
+ private val labelColor: Color = Gray400,
+ private val selectedLabelColor: Color = White,
+ private val disabledLabelColor: Color = Gray400,
+ private val disabledSelectedLabelColor: Color = White
+) {
+ @Stable
+ fun provideContainerColor(enabled: Boolean, selected: Boolean): Color {
+ return when {
+ enabled && selected -> selectedContainerColor
+ !enabled && selected -> disabledSelectedContainerColor
+ !enabled -> disabledContainerColor
+ else -> containerColor
+ }
+ }
+
+ @Stable
+ fun provideLabelColor(enabled: Boolean, selected: Boolean): Color {
+ return when {
+ enabled && selected -> selectedLabelColor
+ !enabled && selected -> disabledSelectedLabelColor
+ !enabled -> disabledLabelColor
+ else -> labelColor
+ }
+ }
+}
+
+/**
+ * Preview
+ * */
+@Preview(
+ name = "FunchChip - Chip with leading and trailing icon",
+ showBackground = true
+)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ val isSelected = remember { mutableStateOf(false) }
+ val onSelected = { isSelected.value = !isSelected.value }
+
+ FunchChip(
+ selected = isSelected.value,
+ enabled = true,
+ onSelected = onSelected,
+ leadingIcon = { LeadingIconForPreview() },
+ trailingIcon = { TrailingIconForPreview() },
+ label = { Text(text = "안뇽") }
+ )
+ }
+}
+
+@Composable
+@Preview(
+ name = "FunchChip - Chip with leading icon",
+ showBackground = true
+)
+private fun Preview2() {
+ FunchTheme {
+ val isSelected = remember { mutableStateOf(false) }
+ val onSelected = { isSelected.value = !isSelected.value }
+ FunchChip(
+ selected = isSelected.value,
+ enabled = true,
+ onSelected = onSelected,
+ leadingIcon = { LeadingIconForPreview() },
+ label = { Text(text = "안뇽") }
+ )
+ }
+}
+
+@Composable
+@Preview(
+ name = "FunchChip - Chip with trailing icon",
+ showBackground = true
+)
+private fun Preview3() {
+ FunchTheme {
+ val isSelected = remember { mutableStateOf(false) }
+ val onSelected = { isSelected.value = !isSelected.value }
+ FunchChip(
+ selected = isSelected.value,
+ enabled = true,
+ onSelected = onSelected,
+ trailingIcon = { TrailingIconForPreview() },
+ label = { Text(text = "안뇽") }
+ )
+ }
+}
+
+@Preview(
+ name = "FunchChip - non interactive",
+ showBackground = true
+)
+@Composable
+private fun Preview4() {
+ FunchTheme {
+ Column(Modifier.background(Gray900)) {
+ Spacer(modifier = Modifier.size(12.dp))
+
+ FunchChip(
+ selected = false,
+ enabled = false,
+ leadingIcon = { LeadingIconForPreview() },
+ label = { Text(text = "안뇽") }
+ )
+ Spacer(modifier = Modifier.size(12.dp))
+
+ FunchChip(
+ selected = true,
+ enabled = false,
+ leadingIcon = { LeadingIconForPreview() },
+ label = { Text(text = "안뇽") }
+ )
+ }
+ }
+}
+
+@Preview(
+ name = "FunchChip - matched",
+ showBackground = true
+)
+@Composable
+private fun Preview5() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Gray900)
+ .padding(10.dp)
+ ) {
+ Column {
+ FunchChip(
+ matched = true,
+ selected = true,
+ enabled = false,
+ label = { Text(text = "Text", color = White, style = FunchTheme.typography.b) }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrailingIconForPreview() {
+ Row {
+ Box(
+ modifier =
+ Modifier
+ .background(White, CircleShape)
+ .border(1.dp, Color.Green, CircleShape)
+ .size(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "2", style = FunchTheme.typography.caption, color = Color.Green)
+ }
+ Spacer(modifier = Modifier.size(2.dp))
+ Box(
+ modifier =
+ Modifier
+ .background(White, CircleShape)
+ .border(1.dp, Color.Blue, CircleShape)
+ .size(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "3", style = FunchTheme.typography.caption, color = Color.Blue)
+ }
+ Spacer(modifier = Modifier.size(2.dp))
+ Box(
+ modifier =
+ Modifier
+ .background(White, CircleShape)
+ .border(1.dp, Color.Red, CircleShape)
+ .size(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = "4", style = FunchTheme.typography.caption, color = Color.Red)
+ }
+ }
+}
+
+@Composable
+private fun LeadingIconForPreview() {
+ Box(
+ modifier =
+ Modifier
+ .size(32.dp)
+ .clip(shape = RoundedCornerShape(10.dp))
+ .background(color = Gray900),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(18.dp),
+ painter = painterResource(id = FunchIconAsset.Arrow.arrow_up_limit_24),
+ contentDescription = "d",
+ tint = Color.Red
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/component/Label.kt b/core/designsystem/src/main/java/com/moya/funch/component/Label.kt
new file mode 100644
index 00000000..af5da5a5
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/component/Label.kt
@@ -0,0 +1,44 @@
+package com.moya.funch.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+
+@Composable
+fun FunchSmallLabel(modifier: Modifier = Modifier, text: String) {
+ Box(
+ modifier = Modifier
+ .width(52.dp)
+ .height(48.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = text,
+ color = Gray400,
+ style = FunchTheme.typography.b
+ )
+ }
+}
+
+@Composable
+fun FunchLargeLabel(modifier: Modifier = Modifier, text: String) {
+ Box(
+ modifier = modifier
+ .width(52.dp)
+ .height(56.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = text,
+ color = Gray400,
+ style = FunchTheme.typography.b
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/component/TextField.kt b/core/designsystem/src/main/java/com/moya/funch/component/TextField.kt
new file mode 100644
index 00000000..7ec5d04a
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/component/TextField.kt
@@ -0,0 +1,528 @@
+package com.moya.funch.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.theme.Coral500
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.White
+import com.moya.funch.theme.Yellow500
+import com.moya.funch.ui.FunchErrorCaption
+
+@Composable
+fun FunchDefaultTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ hint: String,
+ isError: Boolean = false,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ isFocus: Boolean = interactionSource.collectIsFocusedAsState().value,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default
+) {
+ BasicTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle =
+ TextStyle(
+ color = White,
+ fontSize = FunchTheme.typography.b.fontSize,
+ lineHeight = FunchTheme.typography.b.lineHeight,
+ fontFamily = FunchTheme.typography.b.fontFamily,
+ fontWeight = FunchTheme.typography.b.fontWeight
+ ),
+ cursorBrush = SolidColor(Color(0xFF0074FF)),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(Gray800, RoundedCornerShape(16.dp))
+ .then(
+ if (isError) {
+ Modifier.border(
+ width = 1.dp,
+ color = Coral500,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else if (isFocus) {
+ Modifier.border(
+ width = 1.dp,
+ color = Color.White,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ if (value.isEmpty()) {
+ Text(
+ text = hint,
+ color = Gray400,
+ fontSize = 14.sp,
+ style = FunchTheme.typography.b
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+}
+
+@Composable
+fun FunchMaxLengthTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ maxLength: Int,
+ hint: String,
+ errorText: String,
+ isError: Boolean = false,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ isFocus: Boolean = interactionSource.collectIsFocusedAsState().value,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default
+) {
+ BasicTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle =
+ TextStyle(
+ color = White,
+ fontSize = FunchTheme.typography.b.fontSize,
+ lineHeight = FunchTheme.typography.b.lineHeight,
+ fontFamily = FunchTheme.typography.b.fontFamily,
+ fontWeight = FunchTheme.typography.b.fontWeight
+ ),
+ cursorBrush = SolidColor(Color(0xFF0074FF)),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ Column(modifier = Modifier.height((56 + 24).dp)) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(Gray800, RoundedCornerShape(16.dp))
+ .then(
+ if (isError) {
+ Modifier.border(
+ width = 1.dp,
+ color = Coral500,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else if (isFocus) {
+ Modifier.border(
+ width = 1.dp,
+ color = Color.White,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = hint,
+ color = Gray400,
+ fontSize = 14.sp,
+ style = FunchTheme.typography.b
+ )
+ }
+ innerTextField()
+ }
+
+ Text(
+ text =
+ annotatedStringMaxLengthType(
+ isError = isError,
+ value = value,
+ maxLength = maxLength
+ ),
+ style =
+ TextStyle(
+ fontSize = 14.sp,
+ lineHeight = FunchTheme.typography.b.lineHeight,
+ fontFamily = FunchTheme.typography.b.fontFamily,
+ fontWeight = FunchTheme.typography.b.fontWeight
+ )
+ )
+ }
+ if (isError) {
+ FunchErrorCaption(
+ modifier = Modifier
+ .padding(
+ start = 8.dp,
+ top = 4.dp
+ ),
+ errorText = errorText
+ )
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun FunchIconTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ onValueChange: (String) -> Unit,
+ hint: String,
+ iconType: FunchIcon,
+ isError: Boolean = false,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ isFocus: Boolean = interactionSource.collectIsFocusedAsState().value,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default
+) {
+ BasicTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle =
+ TextStyle(
+ color = White,
+ fontSize = FunchTheme.typography.b.fontSize,
+ lineHeight = FunchTheme.typography.b.lineHeight,
+ fontFamily = FunchTheme.typography.b.fontFamily,
+ fontWeight = FunchTheme.typography.b.fontWeight
+ ),
+ cursorBrush = SolidColor(Color(0xFF0074FF)),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(Gray800, RoundedCornerShape(16.dp))
+ .then(
+ if (isError) {
+ Modifier.border(
+ width = 1.dp,
+ color = Coral500,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else if (isFocus) {
+ Modifier.border(
+ width = 1.dp,
+ color = Color.White,
+ shape = RoundedCornerShape(16.dp)
+ )
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(id = iconType.resId),
+ contentDescription = iconType.description,
+ tint = iconType.tint
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = hint,
+ color = Gray400,
+ fontSize = 14.sp,
+ style = FunchTheme.typography.b
+ )
+ }
+ innerTextField()
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun FunchButtonTextField(
+ modifier: Modifier = Modifier,
+ backgroundColor: Color,
+ value: String,
+ onValueChange: (String) -> Unit,
+ hint: String,
+ iconButton: @Composable () -> Unit,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default
+) {
+ BasicTextField(
+ modifier = modifier,
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle =
+ TextStyle(
+ color = White,
+ fontSize = FunchTheme.typography.b.fontSize,
+ lineHeight = FunchTheme.typography.b.lineHeight,
+ fontFamily = FunchTheme.typography.b.fontFamily,
+ fontWeight = FunchTheme.typography.b.fontWeight
+ ),
+ cursorBrush = SolidColor(Color(0xFF0074FF)),
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(backgroundColor, RoundedCornerShape(16.dp))
+ .padding(start = 16.dp, end = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ if (value.isEmpty()) {
+ Text(
+ text = hint,
+ color = Gray400,
+ fontSize = 14.sp,
+ style = FunchTheme.typography.b
+ )
+ }
+ innerTextField()
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ iconButton()
+ }
+ }
+ )
+}
+
+private fun annotatedStringMaxLengthType(isError: Boolean, value: String, maxLength: Int) = buildAnnotatedString {
+ if (!isError) {
+ if (value.isEmpty()) {
+ withStyle(style = SpanStyle(color = Gray400)) {
+ append("${value.length}/$maxLength")
+ }
+ } else {
+ withStyle(style = SpanStyle(color = White)) {
+ append("${value.length}")
+ }
+ withStyle(style = SpanStyle(color = Gray400)) {
+ append("/$maxLength")
+ }
+ }
+ } else {
+ withStyle(style = SpanStyle(color = Coral500)) {
+ append("${value.length}/$maxLength")
+ }
+ }
+}
+
+// ============================== Preview =================================
+
+@Preview("Default", showBackground = true, backgroundColor = 0xFF2C2C2C)
+@Composable
+private fun Preview1() {
+ var text by remember { mutableStateOf("") }
+ val isError = remember { mutableStateOf(false) }
+ val maxLength = 9
+
+ LaunchedEffect(text) {
+ if (text.length < maxLength) {
+ isError.value = false
+ }
+ }
+
+ FunchTheme {
+ Column {
+ FunchDefaultTextField(
+ value = text,
+ onValueChange = { innerText -> text = innerText },
+ hint = "최대 ${maxLength}글자",
+ isError = isError.value
+ )
+ if (isError.value) {
+ FunchErrorCaption(
+ modifier = Modifier.padding(top = 4.dp, start = 4.dp),
+ errorText = "errorText"
+ )
+ }
+ Button(
+ onClick = { isError.value = text.length > maxLength }
+ ) {
+ Text(text = "전송")
+ }
+ }
+ }
+}
+
+@Preview("MaxLengthType", showBackground = true, backgroundColor = 0xFF2C2C2C)
+@Composable
+private fun Preview2() {
+ var text by remember { mutableStateOf("ghg홓") }
+ val isError = remember { mutableStateOf(true) }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val maxLength = 9
+
+ LaunchedEffect(isFocused) {
+ if (!isFocused || text.length < maxLength) {
+ isError.value = false
+ }
+ }
+
+ FunchTheme {
+ Column {
+ FunchMaxLengthTextField(
+ value = text,
+ onValueChange = { innerText ->
+ if (innerText.length <= maxLength) {
+ text = innerText
+ isError.value = false
+ } else {
+ isError.value = true
+ }
+ },
+ maxLength = maxLength,
+ hint = "최대 ${maxLength}글자",
+ isError = isError.value,
+ interactionSource = interactionSource,
+ isFocus = isFocused,
+ errorText = "최대 ${maxLength}글자를 초과했어요"
+ )
+ }
+ }
+}
+
+@Preview("Icon", showBackground = true, backgroundColor = 0xFF2C2C2C)
+@Composable
+private fun Preview3() {
+ var text by remember { mutableStateOf("") }
+ val isError = remember { mutableStateOf(true) }
+
+ FunchTheme {
+ Column {
+ FunchIconTextField(
+ value = text,
+ onValueChange = { innerText -> text = innerText },
+ hint = "가까운 지하철역 검색",
+ iconType =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "",
+ tint = Gray500
+ ),
+ isError = isError.value
+ )
+ if (isError.value) {
+ FunchErrorCaption(
+ modifier = Modifier
+ .padding(
+ start = 8.dp,
+ top = 4.dp
+ ),
+ errorText = "존재하지 않는 지하철역이에요"
+ )
+ }
+ }
+ }
+}
+
+@Preview("Button", showBackground = true, backgroundColor = 0xFF2C2C2C)
+@Composable
+private fun Preview4() {
+ var text by remember { mutableStateOf("") }
+ val isError = remember { mutableStateOf(false) }
+
+ FunchTheme {
+ Column {
+ FunchButtonTextField(
+ value = text,
+ onValueChange = { innerText -> text = innerText },
+ hint = "친구 코드를 입력하고 매칭하기",
+ backgroundColor = Gray800,
+ iconButton = {
+ FunchIconButton(
+ modifier = Modifier.size(40.dp),
+ backgroundColor = Gray500,
+ onClick = { /*TODO*/ },
+ roundedCornerShape = RoundedCornerShape(12.dp),
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "",
+ tint = Yellow500
+ )
+ )
+ }
+ )
+ if (isError.value) {
+ FunchErrorCaption(
+ errorText = "존재하지 않는 지하철역이에요"
+ )
+ }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/component/TopBar.kt b/core/designsystem/src/main/java/com/moya/funch/component/TopBar.kt
new file mode 100644
index 00000000..cce3ea5a
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/component/TopBar.kt
@@ -0,0 +1,102 @@
+package com.moya.funch.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray900
+import com.moya.funch.ui.FunchFeedbackButton
+
+@Composable
+fun FunchNonTitleTopBar(
+ modifier: Modifier = Modifier,
+ leadingIcon: (@Composable () -> Unit)? = {},
+ trailingIcon: (@Composable () -> Unit) = {}
+) {
+ val arrangement =
+ if (leadingIcon != null) {
+ Arrangement.SpaceBetween
+ } else {
+ Arrangement.End
+ }
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(Gray900)
+ .padding(top = 6.dp, bottom = 2.dp),
+ horizontalArrangement = arrangement,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (leadingIcon != null) leadingIcon()
+ Spacer(modifier = Modifier)
+ trailingIcon()
+ }
+}
+
+@Composable
+@Preview(showBackground = true, name = "FunchNonTitleTopBar - Leading and Trailing Icon")
+private fun Preview() {
+ FunchTheme {
+ FunchNonTitleTopBar(
+ leadingIcon = {
+ FunchIconButton(
+ modifier = Modifier.size(40.dp),
+ onClick = { },
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "Search",
+ tint = Gray400
+ )
+ )
+ },
+ trailingIcon = {
+ FunchFeedbackButton(onClick = {})
+ }
+ )
+ }
+}
+
+@Composable
+@Preview(showBackground = true, name = "FunchNonTitleTopBar - No Trailing Icon")
+private fun Preview2() {
+ FunchTheme {
+ FunchNonTitleTopBar(
+ leadingIcon = {
+ FunchIconButton(
+ modifier = Modifier.size(40.dp),
+ onClick = { },
+ funchIcon =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "Search",
+ tint = Gray400
+ )
+ )
+ }
+ )
+ }
+}
+
+@Composable
+@Preview(showBackground = true, name = "FunchNonTitleTopBar - Only Trailing Icon")
+private fun Preview3() {
+ FunchTheme {
+ FunchNonTitleTopBar(
+ trailingIcon = {
+ FunchFeedbackButton(onClick = {})
+ }
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/icon/FunchIconAsset.kt b/core/designsystem/src/main/java/com/moya/funch/icon/FunchIconAsset.kt
new file mode 100644
index 00000000..5bfdce8c
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/icon/FunchIconAsset.kt
@@ -0,0 +1,92 @@
+package com.moya.funch.icon
+
+import com.moya.funch.designsystem.R
+
+object FunchIconAsset {
+ object Search {
+ val search_24 = R.drawable.ic_search_24
+ val search_16 = R.drawable.ic_search_16
+ }
+
+ object Arrow {
+ val arrow_back_android_24 = R.drawable.ic_arrow_back_android_24
+ val arrow_right_android_24 = R.drawable.ic_arrow_right_android_24
+ val arrow_left_small_24 = R.drawable.ic_arrow_left_small_24
+ val arrow_right_small_24 = R.drawable.ic_arrow_right_small_24
+ val arrow_up_24 = R.drawable.ic_arrow_up_24
+ val arrow_down_24 = R.drawable.ic_arrow_down_24
+ val arrow_up_limit_24 = R.drawable.ic_arrow_up_limit_24
+ val left_right_24 = R.drawable.ic_left_right_24
+ val diagonal_24 = R.drawable.ic_diagonal_24
+ }
+
+ // @GunHyung Ham TODO : 추후 오브젝트 이름 변경 필요
+ object Etc {
+ val information_24 = R.drawable.ic_information_24
+ val minus_24 = R.drawable.ic_minus_24
+ val close_24 = R.drawable.ic_close_24
+ val profile_80 = R.drawable.ic_profile_80
+ val view_count_80 = R.drawable.ic_view_count_80
+ val code_80 = R.drawable.ic_code_80
+ }
+
+ object Job {
+ val designer_24 = R.drawable.ic_designer_18
+ val developer_24 = R.drawable.ic_developer_18
+ }
+
+ object Club {
+ val sopt_24 = R.drawable.ic_sopt_24
+ val depromeet_24 = R.drawable.ic_depromeet_24
+ val nexters_24 = R.drawable.ic_nexters_24
+ }
+
+ object SubwayLine {
+ val subway_line_one = R.drawable.ic_subway_line_one_16
+ val subway_line_two = R.drawable.ic_subway_line_two_16
+ val subway_line_three = R.drawable.ic_subway_line_three_16
+ val subway_line_four = R.drawable.ic_subway_line_four_16
+ val subway_line_five = R.drawable.ic_subway_line_five_16
+ val subway_line_six = R.drawable.ic_subway_line_six_16
+ val subway_line_seven = R.drawable.ic_subway_line_seven_16
+ val subway_line_eight = R.drawable.ic_subway_line_eight_16
+ val subway_line_nine = R.drawable.ic_subway_line_nine_16
+ val subway_line_gyeongui_jungang = R.drawable.ic_subway_line_gyeongui_jungang_16
+ val subway_line_shinbundang = R.drawable.ic_subway_line_shinbundang_16
+ val subway_line_suinbundang = R.drawable.ic_subway_line_suinbundang_16
+ val subway_line_airport = R.drawable.ic_subway_line_airport_16
+ val subway_line_incheon_one = R.drawable.ic_subway_line_incheon_one_16
+ val subway_line_uijeongbu = R.drawable.ic_subway_line_uijeongbu_16
+ val subway_line_ui_sinseol = R.drawable.ic_subway_line_ui_sinseol_16
+ val subway_line_gimpo_goldline = R.drawable.ic_subway_line_gimpo_goldline_16
+ val subway_line_incheon_two = R.drawable.ic_subway_line_incheon_two_16
+ val subway_line_youngin_ever = R.drawable.ic_subway_line_youngin_ever_16
+ val subway_line_sillim = R.drawable.ic_subway_line_sillim_16
+ val subway_line_gyeongchun = R.drawable.ic_subway_line_gyeongchun_16
+ val subway_line_geonggang = R.drawable.ic_subway_line_geonggang_16
+ val subway_line_seohae = R.drawable.ic_subway_line_seohae_16
+ }
+
+ object Blood {
+ val great_32 = R.drawable.ic_blood_great_32
+ val good_32 = R.drawable.ic_blood_good_32
+ val soso_32 = R.drawable.ic_blood_soso_32
+ val bad_32 = R.drawable.ic_blood_bad_32
+ }
+
+ object MBTI {
+ val one = R.drawable.ic_mbti1_32
+ val two = R.drawable.ic_mbti2_32
+ val three = R.drawable.ic_mbti3_32
+ val four = R.drawable.ic_mbti4_32
+ val five = R.drawable.ic_mbti5_32
+ }
+
+ object MatchPercentage {
+ val twenty = R.drawable.ic_match_percentage20_120
+ val forty = R.drawable.ic_match_percentage40_120
+ val sixty = R.drawable.ic_match_percentage60_120
+ val eighty = R.drawable.ic_match_percentage80_120
+ val hundred = R.drawable.ic_match_percentage100_120
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/modifier/ClickableSingle.kt b/core/designsystem/src/main/java/com/moya/funch/modifier/ClickableSingle.kt
new file mode 100644
index 00000000..23c5fb43
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/modifier/ClickableSingle.kt
@@ -0,0 +1,63 @@
+package com.moya.funch.modifier
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.semantics.Role
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+
+/**
+ * 여러 번 클릭 이벤트를 막아주는 Modifier
+ * */
+@SuppressLint("ModifierFactoryUnreferencedReceiver")
+fun Modifier.clickableSingle(
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ role: Role? = null,
+ interactionSource: MutableInteractionSource? = null,
+ onClick: () -> Unit
+): Modifier = composed(
+ inspectorInfo =
+ debugInspectorInfo {
+ name = "clickable"
+ properties["enabled"] = enabled
+ properties["onClickLabel"] = onClickLabel
+ properties["role"] = role
+ properties["onClick"] = onClick
+ }
+) {
+ val manager: SingleEventHandler = remember { DefaultSingleEventHandler() }
+ Modifier.clickable(
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ onClick = { manager.handle { onClick() } },
+ role = role,
+ indication = LocalIndication.current,
+ interactionSource = interactionSource ?: remember { MutableInteractionSource() }
+ )
+}
+
+fun interface SingleEventHandler {
+ fun handle(event: () -> Unit)
+}
+
+internal class DefaultSingleEventHandler : SingleEventHandler {
+ private val currentTime: TimeMark get() = TimeSource.Monotonic.markNow()
+ private val throttleDuration: Duration = 900.milliseconds
+ private lateinit var lastEventTime: TimeMark
+
+ override fun handle(event: () -> Unit) {
+ if (::lastEventTime.isInitialized.not() || (lastEventTime + throttleDuration).hasPassedNow()) {
+ event()
+ }
+ lastEventTime = currentTime
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/modifier/NeonSign.kt b/core/designsystem/src/main/java/com/moya/funch/modifier/NeonSign.kt
new file mode 100644
index 00000000..fa2bab51
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/modifier/NeonSign.kt
@@ -0,0 +1,123 @@
+package com.moya.funch.modifier
+
+import android.graphics.BlurMaskFilter
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray900
+import com.moya.funch.theme.Lemon500
+import com.moya.funch.theme.Yellow500
+
+/**
+ * Composable에 그림자 효과를 추가하는 Modifier입니다.
+ *
+ * @param color 그림자의 색상입니다.
+ * @param borderRadius 그림자가 적용될 border의 radius
+ * @param blurRadius 그림자의 흐림 반경
+ * @param offsetY 그림자의 수직 위치 offset
+ * @param offsetX 그림자의 수평 위치 offset
+ * @param spread 그림자의 퍼지는 정도
+ * @param modifier
+ *
+ * @return Composable에 그림자 효과를 적용하는 Modifier
+ */
+fun Modifier.neonSign(
+ color: Color = Lemon500,
+ borderRadius: Dp = 0.dp,
+ blurRadius: Dp = 8.dp,
+ offsetY: Dp = 0.dp,
+ offsetX: Dp = 0.dp,
+ spread: PaddingValues = PaddingValues(top = (-2).dp, start = 0.dp, end = 0.dp, bottom = 2.dp),
+ modifier: Modifier = Modifier
+): Modifier = this.then(
+ modifier.drawBehind {
+ this.drawIntoCanvas { canvas ->
+ val spreadLeft = spread.calculateLeftPadding(LayoutDirection.Ltr).toPx()
+ val spreadRight = spread.calculateRightPadding(LayoutDirection.Ltr).toPx()
+ val spreadTop = spread.calculateTopPadding().toPx()
+ val spreadBottom = spread.calculateBottomPadding().toPx()
+
+ val left = (0f + offsetX.toPx()) - spreadLeft
+ val top = (0f + offsetY.toPx()) - spreadTop
+ val right = (size.width + offsetX.toPx()) + spreadRight
+ val bottom = (size.height + offsetY.toPx()) + spreadBottom
+ val paint =
+ Paint().apply {
+ val nativePaint = asFrameworkPaint()
+ if (blurRadius != 0.dp) {
+ nativePaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL))
+ }
+ nativePaint.color = color.toArgb()
+ }
+
+ canvas.drawRoundRect(
+ left = left,
+ top = top,
+ right = right,
+ bottom = bottom,
+ radiusX = borderRadius.toPx(),
+ radiusY = borderRadius.toPx(),
+ paint
+ )
+ }
+ }
+)
+
+@Preview(showBackground = true)
+@Composable
+private fun ShadowPreview() {
+ FunchTheme {
+ val brush = Brush.horizontalGradient(listOf(Lemon500, Yellow500))
+ Column(
+ Modifier
+ .size(width = 360.dp, height = 114.dp)
+ .background(Color.Black)
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .padding(horizontal = 4.dp)
+ .neonSign(
+ color = Lemon500,
+ blurRadius = 20.dp,
+ borderRadius = 16.dp
+ )
+ .size(320.dp, 64.dp)
+ .clip(RoundedCornerShape(size = 16.dp))
+ .background(
+ brush
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Button",
+ style = FunchTheme.typography.sbt1,
+ color = Gray900,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/modifier/VerticalScrollbar.kt b/core/designsystem/src/main/java/com/moya/funch/modifier/VerticalScrollbar.kt
new file mode 100644
index 00000000..982b0442
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/modifier/VerticalScrollbar.kt
@@ -0,0 +1,123 @@
+package com.moya.funch.modifier
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.math.max
+
+/**
+ * @see 스크롤바 레퍼런스
+ */
+@Immutable
+data class ScrollBarConfig(
+ val indicatorHeight: Dp = 39.dp,
+ val indicatorThickness: Dp = 8.dp,
+ val indicatorColor: Color = Color.LightGray,
+ val alpha: Float? = null,
+ val alphaAnimationSpec: AnimationSpec? = null,
+ val padding: PaddingValues = PaddingValues(all = 0.dp)
+)
+
+fun Modifier.scrollbar(
+ state: ScrollState,
+ indicatorHeight: Dp = 39.dp,
+ indicatorThickness: Dp = 8.dp,
+ indicatorColor: Color = Color.LightGray,
+ alpha: Float = if (state.isScrollInProgress) 0.8f else 0f,
+ alphaAnimationSpec: AnimationSpec = tween(
+ delayMillis = if (state.isScrollInProgress) 0 else 1500,
+ durationMillis = if (state.isScrollInProgress) 150 else 500
+ ),
+ padding: PaddingValues = PaddingValues(all = 0.dp)
+): Modifier = composed {
+ val scrollbarAlpha by animateFloatAsState(
+ targetValue = alpha,
+ animationSpec = alphaAnimationSpec,
+ label = ""
+ )
+
+ drawWithContent {
+ drawContent()
+
+ val showScrollBar = state.isScrollInProgress || scrollbarAlpha > 0.0f
+
+ if (showScrollBar) {
+ val (topPadding, bottomPadding, startPadding, endPadding) = listOf(
+ padding.calculateTopPadding().toPx(),
+ padding.calculateBottomPadding().toPx(),
+ padding.calculateStartPadding(layoutDirection).toPx(),
+ padding.calculateEndPadding(layoutDirection).toPx()
+ )
+
+ val viewPortLength = size.height
+ val viewPortCrossAxisLength = size.width
+ val contentLength = max(viewPortLength + state.maxValue, 0.001f)
+ val indicatorThicknessPx = indicatorThickness.toPx()
+ val scrollbarSizeWithoutInsets = Size(indicatorThicknessPx, indicatorHeight.toPx())
+ val maxScrollOffset = viewPortLength - scrollbarSizeWithoutInsets.height - topPadding - bottomPadding
+ val scrollOffsetViewPort =
+ if (contentLength > viewPortLength) {
+ topPadding + (state.value / (contentLength - viewPortLength)) * maxScrollOffset
+ } else {
+ topPadding
+ }
+
+ drawRoundRect(
+ color = indicatorColor,
+ cornerRadius = CornerRadius(
+ x = indicatorThicknessPx / 2,
+ y = indicatorThicknessPx / 2
+ ),
+ topLeft = Offset(
+ x = if (layoutDirection == LayoutDirection.Ltr) {
+ viewPortCrossAxisLength - indicatorThicknessPx - endPadding
+ } else {
+ startPadding
+ },
+ y = scrollOffsetViewPort
+ ),
+ size = scrollbarSizeWithoutInsets,
+ alpha = scrollbarAlpha
+ )
+ }
+ }
+}
+
+fun Modifier.verticalScrollWithScrollbar(
+ state: ScrollState,
+ enabled: Boolean = true,
+ flingBehavior: FlingBehavior? = null,
+ reverseScrolling: Boolean = false,
+ scrollbarConfig: ScrollBarConfig = ScrollBarConfig()
+) = this
+ .scrollbar(
+ state = state,
+ indicatorHeight = scrollbarConfig.indicatorHeight,
+ indicatorThickness = scrollbarConfig.indicatorThickness,
+ indicatorColor = scrollbarConfig.indicatorColor,
+ alpha = scrollbarConfig.alpha ?: if (state.isScrollInProgress) 0.8f else 0f,
+ alphaAnimationSpec = scrollbarConfig.alphaAnimationSpec ?: tween(
+ delayMillis = if (state.isScrollInProgress) 0 else 1500,
+ durationMillis = if (state.isScrollInProgress) 150 else 500
+ ),
+ padding = scrollbarConfig.padding
+ )
+ .verticalScroll(state, enabled, flingBehavior, reverseScrolling)
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Background.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Background.kt
new file mode 100644
index 00000000..9019aa2a
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Background.kt
@@ -0,0 +1,14 @@
+package com.moya.funch.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+
+@Immutable
+data class BackgroundTheme(
+ val color: Color = Color.Unspecified,
+ val tonalElevation: Dp = Dp.Unspecified
+)
+
+val LocalBackgroundTheme = staticCompositionLocalOf { BackgroundTheme() }
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Color.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Color.kt
new file mode 100644
index 00000000..27c57821
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Color.kt
@@ -0,0 +1,56 @@
+package com.moya.funch.theme
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+
+internal val Coral500 = Color(0xFFF86E6F)
+internal val Lemon500 = Color(0xFFFFE83B)
+internal val Lemon600 = Color(0xFFE1CA13)
+internal val Lemon900 = Color(0xFF90720A)
+internal val Yellow500 = Color(0xFFFFD240)
+internal val Yellow600 = Color(0xFFE1B012)
+internal val White = Color(0xFFFFFFFF)
+internal val Gray900 = Color(0xFF151515)
+internal val Gray800 = Color(0xFF242627)
+internal val Gray700 = Color(0xFF2C2C2C)
+internal val Gray600 = Color(0xFF363636)
+internal val Gray500 = Color(0xFF404040)
+internal val Gray400 = Color(0xFF6D6D6D)
+internal val Gray300 = Color(0xFF9B9B9B)
+
+@Stable
+class FunchColorSchema(
+ background: Color,
+ error: Color,
+ white: Color
+) {
+ var background by mutableStateOf(background)
+ private set
+ var error by mutableStateOf(error)
+ private set
+ var white by mutableStateOf(white)
+ private set
+
+ fun copy(): FunchColorSchema = FunchColorSchema(
+ background = background,
+ error = error,
+ white = white
+ )
+
+ fun update(other: FunchColorSchema) {
+ background = other.background
+ error = other.error
+ white = other.white
+ }
+}
+
+fun funchDarkColorSchema(background: Color = Gray900, white: Color = White, error: Color = Coral500): FunchColorSchema {
+ return FunchColorSchema(
+ white = white,
+ background = background,
+ error = error
+ )
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Gradient.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Gradient.kt
new file mode 100644
index 00000000..1d66012f
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Gradient.kt
@@ -0,0 +1,14 @@
+package com.moya.funch.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+@Immutable
+data class GradientColors(
+ val top: Color = Color.Unspecified,
+ val bottom: Color = Color.Unspecified,
+ val container: Color = Color.Unspecified
+)
+
+val LocalGradientColors = staticCompositionLocalOf { GradientColors() }
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Shape.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Shape.kt
new file mode 100644
index 00000000..48fc253a
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Shape.kt
@@ -0,0 +1,48 @@
+package com.moya.funch.theme
+
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.dp
+
+object FunchRadiusDefaults {
+ val Small = 12.dp
+ val Medium = 16.dp
+ val Large = 20.dp
+}
+
+object FunchShapeDefaults {
+ val ExtraSmall = RoundedCornerShape(10.dp)
+ val Small = RoundedCornerShape(12.dp)
+ val Medium = RoundedCornerShape(16.dp)
+ val Large = RoundedCornerShape(20.dp)
+ val ExtraLarge = RoundedCornerShape(50.dp)
+}
+
+@Immutable
+class FunchShapes(
+ val extraSmall: CornerBasedShape = FunchShapeDefaults.ExtraSmall,
+ val small: CornerBasedShape = FunchShapeDefaults.Small,
+ val medium: CornerBasedShape = FunchShapeDefaults.Medium,
+ val large: CornerBasedShape = FunchShapeDefaults.Large,
+ val extraLarge: CornerBasedShape = FunchShapeDefaults.ExtraLarge
+) {
+ fun copy(
+ extraSmall: CornerBasedShape = this.extraSmall,
+ small: CornerBasedShape = this.small,
+ medium: CornerBasedShape = this.medium,
+ large: CornerBasedShape = this.large,
+ extraLarge: CornerBasedShape = this.extraLarge
+ ): FunchShapes {
+ return FunchShapes(
+ extraSmall = extraSmall,
+ small = small,
+ medium = medium,
+ large = large,
+ extraLarge = extraLarge
+ )
+ }
+}
+
+val LocalFunchShapes = staticCompositionLocalOf { FunchShapes() }
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Theme.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Theme.kt
new file mode 100644
index 00000000..7a5c3dc6
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Theme.kt
@@ -0,0 +1,114 @@
+package com.moya.funch.theme
+
+import android.app.Activity
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.tooling.preview.Preview
+
+private val LocalFunchColors =
+ staticCompositionLocalOf {
+ error("No FunchColors provided")
+ }
+private val LocalFunchTypography =
+ staticCompositionLocalOf {
+ error("No FunchTypography provided")
+ }
+
+private val DarkGradientColors = GradientColors(top = Gray900, bottom = Gray900)
+
+private val DarkAndroidBackgroundTheme = BackgroundTheme(color = Gray900)
+
+@Composable
+private fun statusBar() {
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = Gray900.toArgb()
+ window.navigationBarColor = Gray900.toArgb()
+ }
+ }
+}
+
+object FunchTheme {
+ val colors: FunchColorSchema @Composable get() = LocalFunchColors.current
+ val typography: FunchTypography @Composable get() = LocalFunchTypography.current
+ val shapes: FunchShapes @Composable get() = LocalFunchShapes.current
+}
+
+@Composable
+fun ProvideFunchProperty(colors: FunchColorSchema, typography: FunchTypography, content: @Composable () -> Unit) {
+ val provideColors = remember { colors.copy() }
+ provideColors.update(colors)
+ val provideTypography = remember { typography.copy() }
+ provideTypography.update(typography)
+
+ statusBar()
+
+ val provideShape = remember { FunchShapes() }
+ CompositionLocalProvider(
+ LocalFunchColors provides provideColors,
+ LocalFunchTypography provides provideTypography,
+ LocalFunchShapes provides provideShape,
+ content = content
+ )
+}
+
+@Composable
+fun FunchTheme(content: @Composable () -> Unit) {
+ // this version provides only dark theme
+ val colors = funchDarkColorSchema()
+ val gradientColors = DarkGradientColors
+ val typography = funchTypography
+ val backgroundTheme = DarkAndroidBackgroundTheme
+
+ CompositionLocalProvider(
+ LocalGradientColors provides gradientColors,
+ LocalBackgroundTheme provides backgroundTheme
+ ) {
+ ProvideFunchProperty(colors, typography) {
+ MaterialTheme(content = content)
+ }
+ }
+}
+
+// TODO : 추후 삭제
+@Preview("FunchTheme 예시")
+@Composable
+private fun NiaThemePreview() {
+ FunchTheme {
+ val color = LocalBackgroundTheme.current.color
+ Surface(
+ color = color
+ ) {
+ Column {
+ Text(
+ text = "Hello, Funch!",
+ color = FunchTheme.colors.white,
+ style = FunchTheme.typography.t1
+ )
+ Text(
+ text = "Hello, Funch!",
+ color = FunchTheme.colors.white,
+ style = FunchTheme.typography.sbt1
+ )
+ Button(
+ onClick = { /*TODO*/ },
+ shape = FunchTheme.shapes.small
+ ) {
+ Text(text = "Button")
+ }
+ }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/theme/Type.kt b/core/designsystem/src/main/java/com/moya/funch/theme/Type.kt
new file mode 100644
index 00000000..dd45963a
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/theme/Type.kt
@@ -0,0 +1,167 @@
+package com.moya.funch.theme
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import com.moya.funch.designsystem.R
+
+private val spoqaHanSansNeo =
+ FontFamily(
+ Font(
+ R.font.spoqa_han_sans_neo_bold,
+ FontWeight.Bold,
+ FontStyle.Normal
+ ),
+ Font(
+ R.font.spoqa_han_sans_neo_medium,
+ FontWeight.Medium,
+ FontStyle.Normal
+ ),
+ Font(
+ R.font.spoqa_han_sans_neo_regular,
+ FontWeight.Normal,
+ FontStyle.Normal
+ )
+ )
+
+@Stable
+class FunchTypography internal constructor(
+ t1: TextStyle,
+ t2: TextStyle,
+ sbt1: TextStyle,
+ sbt2: TextStyle,
+ b: TextStyle,
+ caption: TextStyle
+) {
+ var t1: TextStyle by mutableStateOf(t1)
+ private set
+ var t2: TextStyle by mutableStateOf(t2)
+ private set
+ var sbt1: TextStyle by mutableStateOf(sbt1)
+ private set
+ var sbt2: TextStyle by mutableStateOf(sbt2)
+ private set
+ var b: TextStyle by mutableStateOf(b)
+ private set
+ var caption: TextStyle by mutableStateOf(caption)
+ private set
+
+ fun copy(
+ t1: TextStyle = this.t1,
+ t2: TextStyle = this.t2,
+ sbt1: TextStyle = this.sbt1,
+ sbt2: TextStyle = this.sbt2,
+ b: TextStyle = this.b,
+ caption: TextStyle = this.caption
+ ): FunchTypography = FunchTypography(
+ t1 = t1,
+ t2 = t2,
+ sbt1 = sbt1,
+ sbt2 = sbt2,
+ b = b,
+ caption = caption
+ )
+
+ fun update(other: FunchTypography) {
+ t1 = other.t1
+ t2 = other.t2
+ sbt1 = other.sbt1
+ sbt2 = other.sbt2
+ b = other.b
+ caption = other.caption
+ }
+}
+
+internal fun funchTypography(): FunchTypography {
+ return FunchTypography(
+ t1 =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp,
+ letterSpacing = (-0.02).em,
+ lineHeight = 28.6.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ ),
+ t2 =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ letterSpacing = (-0.02).em,
+ lineHeight = 26.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ ),
+ sbt1 =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ letterSpacing = (-0.02).em,
+ lineHeight = 23.4.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ ),
+ sbt2 =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ letterSpacing = (-0.02).sp,
+ lineHeight = 20.8.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ ),
+ b =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ letterSpacing = (-0.03).em,
+ lineHeight = 21.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ ),
+ caption =
+ TextStyle(
+ fontFamily = spoqaHanSansNeo,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ letterSpacing = (-0.03).em,
+ lineHeight = 18.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ )
+ )
+}
+
+internal val funchTypography = funchTypography()
diff --git a/core/designsystem/src/main/java/com/moya/funch/ui/FunchCaption.kt b/core/designsystem/src/main/java/com/moya/funch/ui/FunchCaption.kt
new file mode 100644
index 00000000..fb631811
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/ui/FunchCaption.kt
@@ -0,0 +1,48 @@
+package com.moya.funch.ui
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.theme.Coral500
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+fun FunchErrorCaption(modifier: Modifier = Modifier, errorText: String, description: String = "") {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painter = painterResource(id = FunchIconAsset.Etc.information_24),
+ contentDescription = description,
+ tint = Coral500
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = errorText,
+ color = Coral500,
+ style = FunchTheme.typography.caption
+ )
+ }
+}
+
+// ============================== Preview =================================
+
+@Preview("Error Caption", showBackground = true, backgroundColor = 0xFF2C2C2C)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ FunchErrorCaption(
+ errorText = "errorText"
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/ui/FunchDropDown.kt b/core/designsystem/src/main/java/com/moya/funch/ui/FunchDropDown.kt
new file mode 100644
index 00000000..abf06335
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/ui/FunchDropDown.kt
@@ -0,0 +1,266 @@
+package com.moya.funch.ui
+
+import androidx.compose.foundation.Indication
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.modifier.ScrollBarConfig
+import com.moya.funch.modifier.verticalScrollWithScrollbar
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray300
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.LocalBackgroundTheme
+import com.moya.funch.theme.White
+import kotlin.math.roundToInt
+
+@Composable
+fun FunchDropDownButton(
+ modifier: Modifier = Modifier,
+ placeHolder: String,
+ onClick: () -> Unit,
+ isDropDownMenuExpanded: Boolean,
+ indication: Indication? = LocalIndication.current,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.medium
+ )
+ .then(
+ if (isDropDownMenuExpanded) {
+ Modifier.border(
+ width = 1.dp,
+ color = White,
+ shape = FunchTheme.shapes.medium
+ )
+ } else {
+ Modifier
+ }
+ )
+ .clickable(
+ onClick = onClick,
+ indication = indication,
+ interactionSource = interactionSource
+ )
+ .padding(
+ top = 8.dp,
+ bottom = 8.dp,
+ start = 16.dp,
+ end = 8.dp
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = placeHolder,
+ style = FunchTheme.typography.b,
+ color = White
+ )
+ Icon(
+ modifier = Modifier.padding(8.dp),
+ painter = painterResource(
+ id = if (isDropDownMenuExpanded) {
+ FunchIconAsset.Arrow.arrow_up_24
+ } else {
+ FunchIconAsset.Arrow.arrow_down_24
+ }
+ ),
+ contentDescription = "",
+ tint = White
+ )
+ }
+}
+
+@Composable
+fun FunchDropDownMenu(
+ modifier: Modifier = Modifier,
+ items: List,
+ buttonBounds: Rect,
+ onItemSelected: (String) -> Unit,
+ scrollState: ScrollState = rememberScrollState()
+) {
+ Popup(
+ alignment = Alignment.TopStart,
+ offset = IntOffset(
+ x = 0,
+ y = with(LocalDensity.current) {
+ (buttonBounds.height).toInt() + 8.dp.toPx().roundToInt()
+ }
+ )
+ ) {
+ Column(
+ modifier = modifier
+ .width(with(LocalDensity.current) { buttonBounds.width.toDp() })
+ .height(144.dp)
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.medium
+ )
+ .clip(FunchTheme.shapes.medium)
+ .verticalScrollWithScrollbar(
+ state = scrollState,
+ scrollbarConfig = ScrollBarConfig(
+ indicatorHeight = 39.dp,
+ indicatorThickness = 4.dp,
+ indicatorColor = Gray300,
+ padding = PaddingValues(
+ top = 16.dp,
+ bottom = 16.dp,
+ end = 4.dp
+ )
+ )
+ )
+ ) {
+ items.forEachIndexed { index, option ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ FunchDropDownItem(
+ option = option,
+ onItemSelected = { onItemSelected(option) },
+ isPressed = isPressed,
+ interactionSource = interactionSource
+ )
+ if (index < items.lastIndex) {
+ Divider(
+ color = Gray500,
+ thickness = 0.5f.dp
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FunchDropDownItem(
+ option: String,
+ onItemSelected: (String) -> Unit,
+ isPressed: Boolean,
+ interactionSource: MutableInteractionSource
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = if (isPressed) Gray500 else Gray800)
+ .clickable(
+ onClick = { onItemSelected(option) },
+ interactionSource = interactionSource,
+ indication = null
+ )
+ .padding(
+ start = 16.dp,
+ end = 8.dp,
+ top = 13.5f.dp,
+ bottom = 13.5f.dp
+ )
+ ) {
+ Text(
+ text = option,
+ color = White,
+ style = FunchTheme.typography.b
+ )
+ }
+}
+
+@Preview(
+ showBackground = true,
+ widthDp = 360,
+ heightDp = 640
+)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ ) {
+ val bloodTypes = listOf("A형", "B형", "O형", "AB형")
+ var placeHolder by remember { mutableStateOf(bloodTypes[0]) }
+ var isDropDownMenuExpanded by remember { mutableStateOf(true) }
+ val buttonBounds = remember { mutableStateOf(Rect.Zero) }
+
+ Text(
+ text = "Hello, World!",
+ fontSize = 50.sp,
+ color = White
+ )
+ Box {
+ FunchDropDownButton(
+ placeHolder = placeHolder,
+ onClick = { isDropDownMenuExpanded = !isDropDownMenuExpanded },
+ isDropDownMenuExpanded = isDropDownMenuExpanded,
+ indication = null,
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ buttonBounds.value = coordinates.boundsInWindow()
+ }
+ )
+ if (isDropDownMenuExpanded) {
+ FunchDropDownMenu(
+ items = bloodTypes,
+ buttonBounds = buttonBounds.value,
+ onItemSelected = { text ->
+ placeHolder = text
+ isDropDownMenuExpanded = false
+ }
+ )
+ }
+ }
+ Text(
+ text = "Hello, World!",
+ fontSize = 50.sp,
+ color = White
+ )
+ }
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/ui/FunchFeedbackButton.kt b/core/designsystem/src/main/java/com/moya/funch/ui/FunchFeedbackButton.kt
new file mode 100644
index 00000000..4937f236
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/ui/FunchFeedbackButton.kt
@@ -0,0 +1,72 @@
+package com.moya.funch.ui
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.component.FunchButtonType
+import com.moya.funch.component.FunchSubButton
+import com.moya.funch.designsystem.R
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray900
+
+@SuppressWarnings("has Android-specific code - This Component will be deprecated soon")
+@Composable
+fun FunchFeedbackButton(enabled: Boolean = true, onClick: () -> Unit) {
+ val url = "https://forms.gle/fGw4Jv8pQTpug77x6"
+ val activity = LocalContext.current.findActivity()
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+
+ FunchSubButton(
+ modifier = Modifier.wrapContentSize(),
+ enabled = enabled,
+ buttonType = FunchButtonType.XSmall,
+ onClick = { activity.startActivity(intent) },
+ text = stringResource(id = R.string.send_feed_back),
+ contentHorizontalPadding = 12.dp
+ )
+}
+
+@Preview(name = "feedbackButton - enabled, disabled", showBackground = true, widthDp = 140)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ Column(
+ Modifier
+ .background(Gray900)
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ FunchFeedbackButton(
+ onClick = { }
+ )
+ Spacer(modifier = Modifier.padding(8.dp))
+ FunchFeedbackButton(
+ enabled = false,
+ onClick = { /*TODO*/ }
+ )
+ }
+ }
+}
+
+private fun Context.findActivity(): Activity {
+ var context = this
+ while (context is ContextWrapper) {
+ if (context is Activity) return context
+ context = context.baseContext
+ }
+ throw IllegalStateException("Permissions should be called in the context of an Activity")
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/ui/FunchTopBar.kt b/core/designsystem/src/main/java/com/moya/funch/ui/FunchTopBar.kt
new file mode 100644
index 00000000..3db4d10f
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/ui/FunchTopBar.kt
@@ -0,0 +1,82 @@
+package com.moya.funch.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.component.FunchIconButton
+import com.moya.funch.component.FunchNonTitleTopBar
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+
+@Composable
+fun FunchTopBar(
+ modifier: Modifier = Modifier,
+ enabledLeadingIcon: Boolean = true,
+ enabledTrailingIcon: Boolean = true,
+ onClickLeadingIcon: () -> Unit = {},
+ onClickTrailingIcon: () -> Unit = {},
+ leadingIcon: FunchIcon? =
+ FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "Search",
+ tint = Gray400
+ ),
+ trailingIcon: (@Composable () -> Unit)? = { FunchFeedbackButton(enabledTrailingIcon, onClickTrailingIcon) }
+) {
+ FunchNonTitleTopBar(
+ modifier,
+ leadingIcon = {
+ if (leadingIcon != null) {
+ FunchIconButton(
+ enabled = enabledLeadingIcon,
+ modifier = Modifier.size(40.dp),
+ onClick = onClickLeadingIcon,
+ funchIcon = leadingIcon
+ )
+ }
+ },
+ trailingIcon = {
+ if (trailingIcon != null) {
+ trailingIcon()
+ }
+ }
+ )
+}
+
+@Composable
+@Preview(showBackground = true, name = "FunchTopBar - Leading and Trailing visible")
+private fun Preview() {
+ FunchTheme {
+ Column(
+ modifier = Modifier.background(Gray400),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ FunchTopBar()
+ FunchTopBar(leadingIcon = null)
+ FunchTopBar(trailingIcon = null)
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "FunchTopBar - enabled, disabled")
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ Column(
+ modifier = Modifier.background(Gray400),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ FunchTopBar(Modifier.padding(start = 12.dp, end = 20.dp), enabledLeadingIcon = false)
+ FunchTopBar(enabledTrailingIcon = false)
+ FunchTopBar(enabledLeadingIcon = false, enabledTrailingIcon = false)
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/moya/funch/ui/SingleEventArea.kt b/core/designsystem/src/main/java/com/moya/funch/ui/SingleEventArea.kt
new file mode 100644
index 00000000..201c01a0
--- /dev/null
+++ b/core/designsystem/src/main/java/com/moya/funch/ui/SingleEventArea.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.moya.funch.modifier.DefaultSingleEventHandler
+import com.moya.funch.modifier.SingleEventHandler
+
+/**
+ * 여러 번 클릭 이벤트를 막아주는 Wrapper Composable
+ * */
+@Composable
+fun SingleEventArea(content: @Composable (SingleEventHandler) -> T) {
+ val singleEventHandler = remember { DefaultSingleEventHandler() }
+
+ content(singleEventHandler)
+}
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_back_android_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_back_android_24.xml
new file mode 100644
index 00000000..5d271812
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_back_android_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_down_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_down_24.xml
new file mode 100644
index 00000000..c68675b3
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_down_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_left_small_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_left_small_24.xml
new file mode 100644
index 00000000..bee06ddc
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_left_small_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_right_android_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_right_android_24.xml
new file mode 100644
index 00000000..29d6c6e2
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_right_android_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_right_small_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_right_small_24.xml
new file mode 100644
index 00000000..b3c3142f
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_right_small_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_up_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_up_24.xml
new file mode 100644
index 00000000..5861854c
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_up_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_arrow_up_limit_24.xml b/core/designsystem/src/main/res/drawable/ic_arrow_up_limit_24.xml
new file mode 100644
index 00000000..c0cf16a7
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_arrow_up_limit_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_blood_bad_32.xml b/core/designsystem/src/main/res/drawable/ic_blood_bad_32.xml
new file mode 100644
index 00000000..fe9d9305
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_blood_bad_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_blood_good_32.xml b/core/designsystem/src/main/res/drawable/ic_blood_good_32.xml
new file mode 100644
index 00000000..a7ea4588
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_blood_good_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_blood_great_32.xml b/core/designsystem/src/main/res/drawable/ic_blood_great_32.xml
new file mode 100644
index 00000000..ec8ee6c5
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_blood_great_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_blood_soso_32.xml b/core/designsystem/src/main/res/drawable/ic_blood_soso_32.xml
new file mode 100644
index 00000000..611c2018
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_blood_soso_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_close_24.xml b/core/designsystem/src/main/res/drawable/ic_close_24.xml
new file mode 100644
index 00000000..1a3663e4
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_close_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_code_80.xml b/core/designsystem/src/main/res/drawable/ic_code_80.xml
new file mode 100644
index 00000000..d9b6bb2e
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_code_80.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_depromeet_24.png b/core/designsystem/src/main/res/drawable/ic_depromeet_24.png
new file mode 100644
index 00000000..82d958c2
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/ic_depromeet_24.png differ
diff --git a/core/designsystem/src/main/res/drawable/ic_designer_18.xml b/core/designsystem/src/main/res/drawable/ic_designer_18.xml
new file mode 100644
index 00000000..abefc5ab
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_designer_18.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_developer_18.xml b/core/designsystem/src/main/res/drawable/ic_developer_18.xml
new file mode 100644
index 00000000..c086844d
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_developer_18.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_diagonal_24.xml b/core/designsystem/src/main/res/drawable/ic_diagonal_24.xml
new file mode 100644
index 00000000..b039e9c1
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_diagonal_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_information_24.xml b/core/designsystem/src/main/res/drawable/ic_information_24.xml
new file mode 100644
index 00000000..9aa74458
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_information_24.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_left_right_24.xml b/core/designsystem/src/main/res/drawable/ic_left_right_24.xml
new file mode 100644
index 00000000..2bca2e3b
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_left_right_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_match_percentage100_120.xml b/core/designsystem/src/main/res/drawable/ic_match_percentage100_120.xml
new file mode 100644
index 00000000..e1527d01
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_match_percentage100_120.xml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_match_percentage20_120.xml b/core/designsystem/src/main/res/drawable/ic_match_percentage20_120.xml
new file mode 100644
index 00000000..ccfb2b55
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_match_percentage20_120.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_match_percentage40_120.xml b/core/designsystem/src/main/res/drawable/ic_match_percentage40_120.xml
new file mode 100644
index 00000000..e4a1df5d
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_match_percentage40_120.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_match_percentage60_120.xml b/core/designsystem/src/main/res/drawable/ic_match_percentage60_120.xml
new file mode 100644
index 00000000..ba14cf72
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_match_percentage60_120.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_match_percentage80_120.xml b/core/designsystem/src/main/res/drawable/ic_match_percentage80_120.xml
new file mode 100644
index 00000000..7308735b
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_match_percentage80_120.xml
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_mbti1_32.xml b/core/designsystem/src/main/res/drawable/ic_mbti1_32.xml
new file mode 100644
index 00000000..2300ed77
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_mbti1_32.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_mbti2_32.xml b/core/designsystem/src/main/res/drawable/ic_mbti2_32.xml
new file mode 100644
index 00000000..89749ef2
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_mbti2_32.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_mbti3_32.xml b/core/designsystem/src/main/res/drawable/ic_mbti3_32.xml
new file mode 100644
index 00000000..8e688fd4
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_mbti3_32.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_mbti4_32.xml b/core/designsystem/src/main/res/drawable/ic_mbti4_32.xml
new file mode 100644
index 00000000..221cf7cf
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_mbti4_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_mbti5_32.xml b/core/designsystem/src/main/res/drawable/ic_mbti5_32.xml
new file mode 100644
index 00000000..efe839b0
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_mbti5_32.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_minus_24.xml b/core/designsystem/src/main/res/drawable/ic_minus_24.xml
new file mode 100644
index 00000000..5dd90021
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_minus_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_nexters_24.png b/core/designsystem/src/main/res/drawable/ic_nexters_24.png
new file mode 100644
index 00000000..0264002a
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/ic_nexters_24.png differ
diff --git a/core/designsystem/src/main/res/drawable/ic_profile_80.xml b/core/designsystem/src/main/res/drawable/ic_profile_80.xml
new file mode 100644
index 00000000..b1cda53f
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_profile_80.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_search_16.xml b/core/designsystem/src/main/res/drawable/ic_search_16.xml
new file mode 100644
index 00000000..e1ecabc1
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_search_16.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_search_24.xml b/core/designsystem/src/main/res/drawable/ic_search_24.xml
new file mode 100644
index 00000000..35d8bfde
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_search_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_sopt_24.png b/core/designsystem/src/main/res/drawable/ic_sopt_24.png
new file mode 100644
index 00000000..8a238f18
Binary files /dev/null and b/core/designsystem/src/main/res/drawable/ic_sopt_24.png differ
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_airport_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_airport_16.xml
new file mode 100644
index 00000000..688706e2
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_airport_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_eight_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_eight_16.xml
new file mode 100644
index 00000000..529bd2f4
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_eight_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_five_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_five_16.xml
new file mode 100644
index 00000000..b684ed53
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_five_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_four_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_four_16.xml
new file mode 100644
index 00000000..267fee39
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_four_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_geonggang_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_geonggang_16.xml
new file mode 100644
index 00000000..7ddaa182
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_geonggang_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_gimpo_goldline_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_gimpo_goldline_16.xml
new file mode 100644
index 00000000..bfc0aacb
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_gimpo_goldline_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongchun_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongchun_16.xml
new file mode 100644
index 00000000..39add149
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongchun_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongui_jungang_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongui_jungang_16.xml
new file mode 100644
index 00000000..fb2b0d62
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_gyeongui_jungang_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_one_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_one_16.xml
new file mode 100644
index 00000000..8d69a051
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_one_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_two_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_two_16.xml
new file mode 100644
index 00000000..49492193
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_incheon_two_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_nine_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_nine_16.xml
new file mode 100644
index 00000000..024e6b83
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_nine_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_one_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_one_16.xml
new file mode 100644
index 00000000..0df0ec88
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_one_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_seohae_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_seohae_16.xml
new file mode 100644
index 00000000..eb61651b
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_seohae_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_seven_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_seven_16.xml
new file mode 100644
index 00000000..0483940b
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_seven_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_shinbundang_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_shinbundang_16.xml
new file mode 100644
index 00000000..5d53bbd2
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_shinbundang_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_sillim_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_sillim_16.xml
new file mode 100644
index 00000000..4baf6226
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_sillim_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_six_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_six_16.xml
new file mode 100644
index 00000000..51fd0690
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_six_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_suinbundang_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_suinbundang_16.xml
new file mode 100644
index 00000000..60464598
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_suinbundang_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_three_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_three_16.xml
new file mode 100644
index 00000000..9d453b06
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_three_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_two_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_two_16.xml
new file mode 100644
index 00000000..5eb82f4d
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_two_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_ui_sinseol_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_ui_sinseol_16.xml
new file mode 100644
index 00000000..0612ffda
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_ui_sinseol_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_uijeongbu_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_uijeongbu_16.xml
new file mode 100644
index 00000000..9649a58a
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_uijeongbu_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_subway_line_youngin_ever_16.xml b/core/designsystem/src/main/res/drawable/ic_subway_line_youngin_ever_16.xml
new file mode 100644
index 00000000..32f7c11a
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_subway_line_youngin_ever_16.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_view_count_80.xml b/core/designsystem/src/main/res/drawable/ic_view_count_80.xml
new file mode 100644
index 00000000..1d685d93
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_view_count_80.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/font/spoqa_han_sans_neo_bold.ttf b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_bold.ttf
new file mode 100644
index 00000000..45aa7f63
Binary files /dev/null and b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_bold.ttf differ
diff --git a/core/designsystem/src/main/res/font/spoqa_han_sans_neo_medium.ttf b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_medium.ttf
new file mode 100644
index 00000000..e736c509
Binary files /dev/null and b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_medium.ttf differ
diff --git a/core/designsystem/src/main/res/font/spoqa_han_sans_neo_regular.ttf b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_regular.ttf
new file mode 100644
index 00000000..ca7bf6d8
Binary files /dev/null and b/core/designsystem/src/main/res/font/spoqa_han_sans_neo_regular.ttf differ
diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b1193d49
--- /dev/null
+++ b/core/designsystem/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 피드백 보내기
+
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 00000000..36e60448
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ `java-library`
+ alias(libs.plugins.funch.jvm.library)
+}
+
+tasks.withType {
+ useJUnitPlatform()
+}
+
+dependencies {
+ implementation(libs.javax.inject)
+ // test
+ testImplementation(kotlin("test"))
+ testImplementation(libs.bundles.junit5)
+ testImplementation(libs.truth)
+ testImplementation(libs.kotlin.coroutines.test)
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/Blood.kt b/core/domain/src/main/java/com/moya/funch/entity/Blood.kt
new file mode 100644
index 00000000..d24ad56f
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/Blood.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.entity
+
+enum class Blood(val type: String) {
+ A("A형"),
+ B("B형"),
+ AB("AB형"),
+ O("O형"),
+ IDLE("idle")
+ ;
+
+ companion object {
+ fun of(bloodType: String): Blood {
+ val blood = runCatching { Blood.valueOf(bloodType) }
+ if (blood.isSuccess) return requireNotNull(blood.getOrNull())
+ return requireNotNull(Blood.entries.find { it.type == bloodType }) { "Club : $bloodType not found" }
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/Club.kt b/core/domain/src/main/java/com/moya/funch/entity/Club.kt
new file mode 100644
index 00000000..52cc523b
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/Club.kt
@@ -0,0 +1,17 @@
+package com.moya.funch.entity
+
+enum class Club(val label: String) {
+ NEXTERS("넥스터즈"),
+ SOPT("SOPT"),
+ DEPROMEET("Depromeet"),
+ IDLE("idle")
+ ;
+
+ companion object {
+ fun of(clubName: String): Club {
+ val club = runCatching { valueOf(clubName) }
+ if (club.isSuccess) return requireNotNull(club.getOrNull())
+ return requireNotNull(entries.find { it.label == clubName }) { "Club : $clubName not found" }
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/Job.kt b/core/domain/src/main/java/com/moya/funch/entity/Job.kt
new file mode 100644
index 00000000..24b9bccd
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/Job.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.entity
+
+enum class Job(val krName: String) {
+ DEVELOPER("개발자"),
+ DESIGNER("디자이너"),
+ IDLE("idle")
+ ;
+
+ companion object {
+ fun of(name: String): Job {
+ val job = runCatching { valueOf(name) }
+ if (job.isSuccess) return requireNotNull(job.getOrNull())
+ return requireNotNull(entries.find { it.krName == name }) { "Job : $name not found" }
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/Mbti.kt b/core/domain/src/main/java/com/moya/funch/entity/Mbti.kt
new file mode 100644
index 00000000..7ab5d32f
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/Mbti.kt
@@ -0,0 +1,21 @@
+package com.moya.funch.entity
+
+enum class Mbti {
+ ENFJ,
+ ENFP,
+ ENTJ,
+ ENTP,
+ ESFJ,
+ ESFP,
+ ESTJ,
+ ESTP,
+ INFJ,
+ INFP,
+ INTJ,
+ INTP,
+ ISFJ,
+ ISFP,
+ ISTJ,
+ ISTP,
+ IDLE
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/SubwayStation.kt b/core/domain/src/main/java/com/moya/funch/entity/SubwayStation.kt
new file mode 100644
index 00000000..4ee785d9
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/SubwayStation.kt
@@ -0,0 +1,36 @@
+package com.moya.funch.entity
+
+data class SubwayStation(
+ val name: String = "",
+ val lines: List = emptyList()
+) {
+ init {
+ require(lines.distinct().size == lines.size) { "Subway lines must be unique" }
+ }
+}
+
+enum class SubwayLine {
+ ONE,
+ TWO,
+ THREE,
+ FOUR,
+ FIVE,
+ SIX,
+ SEVEN,
+ EIGHT,
+ NINE,
+ SEOHAE,
+ AIRPORT,
+ GIMPO,
+ UI_SINSEOL,
+ SILLIM,
+ YOUNGIN,
+ UIJEONGBU,
+ BUNDANG,
+ GYEONGCHUN,
+ GYEONGUI,
+ GYEONGGANG,
+ INCHEON,
+ INCHEON_TWO,
+ SINBUNDANG
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/match/Chemistry.kt b/core/domain/src/main/java/com/moya/funch/entity/match/Chemistry.kt
new file mode 100644
index 00000000..80e16cf2
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/match/Chemistry.kt
@@ -0,0 +1,6 @@
+package com.moya.funch.entity.match
+
+data class Chemistry(
+ val title: String,
+ val description: String
+)
diff --git a/core/domain/src/main/java/com/moya/funch/entity/match/MatchInfo.kt b/core/domain/src/main/java/com/moya/funch/entity/match/MatchInfo.kt
new file mode 100644
index 00000000..3fd274da
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/match/MatchInfo.kt
@@ -0,0 +1,5 @@
+package com.moya.funch.entity.match
+
+data class MatchInfo(
+ val title: String
+)
diff --git a/core/domain/src/main/java/com/moya/funch/entity/match/Matching.kt b/core/domain/src/main/java/com/moya/funch/entity/match/Matching.kt
new file mode 100644
index 00000000..afb37730
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/match/Matching.kt
@@ -0,0 +1,52 @@
+package com.moya.funch.entity.match
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.profile.Profile
+
+data class Matching(
+ val profile: Profile = Profile(),
+ val similarity: Int = 0,
+ val chemistrys: List = emptyList(),
+ val matchInfos: List = emptyList(),
+ val subwayChemistry: Chemistry? = null
+) {
+ init {
+ require(similarity in 0..100) {
+ "similarity must be in 0..100"
+ }
+ }
+
+ fun matches(job: Job, matchInfos: List): Boolean {
+ return matchInfos.any { recommend ->
+ (job.name == recommend.title) || (job.krName == recommend.title)
+ }
+ }
+
+ fun matches(club: Club, recommends: List): Boolean {
+ return recommends.any { recommend ->
+ (club.name == recommend.title) || (club.label == recommend.title)
+ }
+ }
+
+ fun matches(mbti: Mbti, recommends: List): Boolean {
+ return recommends.any { recommend ->
+ mbti.name == recommend.title
+ }
+ }
+
+ fun matches(blood: Blood, recommends: List): Boolean {
+ return recommends.any { recommend ->
+ (blood.name == recommend.title) || (blood.type == recommend.title)
+ }
+ }
+
+ fun matches(subway: SubwayStation, matchInfos: List): Boolean {
+ return matchInfos.any { recommend ->
+ subway.name == recommend.title
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/moya/funch/entity/profile/Profile.kt b/core/domain/src/main/java/com/moya/funch/entity/profile/Profile.kt
new file mode 100644
index 00000000..38dbe6a2
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/entity/profile/Profile.kt
@@ -0,0 +1,32 @@
+package com.moya.funch.entity.profile
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayStation
+
+data class Profile(
+ val id: String = "",
+ val code: String = "NONE",
+ val name: String = "",
+ val job: Job = Job.IDLE,
+ val clubs: List = emptyList(),
+ val mbti: Mbti = Mbti.IDLE,
+ val blood: Blood = Blood.IDLE,
+ val subways: List = listOf(SubwayStation()),
+ val viewCount: Int = 0
+) {
+ init {
+ validate(code)
+ require(clubs.distinct().size == clubs.size) { "Clubs must be unique" }
+ }
+
+ private fun validate(userCode: String) {
+ require(userCode.isNotBlank()) { "Code must not be blank" }
+ require(userCode.length == 4) { "Code must be 4 letters" }
+ require(userCode.all { it in ('A'..'Z') || it in ('0'..'9') }) {
+ "Code must be all numbers or upper case alphabets"
+ }
+ }
+}
diff --git a/core/domain/src/main/java/com/moya/funch/repository/MatchingRepository.kt b/core/domain/src/main/java/com/moya/funch/repository/MatchingRepository.kt
new file mode 100644
index 00000000..c3c6d1d2
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/repository/MatchingRepository.kt
@@ -0,0 +1,7 @@
+package com.moya.funch.repository
+
+import com.moya.funch.entity.match.Matching
+
+fun interface MatchingRepository {
+ suspend fun matchProfile(targetCode: String): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/repository/MemberRepository.kt b/core/domain/src/main/java/com/moya/funch/repository/MemberRepository.kt
new file mode 100644
index 00000000..edeb5043
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/repository/MemberRepository.kt
@@ -0,0 +1,13 @@
+package com.moya.funch.repository
+
+import com.moya.funch.entity.profile.Profile
+
+interface MemberRepository {
+ suspend fun fetchUserProfile(): Result
+
+ suspend fun createUserProfile(profile: Profile): Result
+
+ suspend fun fetchUserViewCount(): Result
+
+ suspend fun fetchMemberProfile(id: String): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/repository/SubwayRepository.kt b/core/domain/src/main/java/com/moya/funch/repository/SubwayRepository.kt
new file mode 100644
index 00000000..223a82df
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/repository/SubwayRepository.kt
@@ -0,0 +1,8 @@
+package com.moya.funch.repository
+
+import com.moya.funch.entity.SubwayStation
+
+interface SubwayRepository {
+
+ suspend fun fetchSubwayStations(subwayStation: String): Result>
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/CanMatchProfileUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/CanMatchProfileUseCase.kt
new file mode 100644
index 00000000..6a12809e
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/CanMatchProfileUseCase.kt
@@ -0,0 +1,24 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.repository.MatchingRepository
+import javax.inject.Inject
+
+class CanMatchProfileUseCaseImpl @Inject constructor(
+ private val matchingRepository: MatchingRepository
+) : CanMatchProfileUseCase {
+ override suspend operator fun invoke(targetCode: String): Result = runCatching {
+ val code = targetCode.uppercase()
+ validate(code)
+ matchingRepository.matchProfile(code).getOrThrow()
+ }
+
+ private fun validate(targetCode: String) {
+ require(targetCode.isNotBlank())
+ require(targetCode.length == 4)
+ require(targetCode.all { it in ('A'..'Z') || it in ('0'..'9') })
+ }
+}
+
+fun interface CanMatchProfileUseCase {
+ suspend operator fun invoke(targetCode: String): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/CreateUserProfileUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/CreateUserProfileUseCase.kt
new file mode 100644
index 00000000..0ae07abd
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/CreateUserProfileUseCase.kt
@@ -0,0 +1,17 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.repository.MemberRepository
+import javax.inject.Inject
+
+class CreateUserProfileUseCaseImpl @Inject constructor(
+ private val memberRepository: MemberRepository
+) : CreateUserProfileUseCase {
+ override suspend operator fun invoke(profile: Profile): Result {
+ return memberRepository.createUserProfile(profile)
+ }
+}
+
+fun interface CreateUserProfileUseCase {
+ suspend operator fun invoke(profile: Profile): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/LoadSubwayStationsUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/LoadSubwayStationsUseCase.kt
new file mode 100644
index 00000000..3446ef88
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/LoadSubwayStationsUseCase.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.repository.SubwayRepository
+import javax.inject.Inject
+
+class LoadSubwayStationsUseCaseImpl @Inject constructor(
+ private val subwayRepository: SubwayRepository
+) : LoadSubwayStationsUseCase {
+
+ override suspend operator fun invoke(subwayStation: String): Result> {
+ return subwayRepository.fetchSubwayStations(subwayStation = subwayStation)
+ }
+}
+
+fun interface LoadSubwayStationsUseCase {
+ suspend operator fun invoke(subwayStation: String): Result>
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/LoadUserProfileUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/LoadUserProfileUseCase.kt
new file mode 100644
index 00000000..c9603748
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/LoadUserProfileUseCase.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.repository.MemberRepository
+import javax.inject.Inject
+
+class LoadUserProfileUseCaseImpl @Inject constructor(
+ private val memberRepository: MemberRepository
+) : LoadUserProfileUseCase {
+ override suspend operator fun invoke(): Result {
+ return memberRepository.fetchUserProfile()
+ }
+}
+
+fun interface LoadUserProfileUseCase {
+
+ suspend operator fun invoke(): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/LoadViewCountUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/LoadViewCountUseCase.kt
new file mode 100644
index 00000000..b845f16f
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/LoadViewCountUseCase.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.repository.MemberRepository
+import javax.inject.Inject
+
+class LoadViewCountUseCaseImpl @Inject constructor(
+ private val memberRepository: MemberRepository
+) : LoadViewCountUseCase {
+ override suspend fun invoke(): Result {
+ return memberRepository.fetchUserViewCount()
+ }
+}
+
+fun interface LoadViewCountUseCase {
+ suspend operator fun invoke(): Result
+}
diff --git a/core/domain/src/main/java/com/moya/funch/usecase/MatchProfileUseCase.kt b/core/domain/src/main/java/com/moya/funch/usecase/MatchProfileUseCase.kt
new file mode 100644
index 00000000..7e6547a0
--- /dev/null
+++ b/core/domain/src/main/java/com/moya/funch/usecase/MatchProfileUseCase.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.usecase
+
+import com.moya.funch.entity.match.Matching
+import com.moya.funch.repository.MatchingRepository
+import javax.inject.Inject
+
+class MatchProfileUseCaseImpl @Inject constructor(
+ private val matchingRepository: MatchingRepository
+) : MatchProfileUseCase {
+ override suspend operator fun invoke(targetCode: String): Result =
+ matchingRepository.matchProfile(targetCode)
+}
+
+fun interface MatchProfileUseCase {
+ suspend operator fun invoke(targetCode: String): Result
+}
diff --git a/core/domain/src/test/java/com/moya/funch/entity/ClubTest.kt b/core/domain/src/test/java/com/moya/funch/entity/ClubTest.kt
new file mode 100644
index 00000000..2cfca253
--- /dev/null
+++ b/core/domain/src/test/java/com/moya/funch/entity/ClubTest.kt
@@ -0,0 +1,24 @@
+package com.moya.funch.entity
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.assertThrows
+
+internal class ClubTest {
+ @Test
+ fun `Club의 label혹은 name에 해당하는 이름으로 찾을 수 있다 `() {
+ assertAll(
+ { Club.of("넥스터즈") },
+ { Club.of("NEXTERS") },
+ { Club.of("SOPT") },
+ { Club.of("Depromeet") },
+ { Club.of("DEPROMEET") },
+ {
+ assertThrows("Club : 닭아리 not found") {
+ Club.of("닭아리")
+ }
+ }
+
+ )
+ }
+}
diff --git a/core/domain/src/test/java/com/moya/funch/entity/JobTest.kt b/core/domain/src/test/java/com/moya/funch/entity/JobTest.kt
new file mode 100644
index 00000000..904ce71a
--- /dev/null
+++ b/core/domain/src/test/java/com/moya/funch/entity/JobTest.kt
@@ -0,0 +1,25 @@
+package com.moya.funch.entity
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.assertThrows
+
+internal class JobTest {
+ @Test
+ fun `Job에 해당하는 name 혹은 krName으로 찾을 수 있다`() {
+ assertThrows("Job : 디발자 not found") {
+ Job.of("디발자")
+ }
+ assertAll(
+ { Job.of("DEVELOPER") },
+ { Job.of("DESIGNER") },
+ { Job.of("개발자") },
+ { Job.of("디자이너") },
+ {
+ assertThrows("Job : 디발자 not found") {
+ Job.of("디발자")
+ }
+ }
+ )
+ }
+}
diff --git a/core/domain/src/test/java/com/moya/funch/entity/SubwayStationTest.kt b/core/domain/src/test/java/com/moya/funch/entity/SubwayStationTest.kt
new file mode 100644
index 00000000..dfbc1c45
--- /dev/null
+++ b/core/domain/src/test/java/com/moya/funch/entity/SubwayStationTest.kt
@@ -0,0 +1,13 @@
+package com.moya.funch.entity
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+internal class SubwayStationTest {
+ @Test
+ fun `지하철 이름이 중복될 수 없다`() {
+ assertThrows("Job : 디발자 not found") {
+ SubwayStation("목동역", lines = listOf(SubwayLine.FIVE, SubwayLine.FIVE))
+ }
+ }
+}
diff --git a/core/domain/src/test/java/com/moya/funch/entity/profile/ProfileTest.kt b/core/domain/src/test/java/com/moya/funch/entity/profile/ProfileTest.kt
new file mode 100644
index 00000000..38afe6fe
--- /dev/null
+++ b/core/domain/src/test/java/com/moya/funch/entity/profile/ProfileTest.kt
@@ -0,0 +1,56 @@
+package com.moya.funch.entity.profile
+
+import com.moya.funch.entity.Club
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.assertThrows
+
+class ProfileTest {
+
+ @Test
+ fun `code는 모두 숫자 혹은 대문자 알파벳이다`() {
+ assertThrows("Code must be all numbers or upper case alphabets") {
+ Profile(code = "a123")
+ }
+ }
+
+ @Test
+ fun `code는 4글자다`() {
+ assertAll(
+ {
+ assertThrows("Code must not be blank") {
+ Profile(code = "0")
+ }
+ },
+ {
+ assertThrows("Code must not be blank") {
+ Profile(code = "1")
+ }
+ },
+ {
+ assertThrows("Code must not be blank") {
+ Profile(code = "12")
+ }
+ },
+ {
+ assertThrows("Code must not be blank") {
+ Profile(code = "123")
+ }
+ }
+ )
+ }
+
+ @Test
+ fun `code는 빈칸이면 안된다`() {
+ assertThrows("Code must not be blank") {
+ Profile(code = "")
+ }
+ }
+
+ @Test
+ fun `clubs는 중복되지 않는다`() {
+ assertThrows("Clubs must be unique") {
+ Profile(clubs = listOf(Club.of("SOPT"), Club.of("SOPT")))
+ }
+ }
+}
diff --git a/core/network/.gitignore b/core/network/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /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 00000000..4bedd590
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,46 @@
+import org.jetbrains.kotlin.konan.properties.Properties
+
+plugins {
+ alias(libs.plugins.ktlint)
+ alias(libs.plugins.funch.android.library)
+ alias(libs.plugins.funch.kotlinx.serialization)
+ alias(libs.plugins.funch.junit5)
+}
+
+val properties =
+ Properties().apply {
+ load(rootProject.file("local.properties").inputStream())
+ }
+android {
+ namespace = "com.moja.funch.network"
+
+ buildTypes {
+ getByName("release") {
+ buildConfigField(
+ "String",
+ "FUNCH_DEBUG_BASE_URL",
+ properties.getProperty("FUNCH_DEBUG_BASE_URL")
+ )
+ }
+ getByName("debug") {
+ buildConfigField(
+ "String",
+ "FUNCH_DEBUG_BASE_URL",
+ properties.getProperty("FUNCH_DEBUG_BASE_URL")
+ )
+ }
+ }
+}
+
+dependencies {
+ implementation(projects.core.datastore)
+ implementation(projects.core.testing)
+
+ implementation(libs.bundles.retrofit)
+ implementation(platform(libs.okhttp.bom))
+ implementation(libs.okhttp.logging.interceptor)
+ // test
+ testImplementation(libs.mockk)
+ testImplementation(libs.kotlin.coroutines.test)
+ testImplementation(libs.mockk.webserver)
+}
diff --git a/core/network/src/main/java/com/moya/funch/network/di/RetrofitModule.kt b/core/network/src/main/java/com/moya/funch/network/di/RetrofitModule.kt
new file mode 100644
index 00000000..9ccd28a9
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/di/RetrofitModule.kt
@@ -0,0 +1,60 @@
+package com.moya.funch.network.di
+
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import com.moja.funch.network.BuildConfig
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Converter
+import retrofit2.Retrofit
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RetrofitModule {
+ @Provides
+ @Singleton
+ fun provideJson(): Json = Json {
+ coerceInputValues = true
+ }
+
+ @Singleton
+ @Provides
+ fun provideJsonConverterFactory(json: Json): Converter.Factory {
+ return json.asConverterFactory("application/json".toMediaType())
+ }
+
+ @Singleton
+ @Provides
+ fun provideLoggingInterceptor(): Interceptor = HttpLoggingInterceptor().setLevel(
+ if (BuildConfig.DEBUG) {
+ HttpLoggingInterceptor.Level.BODY
+ } else {
+ HttpLoggingInterceptor.Level.NONE
+ }
+ )
+
+ @Singleton
+ @Provides
+ fun provideOkHttpClient(logInterceptor: Interceptor): OkHttpClient = OkHttpClient
+ .Builder()
+ .addInterceptor(logInterceptor)
+ .connectTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS).build()
+
+ @Singleton
+ @Provides
+ fun provideRetrofit(client: OkHttpClient, converterFactory: Converter.Factory): Retrofit = Retrofit.Builder()
+ .baseUrl(BuildConfig.FUNCH_DEBUG_BASE_URL)
+ .client(client)
+ .addConverterFactory(converterFactory)
+ .build()
+}
diff --git a/core/network/src/main/java/com/moya/funch/network/di/ServiceModule.kt b/core/network/src/main/java/com/moya/funch/network/di/ServiceModule.kt
new file mode 100644
index 00000000..a01c80f0
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/di/ServiceModule.kt
@@ -0,0 +1,28 @@
+package com.moya.funch.network.di
+
+import com.moya.funch.network.service.MatchingService
+import com.moya.funch.network.service.MemberService
+import com.moya.funch.network.service.SubwayService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import retrofit2.Retrofit
+import retrofit2.create
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ServiceModule {
+ @Provides
+ @Singleton
+ fun providesMatchingService(retrofit: Retrofit): MatchingService = retrofit.create()
+
+ @Provides
+ @Singleton
+ fun providesMemberService(retrofit: Retrofit): MemberService = retrofit.create()
+
+ @Provides
+ @Singleton
+ fun providesSubwayStationService(retrofit: Retrofit): SubwayService = retrofit.create()
+}
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/request/MatchingRequest.kt b/core/network/src/main/java/com/moya/funch/network/dto/request/MatchingRequest.kt
new file mode 100644
index 00000000..8e6324fb
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/request/MatchingRequest.kt
@@ -0,0 +1,10 @@
+package com.moya.funch.network.dto.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MatchingRequest(
+ @SerialName("requestMemberId") val userId: String,
+ @SerialName("targetMemberCode") val targetCode: String
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/request/MemberRequest.kt b/core/network/src/main/java/com/moya/funch/network/dto/request/MemberRequest.kt
new file mode 100644
index 00000000..3f9ced66
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/request/MemberRequest.kt
@@ -0,0 +1,22 @@
+package com.moya.funch.network.dto.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MemberRequest(
+ @SerialName("name")
+ val name: String,
+ @SerialName("jobGroup")
+ val jobGroup: String,
+ @SerialName("clubs")
+ val clubs: List,
+ @SerialName("bloodType")
+ val bloodType: String,
+ @SerialName("subwayStations")
+ val subwayStations: List,
+ @SerialName("mbti")
+ val mbti: String,
+ @SerialName("deviceNumber")
+ val deviceNumber: String
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/BaseResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/BaseResponse.kt
new file mode 100644
index 00000000..e1e9ddd5
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/BaseResponse.kt
@@ -0,0 +1,14 @@
+package com.moya.funch.network.dto.response
+
+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
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/match/ChemistryResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/match/ChemistryResponse.kt
new file mode 100644
index 00000000..e63865a6
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/match/ChemistryResponse.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.network.dto.response.match
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ChemistryResponse(
+ @SerialName("title")
+ val title: String = "",
+ @SerialName("description")
+ val description: String = ""
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchInfoResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchInfoResponse.kt
new file mode 100644
index 00000000..e38ee863
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchInfoResponse.kt
@@ -0,0 +1,10 @@
+package com.moya.funch.network.dto.response.match
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MatchInfoResponse(
+ @SerialName("title")
+ val title: String = ""
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchingResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchingResponse.kt
new file mode 100644
index 00000000..6db10053
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/match/MatchingResponse.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.network.dto.response.match
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MatchingResponse(
+ @SerialName("profile")
+ val profile: ProfileResponse = ProfileResponse(),
+ @SerialName("similarity")
+ val similarity: Int = 0,
+ @SerialName("chemistryInfos")
+ val chemistryInfos: List = listOf(),
+ @SerialName("matchedInfos")
+ val matchInfos: List = listOf(),
+ @SerialName("subwayChemistryInfo")
+ val subwayChemistry: ChemistryResponse? = null
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/match/ProfileResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/match/ProfileResponse.kt
new file mode 100644
index 00000000..45a8b92b
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/match/ProfileResponse.kt
@@ -0,0 +1,20 @@
+package com.moya.funch.network.dto.response.match
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ProfileResponse(
+ @SerialName("name")
+ val name: String = "",
+ @SerialName("jobGroup")
+ val jobGroup: String = "",
+ @SerialName("clubs")
+ val clubs: List = listOf(),
+ @SerialName("mbti")
+ val mbti: String = "",
+ @SerialName("bloodType")
+ val bloodType: String = "",
+ @SerialName("subwayInfos")
+ val subways: List = listOf()
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/match/SubwayResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/match/SubwayResponse.kt
new file mode 100644
index 00000000..1d8c24d9
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/match/SubwayResponse.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.network.dto.response.match
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SubwayResponse(
+ @SerialName("lines")
+ val lines: List = listOf(),
+ @SerialName("name")
+ val name: String = ""
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/member/MemberResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/member/MemberResponse.kt
new file mode 100644
index 00000000..d68321eb
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/member/MemberResponse.kt
@@ -0,0 +1,27 @@
+package com.moya.funch.network.dto.response.member
+
+import com.moya.funch.network.dto.response.match.SubwayResponse
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MemberResponse(
+ @SerialName("id")
+ val id: String = "",
+ @SerialName("name")
+ val name: String = "",
+ @SerialName("bloodType")
+ val bloodType: String = "",
+ @SerialName("jobGroup")
+ val jobGroup: String = "",
+ @SerialName("clubs")
+ val clubs: List = listOf(),
+ @SerialName("mbti")
+ val mbti: String = "",
+ @SerialName("memberCode")
+ val memberCode: String = "",
+ @SerialName("subwayInfos")
+ val subwayInfos: List = listOf(),
+ @SerialName("viewCount")
+ val viewCount: Int = 0
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/LocationResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/LocationResponse.kt
new file mode 100644
index 00000000..3240a769
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/LocationResponse.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.network.dto.response.subwaystation
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LocationResponse(
+ @SerialName("latitude")
+ val latitude: String = "",
+ @SerialName("longitude")
+ val longitude: String = ""
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/SubwayStationsResponse.kt b/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/SubwayStationsResponse.kt
new file mode 100644
index 00000000..48864533
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/dto/response/subwaystation/SubwayStationsResponse.kt
@@ -0,0 +1,16 @@
+package com.moya.funch.network.dto.response.subwaystation
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SubwayStationsResponse(
+ @SerialName("id")
+ val id: String = "",
+ @SerialName("name")
+ val name: String = "",
+ @SerialName("lines")
+ val lines: List = listOf(),
+ @SerialName("location")
+ val location: LocationResponse = LocationResponse()
+)
diff --git a/core/network/src/main/java/com/moya/funch/network/service/MatchingService.kt b/core/network/src/main/java/com/moya/funch/network/service/MatchingService.kt
new file mode 100644
index 00000000..11374341
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/service/MatchingService.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.network.service
+
+import com.moya.funch.network.dto.request.MatchingRequest
+import com.moya.funch.network.dto.response.BaseResponse
+import com.moya.funch.network.dto.response.match.MatchingResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface MatchingService {
+ @POST("api/v1/matching")
+ suspend fun matchProfile(@Body body: MatchingRequest): BaseResponse
+}
diff --git a/core/network/src/main/java/com/moya/funch/network/service/MemberService.kt b/core/network/src/main/java/com/moya/funch/network/service/MemberService.kt
new file mode 100644
index 00000000..bc97005e
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/service/MemberService.kt
@@ -0,0 +1,22 @@
+package com.moya.funch.network.service
+
+import com.moya.funch.network.dto.request.MemberRequest
+import com.moya.funch.network.dto.response.BaseResponse
+import com.moya.funch.network.dto.response.member.MemberResponse
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface MemberService {
+
+ @POST("api/v1/members")
+ suspend fun createMember(@Body body: MemberRequest): BaseResponse
+
+ @GET("api/v1/members/{id}")
+ suspend fun findMemberById(@Path("id") id: String): BaseResponse
+
+ @GET("api/v1/members")
+ suspend fun findMemberByDeviceNumber(@Query("deviceNumber") deviceNumber: String): BaseResponse
+}
diff --git a/core/network/src/main/java/com/moya/funch/network/service/SubwayService.kt b/core/network/src/main/java/com/moya/funch/network/service/SubwayService.kt
new file mode 100644
index 00000000..7f9c58c1
--- /dev/null
+++ b/core/network/src/main/java/com/moya/funch/network/service/SubwayService.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.network.service
+
+import com.moya.funch.network.dto.response.BaseResponse
+import com.moya.funch.network.dto.response.subwaystation.SubwayStationsResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface SubwayService {
+
+ @GET("api/v1/subway-stations/search")
+ suspend fun findSubwayStations(@Query("query") subwayStation: String): BaseResponse>
+}
diff --git a/core/network/src/test/java/com/moya/funch/network/service/MatchingServiceTest.kt b/core/network/src/test/java/com/moya/funch/network/service/MatchingServiceTest.kt
new file mode 100644
index 00000000..df6b9cd0
--- /dev/null
+++ b/core/network/src/test/java/com/moya/funch/network/service/MatchingServiceTest.kt
@@ -0,0 +1,118 @@
+package com.moya.funch.network.service
+
+import com.google.common.truth.Truth.assertThat
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import com.moya.funch.network.dto.request.MatchingRequest
+import com.moya.funch.network.dto.response.BaseResponse
+import com.moya.funch.network.dto.response.match.ChemistryResponse
+import com.moya.funch.network.dto.response.match.MatchInfoResponse
+import com.moya.funch.network.dto.response.match.MatchingResponse
+import com.moya.funch.network.dto.response.match.ProfileResponse
+import com.moya.funch.network.dto.response.match.SubwayResponse
+import com.moya.funch.rule.CoroutinesTestExtension
+import io.mockk.junit5.MockKExtension
+import java.io.File
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertAll
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.RegisterExtension
+import retrofit2.Retrofit
+import retrofit2.create
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MockKExtension::class)
+@ExtendWith(CoroutinesTestExtension::class)
+internal class MatchingServiceTest {
+ private lateinit var mockWebServer: MockWebServer
+ private lateinit var matchingService: MatchingService
+
+ @BeforeEach
+ fun setUp() {
+ mockWebServer = MockWebServer()
+ matchingService =
+ Retrofit.Builder().addConverterFactory(
+ Json {
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ coerceInputValues = true
+ }.asConverterFactory("application/json".toMediaType())
+ ).baseUrl(mockWebServer.url("")).build().create()
+ }
+
+ @Test
+ fun `내 id와 상대방의 code로 Matching 결과를 불러올 수 있다`() = runTest {
+ // given
+ val matchingJson = File("src/test/res/matching_result.json").readText()
+ val fakeResponse = MockResponse().setBody(matchingJson).setResponseCode(200)
+ mockWebServer.enqueue(fakeResponse)
+ val expected =
+ BaseResponse(
+ status = 200,
+ message = "OK",
+ data =
+ MatchingResponse(
+ profile =
+ ProfileResponse(
+ name = "aaa",
+ jobGroup = "개발자",
+ clubs = listOf("DEPROMEET"),
+ mbti = "ENFJ",
+ bloodType = "AB",
+ subways = listOf(
+ SubwayResponse(
+ lines = listOf("ONE", "FOUR"),
+ name = "서울역"
+ )
+ )
+ ),
+ similarity = 40,
+ chemistryInfos =
+ listOf(
+ ChemistryResponse(
+ "기막힌 타이밍에 등장한 너!",
+ "미정"
+ ),
+ ChemistryResponse(
+ "서로 비슷한 똑! 닮은 꼴",
+ "미정"
+ )
+ ),
+ matchInfos =
+ listOf(
+ MatchInfoResponse("ENFJ"),
+ MatchInfoResponse("전갈자리")
+ ),
+ subwayChemistry = null
+ )
+ )
+ // when
+ val actualResponse =
+ matchingService.matchProfile(
+ MatchingRequest(
+ userId = "65b6c543ebe5db753688b9dd",
+ targetCode = "7O2K"
+ )
+ )
+ // then
+ assertAll(
+ { assertThat(actualResponse.data.matchInfos).isEqualTo(expected.data.matchInfos) },
+ { assertThat(actualResponse.data.profile).isEqualTo(expected.data.profile) },
+ { assertThat(actualResponse.data.subwayChemistry).isEqualTo(expected.data.subwayChemistry) },
+ { assertThat(actualResponse.data.similarity).isEqualTo(expected.data.similarity) },
+ { assertThat(actualResponse.data.chemistryInfos).isEqualTo(expected.data.chemistryInfos) }
+ )
+ }
+
+ companion object {
+ @JvmField
+ @RegisterExtension
+ val coroutineExtension = CoroutinesTestExtension()
+ }
+}
diff --git a/core/network/src/test/java/com/moya/funch/network/service/SubwayServiceTest.kt b/core/network/src/test/java/com/moya/funch/network/service/SubwayServiceTest.kt
new file mode 100644
index 00000000..6dca8a9a
--- /dev/null
+++ b/core/network/src/test/java/com/moya/funch/network/service/SubwayServiceTest.kt
@@ -0,0 +1,80 @@
+package com.moya.funch.network.service
+
+import com.google.common.truth.Truth
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import com.moya.funch.network.dto.response.BaseResponse
+import com.moya.funch.network.dto.response.subwaystation.LocationResponse
+import com.moya.funch.network.dto.response.subwaystation.SubwayStationsResponse
+import com.moya.funch.rule.CoroutinesTestExtension
+import io.mockk.junit5.MockKExtension
+import java.io.File
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import retrofit2.Retrofit
+import retrofit2.create
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(MockKExtension::class)
+@ExtendWith(CoroutinesTestExtension::class)
+internal class SubwayServiceTest {
+
+ private lateinit var mockWebServer: MockWebServer
+ private lateinit var subwayStationService: SubwayService
+
+ @BeforeEach
+ fun setUp() {
+ mockWebServer = MockWebServer()
+ subwayStationService =
+ Retrofit.Builder().addConverterFactory(
+ Json {
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ coerceInputValues = true
+ }.asConverterFactory("application/json".toMediaType())
+ ).baseUrl(mockWebServer.url("")).build().create()
+ }
+
+ @Test
+ fun `subway를 입력하여 Subway Station List와 Line List를 불러온다`() = runTest {
+ // given
+ val matchingJson = File("src/test/res/search_subway_stations_result.json").readText()
+ val fakeResponse = MockResponse().setBody(matchingJson).setResponseCode(200)
+ mockWebServer.enqueue(fakeResponse)
+ val expected =
+ BaseResponse(
+ status = 200,
+ message = "OK",
+ data = listOf(
+ SubwayStationsResponse(
+ id = "65cdf2d7ffc89209ea09ce65",
+ name = "강남",
+ lines = listOf("TWO", "SINBUNDANG"),
+ location = LocationResponse(
+ latitude = "0.0",
+ longitude = "0.0"
+ )
+ ),
+ SubwayStationsResponse(
+ id = "65cdf2d7ffc89209ea09ce66",
+ name = "강남구청",
+ lines = listOf("BUNDANG", "SEVEN"),
+ location = LocationResponse(
+ latitude = "0.0",
+ longitude = "0.0"
+ )
+ )
+ )
+ )
+ // when
+ val actualResponse = subwayStationService.findSubwayStations("강")
+ // then
+ Truth.assertThat(actualResponse).isEqualTo(expected)
+ }
+}
diff --git a/core/network/src/test/res/create_member.json b/core/network/src/test/res/create_member.json
new file mode 100644
index 00000000..e074f008
--- /dev/null
+++ b/core/network/src/test/res/create_member.json
@@ -0,0 +1,17 @@
+{
+ "status": "201",
+ "message": "CREATED",
+ "data": {
+ "id": "65c5015dae92783f1e1f7fa3",
+ "name": "abc",
+ "bloodType": "A",
+ "jobGroup": "IOS",
+ "clubs": [
+ "SOPT"
+ ],
+ "subwayInfos": [],
+ "mbti": "ENFJ",
+ "memberCode": "2LL2",
+ "viewCount": 0
+ }
+}
diff --git a/core/network/src/test/res/create_member_request.json b/core/network/src/test/res/create_member_request.json
new file mode 100644
index 00000000..539b47c2
--- /dev/null
+++ b/core/network/src/test/res/create_member_request.json
@@ -0,0 +1,9 @@
+{
+ "name": "abc",
+ "jobGroup": "IOS",
+ "clubs": ["SOPT"],
+ "bloodType": "A",
+ "subwayStations": [],
+ "mbti": "ENFJ",
+ "deviceNumber": "ccc"
+}
diff --git a/core/network/src/test/res/matching_result.json b/core/network/src/test/res/matching_result.json
new file mode 100644
index 00000000..b706fd89
--- /dev/null
+++ b/core/network/src/test/res/matching_result.json
@@ -0,0 +1,44 @@
+{
+ "status": "200",
+ "message": "OK",
+ "data": {
+ "profile": {
+ "name": "aaa",
+ "jobGroup": "개발자",
+ "clubs": [
+ "DEPROMEET"
+ ],
+ "mbti": "ENFJ",
+ "bloodType": "AB",
+ "subwayInfos": [
+ {
+ "name": "서울역",
+ "lines": [
+ "ONE",
+ "FOUR"
+ ]
+ }
+ ]
+ },
+ "similarity": 40,
+ "chemistryInfos": [
+ {
+ "title": "기막힌 타이밍에 등장한 너!",
+ "description": "미정"
+ },
+ {
+ "title": "서로 비슷한 똑! 닮은 꼴",
+ "description": "미정"
+ }
+ ],
+ "matchedInfos": [
+ {
+ "title": "ENFJ"
+ },
+ {
+ "title": "전갈자리"
+ }
+ ],
+ "subwayChemistryInfo": null
+ }
+}
diff --git a/core/network/src/test/res/search_subway_stations_result.json b/core/network/src/test/res/search_subway_stations_result.json
new file mode 100644
index 00000000..c6206da8
--- /dev/null
+++ b/core/network/src/test/res/search_subway_stations_result.json
@@ -0,0 +1,30 @@
+{
+ "status": "200",
+ "message": "OK",
+ "data": [
+ {
+ "id": "65cdf2d7ffc89209ea09ce65",
+ "name": "강남",
+ "lines": [
+ "TWO",
+ "SINBUNDANG"
+ ],
+ "location": {
+ "latitude": "0.0",
+ "longitude": "0.0"
+ }
+ },
+ {
+ "id": "65cdf2d7ffc89209ea09ce66",
+ "name": "강남구청",
+ "lines": [
+ "BUNDANG",
+ "SEVEN"
+ ],
+ "location": {
+ "latitude": "0.0",
+ "longitude": "0.0"
+ }
+ }
+ ]
+}
diff --git a/core/testing/.gitignore b/core/testing/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/testing/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
new file mode 100644
index 00000000..b038e1cf
--- /dev/null
+++ b/core/testing/build.gradle.kts
@@ -0,0 +1,10 @@
+plugins {
+ `java-library`
+ alias(libs.plugins.funch.jvm.library)
+}
+
+dependencies {
+ implementation(libs.javax.inject)
+ implementation(libs.bundles.junit5)
+ implementation(libs.kotlin.coroutines.test)
+}
diff --git a/core/testing/src/main/java/com/moya/funch/rule/CoroutinesTestExtension.kt b/core/testing/src/main/java/com/moya/funch/rule/CoroutinesTestExtension.kt
new file mode 100644
index 00000000..fa0b45da
--- /dev/null
+++ b/core/testing/src/main/java/com/moya/funch/rule/CoroutinesTestExtension.kt
@@ -0,0 +1,24 @@
+package com.moya.funch.rule
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.jupiter.api.extension.AfterEachCallback
+import org.junit.jupiter.api.extension.BeforeEachCallback
+import org.junit.jupiter.api.extension.ExtensionContext
+
+@ExperimentalCoroutinesApi
+class CoroutinesTestExtension(
+ private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
+) : BeforeEachCallback, AfterEachCallback {
+ override fun beforeEach(context: ExtensionContext?) {
+ Dispatchers.setMain(dispatcher)
+ }
+
+ override fun afterEach(context: ExtensionContext?) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/feature/home/.gitignore b/feature/home/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/home/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts
new file mode 100644
index 00000000..45e95f7d
--- /dev/null
+++ b/feature/home/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ alias(libs.plugins.funch.feature)
+ alias(libs.plugins.funch.compose)
+}
+
+android {
+ namespace = "com.moya.funch.home"
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.domain)
+}
diff --git a/feature/home/proguard-rules.pro b/feature/home/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/home/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/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..ee58a0a3
--- /dev/null
+++ b/feature/home/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/home/src/main/java/com/moya/funch/HomeScreen.kt b/feature/home/src/main/java/com/moya/funch/HomeScreen.kt
new file mode 100644
index 00000000..6b0a2268
--- /dev/null
+++ b/feature/home/src/main/java/com/moya/funch/HomeScreen.kt
@@ -0,0 +1,380 @@
+package com.moya.funch
+
+import android.widget.Toast
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.moya.funch.common.jobPainter
+import com.moya.funch.component.FunchButtonTextField
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.component.FunchIconButton
+import com.moya.funch.entity.Job
+import com.moya.funch.home.R
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.modifier.clickableSingle
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray300
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray700
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.Lemon500
+import com.moya.funch.theme.LocalBackgroundTheme
+import com.moya.funch.theme.White
+import com.moya.funch.theme.Yellow500
+import com.moya.funch.ui.FunchTopBar
+import com.moya.funch.ui.SingleEventArea
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+private val brush = Brush.horizontalGradient(
+ 0.5f to Lemon500,
+ 0.5f to Color(0xFFFFD440)
+)
+
+@Composable
+internal fun HomeRoute(
+ viewModel: HomeViewModel = hiltViewModel(),
+ onNavigateToMyProfile: () -> Unit,
+ onNavigateToMatching: (String) -> Unit
+) {
+ val homeModel by viewModel.homeModel.collectAsStateWithLifecycle()
+ val matched by viewModel.matched.collectAsStateWithLifecycle(false)
+ val context = LocalContext.current
+ val matchDone by rememberUpdatedState(viewModel::matchDone)
+
+ LaunchedEffect(viewModel) {
+ viewModel.homeErrorMessage.onEach {
+ Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
+ }.launchIn(this)
+ }
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ viewModel.fetchViewCount()
+ }
+ }
+
+ lifecycleOwner.lifecycle.addObserver(observer)
+
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ if (matched) {
+ matchDone()
+ onNavigateToMatching(homeModel.matchingCode)
+ }
+
+ HomeScreen(
+ myCode = homeModel.myCode,
+ viewCount = homeModel.viewCount,
+ job = homeModel.job,
+ matchingCode = homeModel.matchingCode,
+ onMatchingCodeChange = viewModel::setMatchingCode,
+ matchProfile = viewModel::matchProfile,
+ onNavigateToMyProfile = onNavigateToMyProfile
+ )
+}
+
+@Composable
+internal fun HomeScreen(
+ myCode: String,
+ viewCount: Int,
+ job: Job,
+ matchingCode: String,
+ onMatchingCodeChange: (String) -> Unit,
+ matchProfile: () -> Unit,
+ onNavigateToMyProfile: () -> Unit
+) {
+ val focusManager = LocalFocusManager.current
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectTapGestures(onTap = {
+ focusManager.clearFocus()
+ })
+ }
+ .padding(
+ start = 20.dp,
+ end = 20.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ HomeTopBar(onClickFeedBack = {})
+ MatchingCard(
+ value = matchingCode,
+ onValueChange = onMatchingCodeChange,
+ matchProfile = matchProfile
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ CodeCard(
+ modifier = Modifier.weight(1f),
+ myCode = myCode
+ )
+ MyProfileCard(
+ job,
+ onMyProfileClick = onNavigateToMyProfile
+ )
+ }
+ ProfileViewCounterCard(
+ viewCount = viewCount
+ )
+ }
+}
+
+@Composable
+private fun HomeTopBar(onClickFeedBack: () -> Unit) {
+ FunchTopBar(
+ modifier = Modifier.padding(bottom = 8.dp),
+ leadingIcon = null,
+ onClickTrailingIcon = onClickFeedBack
+ )
+}
+
+@Composable
+private fun MatchingCard(value: String, onValueChange: (String) -> Unit, matchProfile: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .border(
+ width = 1.dp,
+ brush = brush,
+ shape = RoundedCornerShape(20.dp)
+ )
+ .clip(RoundedCornerShape(20.dp))
+ .background(Gray800)
+ .padding(
+ top = 24.dp,
+ bottom = 33.dp,
+ start = 16.dp,
+ end = 16.dp
+ )
+ ) {
+ Text(
+ text = stringResource(id = R.string.matching_card_title),
+ style = FunchTheme.typography.t2,
+ color = White
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(id = R.string.matching_card_caption),
+ style = FunchTheme.typography.b,
+ color = Gray300
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ FunchButtonTextField(
+ backgroundColor = Gray700,
+ value = value,
+ onValueChange = onValueChange,
+ hint = stringResource(id = R.string.matching_card_hint),
+ iconButton = {
+ SingleEventArea { cutter ->
+ FunchIconButton(
+ modifier = Modifier.size(40.dp),
+ roundedCornerShape = RoundedCornerShape(12.dp),
+ backgroundColor = Gray500,
+ onClick = { cutter.handle(matchProfile) },
+ funchIcon = FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ description = "",
+ tint = Yellow500
+ )
+ )
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun CodeCard(modifier: Modifier = Modifier, myCode: String) {
+ Row(
+ modifier = modifier
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.medium
+ )
+ .padding(
+ top = 24.dp,
+ bottom = 24.dp,
+ start = 20.dp
+ ),
+ horizontalArrangement = Arrangement.spacedBy(space = 12.dp)
+ ) {
+ Image(
+ modifier = Modifier.size(40.dp),
+ painter = painterResource(id = FunchIconAsset.Etc.code_80),
+ contentDescription = "code"
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(space = 2.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.my_code_card_caption),
+ style = FunchTheme.typography.b,
+ color = Gray400
+ )
+ Text(
+ text = stringResource(id = R.string.my_code, myCode),
+ style = TextStyle(
+ brush = brush,
+ fontStyle = FunchTheme.typography.sbt2.fontStyle,
+ fontFamily = FunchTheme.typography.sbt2.fontFamily,
+ fontWeight = FunchTheme.typography.sbt2.fontWeight,
+ fontSize = FunchTheme.typography.sbt2.fontSize,
+ letterSpacing = FunchTheme.typography.sbt2.letterSpacing,
+ lineHeight = FunchTheme.typography.sbt2.lineHeight,
+ lineHeightStyle = LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Proportional,
+ trim = LineHeightStyle.Trim.None
+ )
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun MyProfileCard(job: Job, modifier: Modifier = Modifier, onMyProfileClick: () -> Unit) {
+ Column(
+ modifier = modifier
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.medium
+ )
+ .clip(FunchTheme.shapes.medium)
+ .clickableSingle(onClick = onMyProfileClick)
+ .padding(
+ vertical = 11.5.dp,
+ horizontal = 24.dp
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(space = 8.dp)
+ ) {
+ Image(
+ modifier = Modifier.size(40.dp),
+ painter = jobPainter(value = job.krName),
+ contentDescription = ""
+ )
+ Text(
+ text = stringResource(id = R.string.my_profile_card_caption),
+ style = FunchTheme.typography.b,
+ color = Gray400
+ )
+ }
+}
+
+@Composable
+private fun ProfileViewCounterCard(viewCount: Int) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.medium
+ )
+ .padding(
+ top = 24.dp,
+ bottom = 24.dp,
+ start = 20.dp
+ ),
+ horizontalArrangement = Arrangement.spacedBy(space = 12.dp)
+ ) {
+ Image(
+ modifier = Modifier.size(40.dp),
+ painter = painterResource(id = FunchIconAsset.Etc.view_count_80),
+ contentDescription = ""
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(space = 2.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.profile_view_counter_caption),
+ style = FunchTheme.typography.b,
+ color = Gray400
+ )
+ Text(
+ text = stringResource(id = R.string.profile_view_counter_card_subtitle, viewCount),
+ style = FunchTheme.typography.sbt2,
+ color = White
+ )
+ }
+ }
+}
+
+@Preview(
+ "Home UI",
+ showBackground = true,
+ widthDp = 360,
+ heightDp = 640
+)
+@Composable
+private fun Preview1() {
+ var text by remember { mutableStateOf("") }
+ val code = "u23c".uppercase()
+
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ HomeScreen(
+ myCode = code,
+ viewCount = 23,
+ job = Job.DESIGNER,
+ matchingCode = text,
+ onMatchingCodeChange = { text = it },
+ matchProfile = {},
+ onNavigateToMyProfile = {}
+ )
+ }
+ }
+}
diff --git a/feature/home/src/main/java/com/moya/funch/HomeViewModel.kt b/feature/home/src/main/java/com/moya/funch/HomeViewModel.kt
new file mode 100644
index 00000000..080f0f5b
--- /dev/null
+++ b/feature/home/src/main/java/com/moya/funch/HomeViewModel.kt
@@ -0,0 +1,113 @@
+package com.moya.funch
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.moya.funch.entity.Job
+import com.moya.funch.usecase.CanMatchProfileUseCase
+import com.moya.funch.usecase.LoadUserProfileUseCase
+import com.moya.funch.usecase.LoadViewCountUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+data class HomeModel(
+ val myCode: String,
+ val viewCount: Int,
+ val job: Job = Job.IDLE,
+ val matchingCode: String = ""
+) {
+ companion object {
+ fun empty() = HomeModel(
+ myCode = "",
+ viewCount = 0,
+ job = Job.IDLE,
+ matchingCode = ""
+ )
+ }
+}
+
+@HiltViewModel
+internal class HomeViewModel @Inject constructor(
+ private val canMatchProfileUse: CanMatchProfileUseCase,
+ private val loadUserProfileUseCase: LoadUserProfileUseCase,
+ private val loadViewCountUseCase: LoadViewCountUseCase
+) : ViewModel() {
+ private val _homeModel = MutableStateFlow(HomeModel.empty())
+ val homeModel = _homeModel.asStateFlow()
+
+ private val _homeErrorMessage: MutableSharedFlow = MutableSharedFlow()
+ val homeErrorMessage = _homeErrorMessage.asSharedFlow()
+
+ private val _matched = MutableStateFlow(false)
+ val matched = _matched.asStateFlow()
+
+ init {
+ initHome()
+ }
+
+ fun setMatchingCode(code: String) {
+ _homeModel.value = _homeModel.value.copy(
+ matchingCode = code.uppercase()
+ )
+ }
+
+ fun matchProfile() {
+ viewModelScope.launch {
+ canMatchProfileUse(homeModel.value.matchingCode).onSuccess {
+ _matched.value = true
+ }.onFailure {
+ Timber.e("matchProfile(): ${it.stackTraceToString()}")
+ _homeErrorMessage.emit("매칭할 수 없는 코드입니다.")
+ }
+ }
+ }
+
+ fun matchDone() {
+ _matched.value = false
+ }
+
+ private fun initHome() {
+ fetchUserProfile()
+ fetchViewCount()
+ }
+
+ private fun fetchUserProfile() {
+ viewModelScope.launch {
+ loadUserProfileUseCase().onSuccess {
+ setMyCode(it.code)
+ _homeModel.value = _homeModel.value.copy(job = it.job)
+ }.onFailure {
+ Timber.e("fetchUserProfile(): ${it.stackTraceToString()}")
+ setMyCode("NONE")
+ }
+ }
+ }
+
+ fun fetchViewCount() {
+ viewModelScope.launch {
+ loadViewCountUseCase().onSuccess {
+ setViewCount(it)
+ }.onFailure {
+ Timber.e("fetchViewCount(): ${it.stackTraceToString()}")
+ setViewCount(0)
+ }
+ }
+ }
+
+ private fun setMyCode(code: String) {
+ _homeModel.value = _homeModel.value.copy(
+ myCode = code.uppercase()
+ )
+ }
+
+ private fun setViewCount(count: Int) {
+ _homeModel.value = _homeModel.value.copy(
+ viewCount = count
+ )
+ }
+}
diff --git a/feature/home/src/main/java/com/moya/funch/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/moya/funch/navigation/HomeNavigation.kt
new file mode 100644
index 00000000..752a8c2c
--- /dev/null
+++ b/feature/home/src/main/java/com/moya/funch/navigation/HomeNavigation.kt
@@ -0,0 +1,21 @@
+package com.moya.funch.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.moya.funch.HomeRoute
+
+const val HOME_ROUTE = "home"
+
+fun NavController.onNavigateToHome() = navigate(HOME_ROUTE) {
+ popUpTo(graph.id)
+}
+
+fun NavGraphBuilder.homeScreen(onNavigateToMyProfile: () -> Unit, onNavigateToMatching: (String) -> Unit) {
+ composable(route = HOME_ROUTE) {
+ HomeRoute(
+ onNavigateToMatching = onNavigateToMatching,
+ onNavigateToMyProfile = onNavigateToMyProfile
+ )
+ }
+}
diff --git a/feature/home/src/main/java/com/moya/funch/theme/HomeColors.kt b/feature/home/src/main/java/com/moya/funch/theme/HomeColors.kt
new file mode 100644
index 00000000..e8bf9fae
--- /dev/null
+++ b/feature/home/src/main/java/com/moya/funch/theme/HomeColors.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.theme
+
+import androidx.compose.ui.graphics.Color
+
+internal val Coral500 = Color(0xFFF86E6F)
+internal val Lemon500 = Color(0xFFFFE83B)
+internal val Lemon600 = Color(0xFFE1CA13)
+internal val Lemon900 = Color(0xFF90720A)
+internal val Yellow500 = Color(0xFFFFD240)
+internal val Yellow600 = Color(0xFFE1B012)
+internal val White = Color(0xFFFFFFFF)
+internal val Gray900 = Color(0xFF151515)
+internal val Gray800 = Color(0xFF242627)
+internal val Gray700 = Color(0xFF2C2C2C)
+internal val Gray600 = Color(0xFF363636)
+internal val Gray500 = Color(0xFF404040)
+internal val Gray400 = Color(0xFF6D6D6D)
+internal val Gray300 = Color(0xFF9B9B9B)
diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml
new file mode 100644
index 00000000..fceb6774
--- /dev/null
+++ b/feature/home/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+
+
+ 친구 코드를 입력하고 매칭하기
+ "우리는 잘 맞을까?
+ 궁합, 공통점 등 친구의 새로운 정보를 알 수 있어요!
+ 나의 코드
+ %s
+ 내 프로필
+ 내 프로필을
+ %d명이 조회했어요
+
diff --git a/feature/match/.gitignore b/feature/match/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/match/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/match/build.gradle.kts b/feature/match/build.gradle.kts
new file mode 100644
index 00000000..b0fbb164
--- /dev/null
+++ b/feature/match/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+ alias(libs.plugins.funch.feature)
+ alias(libs.plugins.funch.compose)
+}
+
+android {
+ namespace = "com.moya.funch.match"
+
+ packaging {
+ resources.excludes.add("META-INF/LICENSE*")
+ }
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.domain)
+ implementation(projects.core.testing)
+
+ implementation(libs.compose.lottie)
+}
diff --git a/feature/match/proguard-rules.pro b/feature/match/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/match/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/feature/match/src/main/AndroidManifest.xml b/feature/match/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..e1000761
--- /dev/null
+++ b/feature/match/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/match/src/main/java/com/moya/funch/match/MatchScreen.kt b/feature/match/src/main/java/com/moya/funch/match/MatchScreen.kt
new file mode 100644
index 00000000..39ce3632
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/MatchScreen.kt
@@ -0,0 +1,153 @@
+package com.moya.funch.match
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.foundation.layout.size
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.airbnb.lottie.compose.LottieAnimation
+import com.airbnb.lottie.compose.LottieCompositionSpec
+import com.airbnb.lottie.compose.LottieConstants
+import com.airbnb.lottie.compose.rememberLottieComposition
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.match.MatchViewModel.Companion.MOCK_MATCHING
+import com.moya.funch.match.component.MatchHorizontalPager
+import com.moya.funch.match.component.MatchTopBar
+import com.moya.funch.match.model.MatchProfileUiModel
+import com.moya.funch.match.theme.Gray400
+import com.moya.funch.match.theme.Gray900
+import com.moya.funch.match.theme.White
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.LocalBackgroundTheme
+import com.moya.funch.ui.FunchTopBar
+
+@Composable
+internal fun MatchRoute(onClose: () -> Unit, code: String, matchViewModel: MatchViewModel = hiltViewModel()) {
+ val uiState by matchViewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(matchViewModel) {
+ matchViewModel.saveMatchCode(code)
+ }
+
+ when (uiState) {
+ is MatchUiState.Loading -> LoadingContent(onClose = onClose)
+ is MatchUiState.Error -> ErrorMatchContent(code)
+ is MatchUiState.Success -> {
+ MatchScreen(
+ onClose = onClose,
+ matching = (uiState as MatchUiState.Success)
+ )
+ }
+ }
+}
+
+@Composable
+private fun MatchScreen(onClose: () -> Unit, matching: MatchUiState.Success) {
+ val (profile, similarity, chemistrys, subwayChemistry) = matching
+ Column(
+ modifier = Modifier
+ .background(Gray900)
+ .fillMaxSize()
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MatchTopBar(onClose = onClose)
+ Spacer(modifier = Modifier.height(8.dp))
+ MatchHorizontalPager(profile, similarity, chemistrys, subwayChemistry)
+ }
+}
+
+@Composable
+private fun ErrorMatchContent(code: String) {
+ Text("There is no match code $code. Please try again.", color = Color.Red)
+}
+
+@Composable
+private fun LoadingContent(onClose: () -> Unit) {
+ val lottieComposition by rememberLottieComposition(
+ LottieCompositionSpec.RawRes(
+ R.raw.funch_character_loading
+ )
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ FunchTopBar(
+ modifier = Modifier.padding(start = 12.dp),
+ trailingIcon = null,
+ leadingIcon = FunchIcon(
+ resId = FunchIconAsset.Arrow.arrow_left_small_24,
+ description = "",
+ tint = Gray400
+ ),
+ onClickLeadingIcon = onClose
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(
+ space = 12.dp,
+ alignment = Alignment.CenterVertically
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ LottieAnimation(
+ modifier = Modifier.size(200.dp),
+ composition = lottieComposition,
+ iterations = LottieConstants.IterateForever
+ )
+ Text(
+ text = stringResource(id = R.string.match_loading_title),
+ color = White,
+ style = FunchTheme.typography.t2
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "Success match screen", device = "id:Nexus 6")
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ Surface(color = LocalBackgroundTheme.current.color) {
+ MatchScreen(
+ onClose = {},
+ matching = MatchUiState.Success(
+ MatchProfileUiModel.from(MOCK_MATCHING),
+ MOCK_MATCHING.similarity,
+ MOCK_MATCHING.chemistrys,
+ MOCK_MATCHING.subwayChemistry
+ )
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, name = "Loading match screen", device = "id:Nexus 6")
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ Surface(color = LocalBackgroundTheme.current.color) {
+ LoadingContent(onClose = {})
+ }
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/MatchViewModel.kt b/feature/match/src/main/java/com/moya/funch/match/MatchViewModel.kt
new file mode 100644
index 00000000..8a661be4
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/MatchViewModel.kt
@@ -0,0 +1,114 @@
+package com.moya.funch.match
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.match.Chemistry
+import com.moya.funch.entity.match.MatchInfo
+import com.moya.funch.entity.match.Matching
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.match.model.MatchProfileUiModel
+import com.moya.funch.usecase.MatchProfileUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import timber.log.Timber
+
+@HiltViewModel
+internal class MatchViewModel @Inject constructor(
+ private val matchProfileUseCase: MatchProfileUseCase,
+ private val savedStateHandle: SavedStateHandle
+) : ViewModel() {
+
+ private val matchCode: StateFlow = savedStateHandle.getStateFlow(MATCH_CODE, "")
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val uiState: StateFlow = matchCode.mapLatest { code ->
+ delay(2400)
+ if (code.isEmpty()) {
+ MatchUiState.Loading
+ } else {
+ matchProfileUseCase(matchCode.value)
+ .onFailure {
+ Timber.e("MatchViewModel - matchProfileUseCase - ${it.stackTraceToString()}")
+ }
+ .getOrNull()
+ ?.let {
+ MatchUiState.Success(MatchProfileUiModel.from(it), it.similarity, it.chemistrys, it.subwayChemistry)
+ } ?: MatchUiState.Error
+ }
+ }.catch {
+ Timber.e("it")
+ emit(MatchUiState.Error)
+ }.stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = MatchUiState.Loading
+ )
+
+ fun saveMatchCode(code: String) {
+ savedStateHandle[MATCH_CODE] = code
+ }
+
+ internal companion object {
+ private const val MATCH_CODE = "matchCode"
+
+ val MOCK_MATCHING = Matching(
+ profile = Profile().copy(
+ name = "abc",
+ job = Job.DEVELOPER,
+ clubs = listOf(Club.SOPT, Club.NEXTERS),
+ mbti = Mbti.INFP,
+ blood = Blood.A,
+ subways = listOf(
+ SubwayStation("목동역", lines = listOf(SubwayLine.FIVE))
+ )
+ ),
+ similarity = 80,
+ chemistrys = listOf(
+ Chemistry(
+ title = "찾았다, 내 소울메이트!",
+ description = "ENTJ인 {userName}님은 비전을 향해 적극적으로 이끄는 리더 타입!"
+ ),
+ Chemistry(
+ title = "서로 다른 점을 찾는 재미",
+ description = "B형인 {userName}님은 호기심과 창의력을 갖췄지만 변덕스러워요"
+ )
+ ),
+ matchInfos = listOf(
+ MatchInfo("개발자"),
+ MatchInfo("SOPT"),
+ MatchInfo("ENFJ"),
+ MatchInfo("A형"),
+ MatchInfo("목동역")
+ ),
+ subwayChemistry = Chemistry(
+ title = "FIVE",
+ description = "5호선"
+ )
+ )
+ }
+}
+
+internal sealed class MatchUiState {
+ data object Loading : MatchUiState()
+ data object Error : MatchUiState()
+ data class Success(
+ val profile: MatchProfileUiModel,
+ val similarity: Int = 0,
+ val chemistrys: List = emptyList(),
+ val subWayChemistry: Chemistry? = null
+ ) : MatchUiState()
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/MatchCardLayout.kt b/feature/match/src/main/java/com/moya/funch/match/component/MatchCardLayout.kt
new file mode 100644
index 00000000..3010edbc
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/MatchCardLayout.kt
@@ -0,0 +1,97 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.match.theme.Gray700
+import com.moya.funch.match.theme.Gray800
+import com.moya.funch.match.theme.Gray900
+import com.moya.funch.match.theme.Lemon500
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+internal fun MatchCardLayout(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Gray800, shape = FunchTheme.shapes.large)
+ .padding(top = 20.dp, start = 20.dp, end = 10.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ content()
+ }
+}
+
+@Composable
+@Preview(
+ showBackground = true,
+ widthDp = 360,
+ heightDp = 640,
+ name = "MatchCard"
+)
+private fun Preview() {
+ FunchTheme {
+ Column(
+ modifier =
+ Modifier
+ .background(Gray900)
+ .padding(60.dp)
+ ) {
+ MatchCardLayout(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Text(text = "우리가 누구?", color = Color.White)
+ Text(text = "다이나믹 듀오~~", color = Color.White)
+ }
+ }
+ }
+}
+
+@Composable
+@Preview(
+ showBackground = true,
+ widthDp = 360,
+ heightDp = 640,
+ name = "FunchNeonSignCard"
+)
+private fun Preview2() {
+ FunchTheme {
+ Column(
+ modifier =
+ Modifier
+ .background(Gray900)
+ .padding(60.dp)
+ ) {
+ val brush =
+ Brush.verticalGradient(
+ 0.1f to Lemon500,
+ 0.8f to Gray700
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Gray800, shape = FunchTheme.shapes.large)
+ .border(1.dp, brush, FunchTheme.shapes.large)
+ .clip(FunchTheme.shapes.large),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "우리가 누구?", color = Color.White)
+ Text(text = "다이나믹 듀오~~", color = Color.White)
+ }
+ }
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/MatchHorizontalPager.kt b/feature/match/src/main/java/com/moya/funch/match/component/MatchHorizontalPager.kt
new file mode 100644
index 00000000..3d0bafe7
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/MatchHorizontalPager.kt
@@ -0,0 +1,105 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.match.Chemistry
+import com.moya.funch.entity.match.MatchInfo
+import com.moya.funch.entity.match.Matching
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.match.model.MatchProfileUiModel
+import com.moya.funch.theme.FunchTheme
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+internal fun MatchHorizontalPager(
+ profile: MatchProfileUiModel,
+ similarity: Int,
+ chemistrys: List,
+ subwayChemistry: Chemistry?
+) {
+ val pageCount = 3
+ val pagerState = rememberPagerState(pageCount = { pageCount })
+ HorizontalPager(
+ pageSpacing = 8.dp,
+ contentPadding = PaddingValues(horizontal = 20.dp),
+ modifier = Modifier,
+ beyondBoundsPageCount = 2,
+ state = pagerState
+ ) { page ->
+ if (page == 0) SimilarityCard(profile.name, similarity, chemistrys, subwayChemistry, page, pageCount)
+ if (page == 1) RecommendCard(page, pageCount)
+ if (page == 2) MatchProfileCard(profile, page, pageCount)
+ }
+}
+
+@Preview(
+ showBackground = true,
+ name = "MatchHorizontalPager - 640dpi",
+ device = "spec:width = 360dp, height = 640dp, dpi = 420",
+ showSystemUi = true
+)
+@Preview(
+ showBackground = true,
+ name = "MatchHorizontalPager - 800dpi",
+ device = "spec:width = 360dp, height = 800dp, dpi = 420",
+ showSystemUi = true
+)
+@Composable
+private fun Preview() {
+ FunchTheme {
+ MatchHorizontalPager(
+ profile = MatchProfileUiModel.from(
+ Matching(
+ profile = Profile().copy(
+ name = "abc",
+ job = Job.DEVELOPER,
+ clubs = listOf(Club.SOPT, Club.NEXTERS),
+ mbti = Mbti.INFP,
+ blood = Blood.A,
+ subways = listOf(
+ SubwayStation("목동역", lines = listOf(SubwayLine.FIVE))
+ )
+ ),
+ similarity = 80,
+ chemistrys = listOf(
+ Chemistry("대한민국 선수분들", "정말 고생 많으셨습니다...")
+ ),
+ matchInfos = listOf(
+ MatchInfo("개발자"),
+ MatchInfo("SOPT"),
+ MatchInfo("ENFJ"),
+ MatchInfo("A형"),
+ MatchInfo("목동역")
+ )
+ )
+ ),
+ similarity = 100,
+ chemistrys = listOf(
+ Chemistry(
+ title = "찾았다, 내 소울메이트!",
+ description = "ENTJ인 {userName}님은 비전을 향해 적극적으로 이끄는 리더 타입!"
+ ),
+ Chemistry(
+ title = "서로 다른 점을 찾는 재미",
+ description = "B형인 {userName}님은 호기심과 창의력을 갖췄지만 변덕스러워요"
+ )
+ ),
+ subwayChemistry = Chemistry(
+ title = "FIVE",
+ description = "5호선"
+ )
+ )
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/MatchPageIndicator.kt b/feature/match/src/main/java/com/moya/funch/match/component/MatchPageIndicator.kt
new file mode 100644
index 00000000..8a0e6ca2
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/MatchPageIndicator.kt
@@ -0,0 +1,52 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.match.theme.Gray400
+import com.moya.funch.match.theme.Gray500
+import com.moya.funch.match.theme.Gray800
+import com.moya.funch.match.theme.White
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+fun MatchPageIndicator(current: Int, pageCount: Int) {
+ require(current in 0 until pageCount)
+ val annotatedString = buildAnnotatedString {
+ append("${current + 1}/$pageCount")
+ addStyle(style = SpanStyle(color = White), start = 0, end = 1)
+ }
+
+ Box(
+ modifier = Modifier
+ .background(Gray500, FunchTheme.shapes.large)
+ .padding(vertical = 2.dp, horizontal = 8.dp)
+ ) {
+ Text(annotatedString, color = Gray400, style = FunchTheme.typography.caption)
+ }
+}
+
+@Composable
+@Preview(showBackground = true, name = "PagerIndicator")
+private fun Preview() {
+ FunchTheme {
+ Column(
+ modifier = Modifier
+ .background(Gray800),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MatchPageIndicator(current = 0, pageCount = 3)
+ MatchPageIndicator(current = 1, pageCount = 3)
+ MatchPageIndicator(current = 2, pageCount = 3)
+ }
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/MatchProfileCard.kt b/feature/match/src/main/java/com/moya/funch/match/component/MatchProfileCard.kt
new file mode 100644
index 00000000..a85f9c9f
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/MatchProfileCard.kt
@@ -0,0 +1,308 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.common.clubPainter
+import com.moya.funch.common.jobPainter
+import com.moya.funch.common.subwayLinePainter
+import com.moya.funch.component.FunchChip
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.match.MatchViewModel
+import com.moya.funch.match.model.MatchProfileUiModel
+import com.moya.funch.match.model.MatchingWrapper
+import com.moya.funch.match.model.ProfileItems
+import com.moya.funch.match.theme.Gray400
+import com.moya.funch.match.theme.Gray800
+import com.moya.funch.match.theme.Gray900
+import com.moya.funch.match.theme.White
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+internal fun MatchProfileCard(profile: MatchProfileUiModel, current: Int, pageCount: Int) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Gray800, shape = FunchTheme.shapes.large)
+ .padding(top = 20.dp)
+ .padding(horizontal = 18.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MatchPageIndicator(current = current, pageCount = pageCount)
+ Spacer(modifier = Modifier.height(8.dp))
+ MatchProfileCardContent(profile)
+ }
+}
+
+@Composable
+private fun MatchProfileCardContent(profile: MatchProfileUiModel) {
+ Column {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = profile.name,
+ style = FunchTheme.typography.t2,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.height(20.dp))
+
+ ProfileInfo(
+ profile.job,
+ profile.clubs,
+ profile.mbti,
+ profile.blood,
+ profile.subways
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ProfileInfo(
+ job: MatchingWrapper,
+ clubs: List>,
+ mbti: MatchingWrapper,
+ blood: MatchingWrapper,
+ subways: List>
+) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // job
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ProfileItemTitle(title = job.profileItem.title)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MatchChip(
+ label = job.data.krName,
+ mached = job.matched,
+ leadingPainter = jobPainter(value = job.data.krName)
+ )
+ }
+ }
+ // clubs
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ val title = clubs.firstOrNull()?.profileItem?.title.orEmpty()
+ ProfileItemTitle(title = title)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ clubs.forEach { club ->
+ MatchChip(
+ label = club.data.label,
+ mached = club.matched,
+ leadingPainter = clubPainter(value = club.data.label)
+ )
+ }
+ }
+ }
+ // mbti
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ProfileItemTitle(title = mbti.profileItem.title)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MatchChip(
+ label = mbti.data.name,
+ mached = mbti.matched,
+ leadingPainter = null
+ )
+ }
+ }
+ // blood
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ProfileItemTitle(title = blood.profileItem.title)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MatchChip(
+ label = blood.data.type,
+ mached = blood.matched
+ )
+ }
+ }
+ // subways
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ val title = subways.firstOrNull()?.profileItem?.title.orEmpty()
+ ProfileItemTitle(title = title)
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ subways.forEach { subway ->
+ MatchChip(
+ label = formatSubwayLabel(subway.data.name),
+ mached = subway.matched,
+ trailingPainter = subway.data.lines.map {
+ subwayLinePainter(value = it.name)
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun formatSubwayLabel(subwayLabel: String): String {
+ if (subwayLabel.lastOrNull() != null && subwayLabel.last() == '역') {
+ return subwayLabel
+ }
+ return "${subwayLabel}역"
+}
+
+// TODO 나중에 리팩토링
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ProfileItem(item: MatchingWrapper) {
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ ProfileItemTitle(title = item.profileItem.title)
+ // 아이템 타입에 따른 특수 처리 로직
+ when (item.profileItem) {
+ is ProfileItems.NonIcon -> {
+ }
+
+ is ProfileItems.LeadingIcon -> {
+ // 일반적인 처리
+ }
+
+ is ProfileItems.TrailingIcon -> {
+ }
+ }
+ // 공통 UI 컴포넌트 렌더링
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // 아이템 데이터를 기반으로 MatchChip 렌더링
+ }
+ }
+}
+
+@Composable
+private fun ProfileItemTitle(title: String) {
+ Box(
+ modifier = Modifier
+ .width(52.dp)
+ .height(48.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = title,
+ color = Gray400,
+ style = FunchTheme.typography.b
+ )
+ }
+}
+
+@Composable
+private fun MatchChip(
+ label: String,
+ leadingPainter: Painter? = null,
+ trailingPainter: List? = null,
+ mached: Boolean
+) {
+ FunchChip(
+ matched = mached,
+ selected = true,
+ enabled = false,
+ leadingIcon = { LeadingIcon(painter = leadingPainter) },
+ label = { ChipLabel(label = label) },
+ trailingIcon = { TrailingIcon(painters = trailingPainter) }
+ )
+}
+
+@Composable
+private fun ChipLabel(label: String) {
+ Text(
+ text = label,
+ style = FunchTheme.typography.b,
+ color = White
+ )
+}
+
+@Composable
+private fun LeadingIcon(painter: Painter? = null) {
+ painter?.let {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(
+ color = Gray900,
+ shape = FunchTheme.shapes.extraSmall
+ )
+ .clip(FunchTheme.shapes.extraSmall),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = it,
+ contentDescription = ""
+ )
+ }
+ }
+}
+
+@Composable
+private fun TrailingIcon(painters: List? = null) {
+ painters?.let {
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ painters.forEach {
+ Image(painter = it, contentDescription = "")
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun Preview() {
+ FunchTheme {
+ MatchProfileCard(
+ MatchProfileUiModel.from(MatchViewModel.MOCK_MATCHING),
+ 2,
+ 3
+ )
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/MatchTopBar.kt b/feature/match/src/main/java/com/moya/funch/match/component/MatchTopBar.kt
new file mode 100644
index 00000000..72ad3bc5
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/MatchTopBar.kt
@@ -0,0 +1,24 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.match.theme.Gray400
+import com.moya.funch.ui.FunchTopBar
+
+@Composable
+internal fun MatchTopBar(onClose: () -> Unit) {
+ // @murjune TODO Change leadingIcon tint funchTheme.xxx
+ FunchTopBar(
+ modifier = Modifier.padding(start = 12.dp, end = 20.dp),
+ leadingIcon = FunchIcon(
+ resId = FunchIconAsset.Etc.close_24,
+ description = "close",
+ tint = Gray400
+ ),
+ onClickLeadingIcon = onClose
+ )
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/RecommendCard.kt b/feature/match/src/main/java/com/moya/funch/match/component/RecommendCard.kt
new file mode 100644
index 00000000..3db27999
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/RecommendCard.kt
@@ -0,0 +1,83 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+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.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.match.R
+import com.moya.funch.match.theme.Gray300
+import com.moya.funch.match.theme.Gray500
+import com.moya.funch.match.theme.Gray800
+import com.moya.funch.match.theme.White
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+internal fun RecommendCard(current: Int, pageCount: Int) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Gray800, shape = FunchTheme.shapes.large)
+ .padding(top = 20.dp)
+ .padding(horizontal = 28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MatchPageIndicator(current = current, pageCount = pageCount)
+ Spacer(modifier = Modifier.height(8.dp))
+ RecommendCardContent()
+ }
+}
+
+@Composable
+private fun RecommendCardContent() {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("우리 이런 주제로 대화해봐요", style = FunchTheme.typography.t2, color = White)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text("지금부터 서로에게 집중하는 시간!", style = FunchTheme.typography.b, color = Gray300)
+ RecommendList()
+}
+
+@Composable
+private fun RecommendList() {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ stringArrayResource(id = R.array.match_recommend_labels).forEach {
+ RecommendItem(it)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+}
+
+@Composable
+private fun RecommendItem(recommend: String) {
+ Box(
+ modifier = Modifier
+ .background(Gray500, FunchTheme.shapes.medium)
+ .padding(horizontal = 16.dp, vertical = (13.5).dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = recommend, style = FunchTheme.typography.b, color = White)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun Preview() {
+ FunchTheme {
+ RecommendCard(1, 3)
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/component/SimilarityCard.kt b/feature/match/src/main/java/com/moya/funch/match/component/SimilarityCard.kt
new file mode 100644
index 00000000..f0f6c641
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/component/SimilarityCard.kt
@@ -0,0 +1,304 @@
+package com.moya.funch.match.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.moya.funch.common.subwayLinePainter
+import com.moya.funch.entity.match.Chemistry
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.match.MatchViewModel
+import com.moya.funch.match.R
+import com.moya.funch.match.theme.Gradient_Lemon500
+import com.moya.funch.match.theme.Gray400
+import com.moya.funch.match.theme.Gray800
+import com.moya.funch.match.theme.White
+import com.moya.funch.theme.FunchTheme
+
+private const val MIN_IMAGE_SIZE = 136
+private const val MAX_IMAGE_SIZE = 200
+private const val MIN_DEVICE_HEIGHT = 640
+private const val MAX_DEVICE_HEIGHT = 800
+
+/**
+ * Image 크기를 계산하는 함수
+ *
+ * 기기가 640 이하인 경우 136dp
+ * 800dp 이상인 경우 200dp
+ * 그 외)
+ * Image크기 : MIN_IMAGE_SIZE + (device height - MIN_DEVICE_HEIGHT) * increaseRate
+ * increaseRate = (MAX_IMAGE_SIZE - MIN_IMAGE_SIZE) / (MAX_DEVICE_HEIGHT - MIN_DEVICE_HEIGHT) = 0.8
+ */
+
+@Composable
+@Stable
+private fun imageSize(): Dp {
+ val configuration = LocalConfiguration.current
+ val increaseRate = 0.8
+ val heightInDp = configuration.screenHeightDp
+ // 만약 화면의 높이가 360 보다 작거나 같으면 136.dp
+ return when {
+ heightInDp < MIN_DEVICE_HEIGHT -> MIN_IMAGE_SIZE.dp
+ heightInDp > MAX_DEVICE_HEIGHT -> MAX_IMAGE_SIZE.dp
+ else -> ((MIN_IMAGE_SIZE + (heightInDp - MIN_DEVICE_HEIGHT)) * increaseRate).dp
+ }
+}
+
+private fun Int.formatNumber(): String {
+ return String.format("%02d", this)
+}
+
+@Composable
+internal fun SimilarityCard(
+ userName: String,
+ similarity: Int,
+ chemistrys: List,
+ subwayChemistry: Chemistry? = null,
+ current: Int,
+ pageCount: Int
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Gray800, shape = FunchTheme.shapes.large)
+ .padding(top = 20.dp)
+ .padding(horizontal = 28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ MatchPageIndicator(current = current, pageCount = pageCount)
+ Spacer(modifier = Modifier.height(8.dp))
+ SimilarityCardContent(
+ userName = userName,
+ similarity = similarity,
+ chemistrys = chemistrys,
+ subwayChemistry = subwayChemistry
+ )
+ }
+}
+
+@Composable
+private fun SimilarityCardContent(
+ userName: String,
+ similarity: Int,
+ chemistrys: List,
+ subwayChemistry: Chemistry?
+) {
+ SimilarityText(similarity = similarity)
+ Spacer(modifier = Modifier.height(16.dp))
+ Image(
+ modifier = Modifier
+ .size(imageSize())
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ painter = similarity.toSimilarityPainter(),
+ contentDescription = "Similarity"
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ ChemistryList(userName, chemistrys, subwayChemistry)
+}
+
+@Composable
+private fun SimilarityText(similarity: Int) {
+ val text = "우리는 ${similarity.formatNumber()}% 닮았어요"
+ val annotatedString = buildAnnotatedString {
+ append(text)
+ val start = text.indexOf("$similarity")
+ addStyle(
+ style = SpanStyle(brush = Brush.horizontalGradient(Gradient_Lemon500)),
+ start = start,
+ end = start + 3
+ )
+ }
+
+ Text(
+ text = annotatedString,
+ style = FunchTheme.typography.t2,
+ color = White
+ )
+}
+
+@Composable
+private fun ChemistryList(userName: String, chemistrys: List, subwayChemistry: Chemistry? = null) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ chemistrys.forEach { chemistry ->
+ ChemistryItem(chemistry = chemistry)
+ Spacer(modifier = Modifier.height(20.dp))
+ }
+ subwayChemistry?.let {
+ SubwayChemistryItem(userName = userName, chemistry = it)
+ }
+ }
+}
+
+@Composable
+private fun ChemistryItem(chemistry: Chemistry) {
+ val (title, description) = chemistry
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top
+ ) {
+ Image(modifier = Modifier.size(24.dp), painter = title.toPainter(), contentDescription = "Chemistry Icon")
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ Text(text = title, style = FunchTheme.typography.sbt2, color = White)
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(text = description, style = FunchTheme.typography.b, color = Gray400)
+ }
+ }
+}
+
+@Composable
+private fun SubwayChemistryItem(userName: String, chemistry: Chemistry) {
+ val (title, description) = chemistry
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.Top
+ ) {
+ Image(
+ modifier = Modifier.size(24.dp),
+ painter = subwayLinePainter(value = title),
+ contentDescription = "Chemistry Icon"
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ Text(
+ text = stringResource(id = R.string.subway_chemistry_title, description),
+ style = FunchTheme.typography.sbt2,
+ color = White
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(id = R.string.subway_chemistry_desciption, userName, description),
+ style = FunchTheme.typography.b,
+ color = Gray400
+ )
+ }
+ }
+}
+
+@Stable
+@Composable
+private fun Int.toSimilarityPainter(): Painter {
+ return when (this) {
+ in 81..100 -> painterResource(id = FunchIconAsset.MatchPercentage.hundred)
+ in 61..80 -> painterResource(id = FunchIconAsset.MatchPercentage.eighty)
+ in 41..60 -> painterResource(id = FunchIconAsset.MatchPercentage.sixty)
+ in 21..40 -> painterResource(id = FunchIconAsset.MatchPercentage.forty)
+ else -> painterResource(id = FunchIconAsset.MatchPercentage.twenty)
+ }
+}
+
+@Stable
+@Composable
+private fun String.toPainter(): Painter {
+ return when {
+ this == stringResource(id = R.string.match_mbti_pride_first) -> painterResource(id = FunchIconAsset.MBTI.one)
+
+ this == stringResource(id = R.string.match_mbti_pride_second) -> painterResource(id = FunchIconAsset.MBTI.two)
+
+ this == stringResource(id = R.string.match_mbti_pride_third) -> painterResource(id = FunchIconAsset.MBTI.three)
+
+ this == stringResource(id = R.string.match_mbti_pride_fourth) -> painterResource(id = FunchIconAsset.MBTI.four)
+
+ this == stringResource(id = R.string.match_mbti_pride_fifth) -> painterResource(id = FunchIconAsset.MBTI.five)
+
+ this == stringResource(id = R.string.match_blood_good) -> painterResource(id = FunchIconAsset.Blood.good_32)
+
+ this == stringResource(id = R.string.match_blood_soso) -> painterResource(id = FunchIconAsset.Blood.soso_32)
+
+ this == stringResource(id = R.string.match_blood_bad) -> painterResource(id = FunchIconAsset.Blood.bad_32)
+
+ this == stringResource(id = R.string.match_blood_great) -> painterResource(id = FunchIconAsset.Blood.great_32)
+
+ // @murjune TODO 지하철 호선 관련 DTO 변경 시 수정 : 임시로 Profile 박음
+ else -> painterResource(id = FunchIconAsset.Etc.profile_80)
+ }
+}
+
+@Composable
+@Preview(
+ showBackground = true,
+ name = "Phone - 640dpi",
+ device = "spec:width = 360dp, height = 640dp, dpi = 420",
+ showSystemUi = true
+)
+@Preview(
+ showBackground = true,
+ name = "Phone - 720dpi",
+ device = "spec:width = 360dp, height = 720dp, dpi = 420",
+ showSystemUi = true
+)
+@Preview(
+ name = "Phone - 891dpi",
+ device = "spec:width = 411dp, height = 891dp, dpi = 420",
+ showSystemUi = true
+)
+private fun Preview() {
+ FunchTheme {
+ Column(
+ modifier = Modifier
+ .background(Gray800)
+ .padding(horizontal = 28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ SimilarityCard(
+ userName = MatchViewModel.MOCK_MATCHING.profile.name,
+ similarity = MatchViewModel.MOCK_MATCHING.similarity,
+ chemistrys = MatchViewModel.MOCK_MATCHING.chemistrys,
+ subwayChemistry = MatchViewModel.MOCK_MATCHING.subwayChemistry,
+ current = 0,
+ pageCount = 3
+ )
+ }
+ }
+}
+
+@Preview(
+ showBackground = true,
+ name = "no subwayLine - 640dpi",
+ device = "spec:width = 360dp, height = 640dp, dpi = 420",
+ showSystemUi = true
+)
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ Column(
+ modifier = Modifier
+ .background(Gray800)
+ .padding(horizontal = 28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ SimilarityCard(
+ userName = MatchViewModel.MOCK_MATCHING.profile.name,
+ similarity = MatchViewModel.MOCK_MATCHING.similarity,
+ chemistrys = MatchViewModel.MOCK_MATCHING.chemistrys,
+ current = 0,
+ pageCount = 3
+ )
+ }
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/model/MatchProfileUiModel.kt b/feature/match/src/main/java/com/moya/funch/match/model/MatchProfileUiModel.kt
new file mode 100644
index 00000000..504a3809
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/model/MatchProfileUiModel.kt
@@ -0,0 +1,66 @@
+package com.moya.funch.match.model
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.match.Matching
+
+internal sealed interface ProfileItems {
+ val title: String
+
+ enum class LeadingIcon(override val title: String) : ProfileItems {
+ JOB("직군"),
+ CLUB("동아리")
+ }
+
+ enum class TrailingIcon(override val title: String) : ProfileItems {
+ SUBWAY("지하철")
+ }
+
+ enum class NonIcon(override val title: String) : ProfileItems {
+ BLOOD("혈액형"),
+ MBTI("MBTI")
+ }
+}
+
+internal data class MatchingWrapper(val profileItem: ProfileItems, val data: T, val matched: Boolean)
+
+internal data class MatchProfileUiModel(
+ val name: String,
+ val job: MatchingWrapper,
+ val clubs: List>,
+ val mbti: MatchingWrapper,
+ val blood: MatchingWrapper,
+ val subways: List>
+) {
+ companion object {
+ fun from(matching: Matching): MatchProfileUiModel {
+ val profile = matching.profile
+ val matchInfos = matching.matchInfos
+ with(profile) {
+ val job = MatchingWrapper(ProfileItems.LeadingIcon.JOB, job, matching.matches(job, matchInfos))
+ val clubs =
+ clubs.map { club ->
+ MatchingWrapper(
+ ProfileItems.LeadingIcon.CLUB,
+ club,
+ matching.matches(club, matchInfos)
+ )
+ }
+ val mbti = MatchingWrapper(ProfileItems.NonIcon.MBTI, mbti, matching.matches(mbti, matchInfos))
+ val blood = MatchingWrapper(ProfileItems.NonIcon.BLOOD, blood, matching.matches(blood, matchInfos))
+ val subwayStations =
+ subways.map { subway ->
+ MatchingWrapper(
+ ProfileItems.TrailingIcon.SUBWAY,
+ subway,
+ matching.matches(subway, matchInfos)
+ )
+ }
+ return MatchProfileUiModel(name = name, job, clubs, mbti, blood, subwayStations)
+ }
+ }
+ }
+}
diff --git a/feature/match/src/main/java/com/moya/funch/match/navigation/MatchNavigatoin.kt b/feature/match/src/main/java/com/moya/funch/match/navigation/MatchNavigatoin.kt
new file mode 100644
index 00000000..acf65966
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/navigation/MatchNavigatoin.kt
@@ -0,0 +1,33 @@
+package com.moya.funch.match.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.moya.funch.match.MatchRoute
+
+private const val MATCH_ROUTE = "match/{code}"
+private const val MEMBER_CODE_KEY = "code"
+private const val NO_MEMBER_CODE = "no member code"
+
+fun NavController.navigateToMatching(memberCode: String, navOptions: NavOptions? = null) =
+ navigate(createMatchRoute(memberCode), navOptions)
+
+fun NavGraphBuilder.matchingScreen(onClose: () -> Unit) {
+ composable(
+ route = MATCH_ROUTE,
+ arguments = listOf(
+ navArgument(MEMBER_CODE_KEY) {
+ type = NavType.StringType
+ defaultValue = NO_MEMBER_CODE
+ }
+ )
+ ) { backStackEntry ->
+ val code = backStackEntry.arguments?.getString(MEMBER_CODE_KEY) ?: NO_MEMBER_CODE
+ MatchRoute(code = code, onClose = onClose)
+ }
+}
+
+private fun createMatchRoute(memberCode: String) = MATCH_ROUTE.replace("{$MEMBER_CODE_KEY}", memberCode)
diff --git a/feature/match/src/main/java/com/moya/funch/match/theme/MatchColor.kt b/feature/match/src/main/java/com/moya/funch/match/theme/MatchColor.kt
new file mode 100644
index 00000000..30ee888a
--- /dev/null
+++ b/feature/match/src/main/java/com/moya/funch/match/theme/MatchColor.kt
@@ -0,0 +1,22 @@
+package com.moya.funch.match.theme
+
+import androidx.compose.ui.graphics.Color
+
+// TODO : @murjune : funchtheme color 확정시 삭제
+
+internal val Coral500 = Color(0xFFF86E6F)
+internal val Lemon500 = Color(0xFFFFE83B)
+internal val Lemon600 = Color(0xFFE1CA13)
+internal val Lemon900 = Color(0xFF90720A)
+internal val Yellow500 = Color(0xFFFFD240)
+internal val Yellow600 = Color(0xFFE1B012)
+internal val White = Color(0xFFFFFFFF)
+internal val Gray900 = Color(0xFF151515)
+internal val Gray800 = Color(0xFF242627)
+internal val Gray700 = Color(0xFF2C2C2C)
+internal val Gray600 = Color(0xFF363636)
+internal val Gray500 = Color(0xFF404040)
+internal val Gray400 = Color(0xFF6D6D6D)
+internal val Gray300 = Color(0xFF9B9B9B)
+
+internal val Gradient_Lemon500 = listOf(Lemon500, Yellow500)
diff --git a/feature/match/src/main/res/raw/funch_character_loading.json b/feature/match/src/main/res/raw/funch_character_loading.json
new file mode 100644
index 00000000..1544554e
--- /dev/null
+++ b/feature/match/src/main/res/raw/funch_character_loading.json
@@ -0,0 +1 @@
+{"v":"4.8.0","meta":{"g":"LottieFiles AE 3.5.4","a":"","k":"","d":"","tc":""},"fr":30,"ip":20,"op":55,"w":500,"h":500,"nm":"ì»´í¬ì§ì
1","ddd":0,"assets":[{"id":"image_0","w":43,"h":37,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAlCAYAAADbVxCwAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAAtUlEQVRYhe3U0Q2CMBSF4V8n6AiO0BEYxREcwREcoW7ACLKBI3QENtCHtjFpKEKpJCbnS+4DzYV7Ai0gIiIiso0BHPCK5eLanBswxv5+QX8zjk/QVM9CABPD5f2PHXLCxOBUI3AFLNABF8DP9K92qAzbwurZx4oh94p7ckODZyxiCHu09Hm/VWl//zSwqwi6658g1zF/iFJ54Lx1WM0Bm2IJYWy8PhECesLb7BvNERERERH5W29z3E6OalHZigAAAABJRU5ErkJggg==","e":1},{"id":"image_1","w":31,"h":21,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAVCAYAAAC+NTVfAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABQUlEQVRIicWW0U3DMBCG//86AJHguU0mgA3ICPUEdAMYISMwAkyQMgHpBu0ECbwXuQPkjoemoSpJBW3SfA+WLFv6zmefbaKFPA1CgUxNOG2bcwyqFQSzEuU8ct43zmmRJiAeTpE2sIHheeK+kqPyPL2JhToHcNWR+EdErErVeD8LtbwSv3ctPRaAbMVBWK24V8xwSxnVHtk2kqCHVDdBs/vP9HoGAJKnQdjh4foTRksAQARyUimdySRPgzs5tY7PRSCxDCGuCIaUY1g5VRvv3b4hWAjAbAh5iTITRf832yEkVpHzhUTOFzC8XtSuTIDqwCn0CcDmImKzt7Fbz2t55LxX07jvAEisFDbb9etSi5xf9hmAkYvW93xHngZB9ZN57Mj7QWMyduuXw4Ff8v0gRhhNDRabMPyvkWpLgtluf5v4Bvkkfq2ppM61AAAAAElFTkSuQmCC","e":1},{"id":"image_2","w":31,"h":21,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAVCAYAAAC+NTVfAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABRUlEQVRIicXW3VGDUBAF4HM2BXhn9DmBCrQDKSG3AtOBlkAJlqAVECuQdJBUAPoehxTArg8hmImQ0QTCeWCG4c58y/1ZIFqSJS4QyNSE07Yxx0K1nGBaopyHvigax7SgMYiHU9CGbGB4nviv+CieJTeRUOcArjqCfyBiVapG+7NQ4xX83jV6rADZwi6o3rjXmOGWMqod2V4kRg9T3RSa3X8m1zMAkCxxQYeb608xWgwAIpCTjtKZmWSJu5NTz/G5EUgkQ8BVnAjMDaWLgo2t7yI4VQfBCeYCMB0CL1Gmoui/sx2GxCr0RS6hL3IYXi+qK2Ogaq8KfQKwuQhs9jb263mNh74o1DTquwASK4XNdvd1kwl9seyzACMXrd/zXbLEuepP5rEj94PGeOzXL4cPfuH7RYwwmhosMmHwX5FqS4Lpbn2b8g0dYYGtdzqaGgAAAABJRU5ErkJggg==","e":1},{"id":"image_3","w":38,"h":41,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAApCAYAAABZa1t7AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABU0lEQVRYhe2YXXGEMBRGz9ZAcVAkREIkrAQkrAQkVEIlIIE62DqIhDjYPpDMbmHD5o8kDz0z38BbDvcCk9wTcfTAGRDmfo0GZpNr5BpBDGahW0AUMALdEUJns0CI0DoauOQS6oApUWidK8/b740gvG0h1ROxUvogqUc5GSLVFZCKqtxR7XNF4fHFjoWlbKY9qb6SlI10iX1VFlMtVmtTtTdzzfZHTmTjoahfLZsOlooJ4CPnYyci4S7WEhLaFBPQuFhrvEObYsC/WDhNi6naEiu+oU0xBYvYXFVjy58Dcqk9vk96uL/8u1vbgvywerUk9St1YxlBbJgrS2kcp6XaVdvdReeeU/jm5aiq5Cn8MV5bL1lYavCRsgyFpD5DpErJjTFSliPGUZrA9rnoyDc+mEmcJj5DJgjOBA7pYuhZfoYT7jbbcfqFyAqdkjUXehNNprn+L++OmvdYxCIjAAAAAElFTkSuQmCC","e":1},{"id":"image_4","w":38,"h":41,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAApCAYAAABZa1t7AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAABU0lEQVRYhe2YXZGDMACEv56Bw8EhIRIioRKQUAlIOAknAQk5B62DSMBB7wEy9KCh+SPJQ3dmB97ysQmQ7IkwtcAZEPP9WiOgZl8Dx/BSNw9097AGeqA5Aug8D+ADtPYIXFIBNcAQCbT2lefT7yyB/7T5pCdCocaDoB7hpA9UkwEqKLmjps9mjcMb22eGMh72oNpCUMbSBvZTGEzXmNYmtY/52tlizKzNn0FTPi3jBqbEBPCV8rEjJWEBq0kS6gQTUDlYbfqEOsGAN5i/qgbTpSFW+oU6wTRMYKooxlb/Dsi59vgubmFZ/Ltb24y6sVpakvJJ3bHsC1VhqBHLaal0aru9RuqewtUvq6qcp/BHO229ZGaozgXKqMsE9e0DlQuuD4EyOqKOGkl0jm1IVx8oItvEZ5IRgArPki5ELdPHcMA+zaZOvxCY0Ckac5Jg+QYm6fX/ANrvmue/JvVHAAAAAElFTkSuQmCC","e":1},{"id":"image_5","w":62,"h":61,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD4AAAA9CAYAAAD1VdrqAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAACs0lEQVRoge2b7XHaQBCGn1UDpgQ6iDowqSB0ENKB0wEl2B2QCoIriNIBrsBKB1DBmx93AgECZBvdIZ2fGQaBmGHf273VfewZHSJpBOTABKiuz1EAJbAys1WXtl0dSbmkuaSVPsZa0lLSzDfg7SFp5A38qNhzLCRNYmsFtoLn3juhKKI2gKSppDKg4KYGGIcUPPJ/eis8hBA9UdiwbkuhrhKgXPK6ZUpJlx6XW6yl6AXw/b2NFpANMGkzBrgovEeiK1qJz87dlEscfRINcAcUl8L+pMclTYHf17YqIP+A3MzWTTcbhcs9H1e41uszf81s0nTjVKgv6b9ogHudeM4fCZc0B750bVFA5moY4e2FutwgoGQY3q7zbGbT+heHHn9keKIBvulgYrP1uA+H18AGhWQv0dU9PgtuSlju6329Lrz7WU58thoz2A5Whti3D5lVF5XHp82/Gxx3VZJLTTh4rZkfzKcQ5hU5OI+3nrwPhHtwwsdx7QiPpDxFjwOMMtzWTmrkZ1dgBswoVeFpJjdwwsvYRsQg6VBPkSLDVSEkR5J93MyKDLd+nhIvAJnfY9pENiYkBeySWxHNjPAUsBO+jGdHUDZmtoR94SmE+9bBGYDfUUzB64vq4nNDwcxK4DmCQaF4rH843DQcM0yvH+2T743VvdefAhoUiqNdoqZJyhxXRjEUnpoKgU6VgkyAP11bFIAXXAXUUR1M47TUzArgZ8dGdc0GmL2p+KeihzVudb56BzZydiHCzGbArysbFIIf50S3Rq5Ivi/MPiy4Z+LXcvv810fSQ2x1J3hT5fJ7xeeKezLhkKVCHdbR7ixKTEp1FdotGmCs8H1/LVd9GZ9aA3R5bKPUrZ5B0+782fJKjVBKelQHiavV0Yz3Ijfmz/1r7N9P1du8AGvccvcKKPxs8ZNr8h8kTm/PgvsQnwAAAABJRU5ErkJggg==","e":1},{"id":"image_6","w":62,"h":61,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD4AAAA9CAYAAAD1VdrqAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAACtElEQVRoge2b4XHaQBBG36oBUwIdRB1AKggdhHTgdEAJuANSQXAFUTrAFVjpACr48uNOIECADOiEdH4zjGWLGe+3uzrd3e4ZDSJpAKTAGCiuz5EBObAys1WTtt0dSamkmaSVbmMtaSlp6h34eEgaeANvFXuOhaRx21qBreCZj04oslYdIGkiKQ8ouMoBw5CCB/6fPgrPIUSPFTat65KpqQFQbvB6ZHJJl16XW6ym6AXw/VqnBWQDjOvMAS4K75Doglrik3M35QaOLokGeAKyS2l/MuKSJsDve1sVkH9AambrqpuVwuXejyuc97rMXzMbV904lepLui8aYKQT7/kj4ZJmwJemLQrITBUzvL1Ul5sE5PQj2mVezWxS/sNhxOf0TzTANx0sbLYR9+nwHtigkOwNdOWIT4ObEpZR+VkvC29+ldM+W40JbCcrfXy2D5kWF0XEJ9Xf6x1PxSAXm3DwWhM/mY8hzQtScBGvvXjvCSNwwoft2hEeSWmMEQcYJLjSTmykZ3dgeswgVuHEmuokQOWeVN+JOtVjJEtwXQjRkeD22KLCzLIEt38eE28Aia8xbVo2JiQZ7Aa3rDUzwpPBTviyPTuCsjGzJewLjyHdtwFOAHxFMYaoL4qLz4KCmeXAawsGhWJe/uWwaDikn1E/qpPvzdV91F8CGhSKoypR1SJlhmuj6AsvVY1Ap1pBxsCfpi0KwBuuA+poz6FyWWpmGfCzYaOaZgNMP9T8U9DBHrcyX30AKzm7EWFmU+DXnQ0KwY9zomsj1yTfFaY3C+6Y+LVcnf/+SHpuW90JPtS5fK34VO2eTDhkqVCHdbQ7i9ImuZpK7RoOGCr8s7+W675sn5IDmjy2ketRz6Bpd/5seScn5JLmamDgqnU041rk5vyp/wz9z1P9Nm+4Ot7KfzK/WvzknvwHFL5vz5ogJ8wAAAAASUVORK5CYII=","e":1},{"id":"image_7","w":279,"h":241,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARcAAADxCAYAAADhufP/AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAMYklEQVR4nO3dS3LbVhbG8e+CUjTpKss9iK2ROe+KTc9ccrqCXoGVFZi9guYOzKyglR2wVxBlBaHKj8pMll0aRx7J8ST2rN0RcXpAUKYeJAEQz4v/bySTNHBGX537wIUTGsGOwq421JUkRQovvnDqSdqupChgsdFG1RXgi4sAiRTKqSupK1NXTvemP4h/6KqqEEjINCZcKmInYU8T9eTUk6kn6bvpF7ocHgQJGopwKYmdhD2day8exoSa6Nb0i2rrAopCuBTEjsKuAu3JKdQsTOhC0CKES47sJOwpUl+mUNKDqusBqkS4rOkiUCLtaRJPvAIgXLKIhzx9Sf2LQGHIA1xCuKRgb8K+pL5mKzsAFiJcVrjoUpwGUrzCA2AlwmUBexuGMvUlPa26FqCJCJcr4lAZyhj6AOsgXGKECpCv1ocLoQIUo7XhQqgAxWpduNhR2FVHQxkTtUCRWhMudhRuK9BATs+qrgVog1aEi70J92TavzgXBUDhvA6XeAi0L+kJ2/OBcgVVF1AUOw4H6ui1pCdV1wK0kXedS9ytjMTzP0ClvOpc5roVggWomBedix2F23G3whAIqInGdy72NgwVMLcC1E2jw8WOw6FMv7DEDNRPI4dFDIOA+mtc52InYU8djUWwALXWqHCxN+GeJhqLk/WB2mtMuNhxOJD0kzhqEmiERsy52JtwJI6bBBql1uHCxC3QXLUNlzhYxmJ+BWikWs65ECxA89UuXOaWmgkWoMFqNSyyk7AXLzWzIgQ0XG06F4IF8EstwoVgAfxTebjYUbitiUYiWACvVBourAoB/qosXAgWwG/VdS7TnbcEC+CpSsIlflaILf2Ax0oPl/jpZh5CBDxX6iY6exPuSfp3mfcEUI3SOhc7CXuSRmXdD0C1SgkX9rIA7VNO58LKENA6hYeLHYdDsTIEtE6h4WJvw1BOz4q8B4B6Kixc7CjclumgqOsDqLfiOpcOE7hAmxUSLvFGOeZZgBbLPVzsKOzKaZj3dQE0S/6dC8MhAMo5XOLh0Hd5XhNAM+UWLgyHAMzLr3PpaF8MhwDEcgmX+GlnVocAXFg7XOLNcvt5FAPAH+t3LoEGcrqXQy0APLJWuMSTuDw7BOCa9TqXDqtDAG6WOVzsbRiKs3ABLJC9czG6FgCLZQqXuGthJy6AhbJ1LnQtAFZIHS50LQCSSN+50LUASCBVuNC1AEgqXedC1wIgocThQtcCII3knYupX1wZAHyTKFzsKOyK3bgAUkjWuQR0LQDSSRYuToOC6wDgmZXhYm/Cvji+EkAaTqdJOpd+0XUA8MyqcIkncll+BpDa8s6FiVwAWZzr9aphUb+MOgD4xT0cf1wYLnYS9jh4G0AGh9KyYVFE1wIgA6fX0vJw2SutGAD+sCXhwpAIQGadZZ0LQyIA2XxyfxsvDReGRACyOJj9cS1c4rcoMiQCkMV49sf1ziWgawGQ0WRJ5yKnsMxaAHjjZ/dw/HH2j5vmXMLyagHgkdH8Py6FS3xOLscrAEjH9M7dHx/Mf3S5c4noWgBkMrr6weVwceqVVQkAb3xSpP2rH16dcwnLqQWAN0z78xO5MxfhYidhT8y3AEjnxq5Fmu9cJgyJAKS0oGuR5sOF+RYAaZjeLepapPlwMcIFQApOg0Vdi3R5QpeDuAEk9fPVfS1XBdLFKf8AkMQnTVYfyzLtXDbULbgYAP7oLxsOzUzDhZ25AJJw+nHVcGgmiP9Dt8h6AHjh0H0zTvze+NmEbreYWgB44liTdGc9bUiSTF25QgoC0Hyf1FHf3V89zzJvNiziWEsAN/mkjsLZodtpBCxDA1ggc7BIUsAyNIAbrBUs0rI3LgJoq2NN1FsnWCQpYI8LgDmHmih0D8en615oI4diAPjA9IN7MB7mdTnCBWg70zsF6rv743Gelw04xwVoMacfFannvsk3WKRp57Kd90UB1N6xnAZFhMoMwyKgTUzv5DR098ejom9FuABtMAuVB8WHygzhAvjtUNKozFCZIVwA30xXfw50rv089qtkRbgAfjiU6UAbGq+7szYvhAvQJNO5k1M5vVak0zqFyVUb+uv5XxRVXQaApb6KDiVJzp0qcqcK3Gu5yan7+lUtg0WSnL3fHUuO14oAjWWHsmCsTnRQp7AhXACvuE9ydiAFB+7O80QHaRdWCeECeOudzI2k85Hb+fW07Jtzngvgr3ty9kyu85v9/nhkH3ZLfY6QcAHawPRUkTuy948P7OxRt4xbEi5AuzyR6/xm73f37Y+w0IeWCRegldy/9Pn81H7/e6p3EaURyIJxURcHUGd2Sxb9ZO8fHxTRxdC5AHiiz+endvZtmOdFCRcAkuyWnP1iZ98O87pioMDVZkcfgIo5e2a/Px7lcalAkaV6/ysAz5me2vvHr9edhwnUiQgXAFc90Oc/x+sEjJMke//Y8qsJgEeOtbUZutvj1E1IPKHrPuVdEQAvZO5g4nCJmNQFsMgDff5zlPY/TcPFudOciwHglydpV5Gm4RIRLgBWMD21s91+0p9Pw4W9LgCScMF+0qMb4mHR5LTIegD4wm4pcqMkvwwkqU7nbgKovQdJHhOYf7bouLhaAHjF2bNVw6Mv4eJE9wIguUj7y77+Ei5GuABIw323bPVoLlxYMQKQknPDRV9dhIvbeTEuoxYAXrm3qHu5cliUHZZQDACfLOheLocL5+kCSO/G7uXqMZfjUkoB4BfnBtc+uvoBZ7sAyCSwh/Mbcm84oJt5FwAZ2OXu5Xq4MO8CIAtzl16wdj1cOtFBacUA8Ijdmn+D47VwicdM70qtCYAfbBLO/rz5pWiOVSMAWbjFnUv8MUMjAFncs7NHXWlBuLg7zw94IwCATILNnrTsXdHO6F4ApBfZinBhaAQgCxeF0pJwYWgEIJtgVecihkYAMrBb0g3PFl36yYfdniJ3VE5BALxh7h9LOxc21AHIavmwaGrpIbwAcE3gtleHy9bmqPBCAPglst7KcHG3xx/l9J8y6gHgjyTDIskZQyMAqSQKl+nELodIAUguWeciSaZRcWUA8E3icHE7r0ZiWRpAQsk7F0kyGxZTBgDfpAoXuhcASaXrXCS6FwCJpA4XuhcASaTvXCS6FwArZQoXuhcAq2TrXCS6FwBLZQ6XaffCrl0AN8veuUiSBcN8ygDgm7XCxe28GPPENICbrNe5SFI0GXKQN4Cr1g4Xt/PrqYzT6gBctn7nIsntvBhKOs7jWgD8kEu4SJLMDXK7FoDGyy1c3M6LsWQ/5nU9AM2WX+ciSVtfDcXOXQDKOVzc7fFHmevneU0AzZRv5yKGRwCmcg8XSXJ3Xw3E6hHQaoWEy/TK1mdzHdBehYVL/DqSYVHXB1BvxXUuktzdl/uSfi7yHgDqqdBwkSRtbfbF/AvQOoWHi7s9/sj8C9A+xXcuiudfHPtfgDYpJVwkyd15fiBzP5R1PwDVKi1cpPjpaQ6XAlqh1HCRJH21yQY7oAVKDxd3e/xRW5uhCBjAa+V3LmIFCWiDSsJFileQgigkYAA/VRYuEgED+KzScJHigDHtVV0HgHxVHi5SfAaM2T+rrgNAfmoRLlL8elgCBvBGbcJFigPGBd8zBwM0X63CRYofE2CSF2i82oWLxCoS4INahos0HzC8qgRootqGixQHzNZmTzwqADROrcNFuvQsEsdlAg1S+3CRpgHj7r7c431IQHM0Ilxm3N1Xg+leGCZ6gbprVLhI8V4YJnqB2mtcuEiXJnqZhwFqqpHhIs3Nw3AuL1BLjQ2XGbfzYqjAHophElArjQ8XaW6YxOHfQG14ES5SPEy687LPg49APXgTLjPuzvMDbW10xWQvUCnvwkWam+x1wfdiLgaohJfhMjPtYjZ77OwFyud1uEizLubVYLqiZIdV1wO0hffhMuO+fvXa3X0VMlQCytGacJlxd54fuLsvu9PNd6wqAUVpXbjMuJ0XQ21tEDJAQVzVBdSB/RFu67/nAzkNJLtVdT1A45n7gXCZQ8gAOSFcbmZ/hNv6/Gdf0kDSvYrLAZqHcFnNznb7cupL7ruqawEag3BJzj7s9mRuIHN7DJmAFQiX9KbzMv/bo5sBliBc1mNnj7pynT1JfUkPKi4HqA/CJT8EDTCHcCnGl6GT25NcyBwNWodwKYd92O0pcqGkkLBBKxAu1bAPuz1N1FPgQpl6YhgF3xAu9WFn34ZS1JWCrlwUSm5bhA6ainCpPzt71JU2uhfBI2kaPjNBj2EWaodX/gAoyv8B/fx1QfZHV1MAAAAASUVORK5CYII=","e":1},{"id":"image_8","w":303,"h":306,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAS8AAAEyCAYAAACrlladAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAWZ0lEQVR4nO3dzW1bSZfG8aeMBmaAWciL2ZsdgfVGYDoCayIwHYHlCMyOoNURNBXBK0XQVAQtZkDtBxhxMcAMMMCZxS3atExKrFNV94P3/wOMtt28l0VIfnSqbn0E4eSY2WtJ5/GPk/hLknb//pB3dVoFPGsj6T7+finpJoRwf/jlUqjdItRhZhM1oTTV91CaSHrTVZuAwu4kzUMIy33/k/AaADObqgmnbUBRHWFM/gghXD79S8KrZ2JFNVUTVFNJbztsDtAXK0nTEMLj9i8Ir47F8akLNUE1Fd0+4JC7EMJ0+wfCqwNmdq4msC5EZQWk+C2EMJcIr9bEwJqpCSyqK8BnI+k8hLAmvCoisIAq/gghXBJehcUB9wtJlyKwgBo2IYTXhFchZnahpsr60HFTgDH4xy9dt2DI4pPCSzWhRZUFtOec8HKIXcO5pI/dtgQYrQnhlSDOdJ9reDPcV5K2k/vW8dchy8ptAfa5UuK0IcLrCAMIrZWaQLpXE1L3kh5fWtgK9IWZPb78qh8RXs/oYWhtV94v43/XBBTGivDao0ehtdL3sFqGENadtgboEcJrRw8G4h8Ug0qEFfAswks/THn42sHbryTd6IjN1wB8N/rwMrOZmicdZy2+7UrSQk1grVt8X+BkjDa8YhdxofbGtR7UhCSBBRQwyvAys7na6SJu1HQJr+gSAmWNKrziU8TkyXAOD2oG/m92d34EUM5owsvMriR9rvw2t2qqrGXl9wFG7+TDK+6ptVDdautazSkn64rvAWDHq64bUJOZXUr6W/WC61rSryGEGcEFtOskK684b+tG9Z4kUmkBHTu58IqD8jeqM2+L0AJ64qS6jXEKxF8qH1x3kv5B9xDoj5OovGI3caHyWzA/SJrx9BDon8FXXvFp4lJlg2uj5ny4CcEF9NOgK69K41t3aqqtdcF7AihssJVXXFBdcnxrI+k/QghTggvov0FWXma2UNk9t27VVFss5QEGYnDhVTi4NmpC66bQ/QC0ZDDhFZ8oLlVutvydpAuqLWCYBjHmVSG4fotjWwQXMFC9r7wKB9eDmmqLvbWAget15VU4uO4knRNcwGnobXjFyadrlQmuP+gmAqell93GneU+uXO4NpIuQwiL3DYB6JfehVfBruJG0pRuInCaetVtLBhcK0kTggs4Xb0Jr4LBdaem4mJ8CzhhvQkvlQmuawbmgXHoRXjFJT8lgmuW3xoAQ9B5eBVaq0hwASPTaXjFbW1yg+sTwQWMT2fhFTcS/DPzNp+YwwWMUyfhFWfP525DQ3ABI9Z6eBWaPf8HwQWMWxeV10J5TxavQwiXhdoCYKBaDa94rmLOKT88VQQgqcXwigP0XzNucUdwAdhqJbziOFfOAP1K0kWh5gA4AW1VXjlnK253h2DJD4BvqoeXmV1Keue8nOACsFfV8IrzuX7PuMUl29oA2Kd25bXIuJa5XAAOqhZeZnYl/3yuO+ZyAXhOlfCK0yI+Oy9/EE8WAbygVuV1lXEtp1gDeFHx8Iqz6L3dxS8M0AM4RtHwMrOJ/LPo70IIORUbgBEpXXktnNdtxDgXgATFwivuiuqdjDpjnAtAiiLhFdcuzp2X34YQcjcmBDAypSqvS0lvHNdtJM0KtQHAiGSHV+YgPd1FAC4lKq+587o7uosAvLLCK86k9xxdRncRQJbcymvuvO4qhLDOfG8AI+YOr1h1eaZGPIQQ5t73BQApr/KaO6+bZbwnAEhyhldG1XUXQlh63hMAdnkrr7nzOvboAlBEcnhlVF3X7BgBoBRP5TV3vpf3OgD4SVJ4xdn03qpr7bgOAPZKrbzmzvfxXgcAex0dXnHnCM9seqouAMWlVF7eJ4Vz53UAcFBKeM0c96fqAlDFUeFlZhfy7dc1d1wDAC86tvKaOe59S9UFoJYXwytOj/jguDcnAQGo5pjKy3OqzwNrGAHUdEx4eZ4yzh3XAMDRng0vMztX+kD9RhLbOwNIcZ74+seXKq+ZoxE3HKoBINFZ4uvvXwovz3gXA/UAqjsYXs4u4wPb3gBIEbfZSvVst3HmuCFVF4BUr1MvCCE82230dBkZqAeQKnWwfiMd6DY6u4wrZtQDcJgmvv5eOjzm5am6Fo5rACC18lpLZcOLLiOAJHH5YfI0CWlPeMVNB98m3owuIwCPqeOag91GuowA2pKcN9t10/vCa+powNJxDQBME19/t/1NifBiYiqAZHGT09TxruX2Nz+EVxw8S50isXzxFQDwM88Q1XL7m6eV1zTnZgBwDOdpZJvdfQKfhlfqfAuJKRIA0s0c1xzOGjO7tzSMdQFIZmbrxKyxOEZ28IapFu19XACnwMxmjqz5aY/AVzs3nDrascz4DADGae645qcu4+6Yl2e8a+m4BsBImdlMvjNgD2+3ZWaL3DIOAJ5jvrGuvWPru5XXJLEdDNYDOJqZzVW66oo3TjV3NALACJnZxMweHTmzPnTPV9sbO9pD5QXgWAulLwXaXrfXtts4cdx07bgGwMiY2aWkd45LNzqiyzhPreU8HwLAuJjZubO7aNY8mTxoW3mlnt6xcn0SAKNhzfrFhXzdxYcQwuK5F2zDy7WHNAA8Y6H0XZm3Zi+9wDvmxWA9gIPM7ErSB+flt7u7R7z0RqkunY0CcOLMt3Zx69GOnf1gZq8dbzCt+ukBDFJmcJmlFEZmNnW8gWcdJIATViC4lqlvmBxedT46gKEqEFyP1jydPNovckxQJcCA3nvQ91kB95Ie43/XpQ/MsWZfv9QtnZ+ahRCSNntwhReA3nuj74ugf5jdHmuPlZotre4lLT2HRtv3eVzep4pbv4UQkreT/yXzTQEM01vtzMEyswc1G/7dHDNNwZpx7xv5donYdRtCmHsufKX02fUATs8bSZ8l/RXHnxZ24MGcNTvK/K384FrJdxCHJCnEEX7PokkAp+9BzeLohZohpoX8s+Z3rSRNU8e5dhFeAI7xv5L+pdC9NmqCK+vBwdNzGwFgn14Fl9SEF4P2ANpQLLikJrz+vcSNAOAZK0nnJeeY/SKeNgKoK3twfp9XKteXBYCnrlUhuKSm8vrX0jcFAElfQgjP70Gf4ZWk/6l1cwCj9d9qlh9Vw1QJADX8m6S/7YVDNHK8kvR/tW4OYPT+jLtOFPdKzdwLAKjlY1wrWXRmwytJ/1nyhgCwx0dJy5IBxoA9gLa8VcEAY8AeQJuKBRjhBeAl15LeS/qiMmPkb9VsZJjllTj9GsDPVmrC6tcQwiyEsIwTTs8l3RW4/7vcp5C/iPAC0Fjp+1bQexdQx73up9acr/h75vt9NLO1dxtotsMBxmuleACHmsA6ev1hCOEqbmSau4/9VzO79x7AUXzBJIBeWWnn6DNJ98ccsvGSEMJ93Od+qbytoRdmlrzPVzCzqaS/Et/sfeLrAbRr7TnOzCM+ObxS3tmNqxDC3gM/nnvj5BOzzWyS0UgAJyjOos+RtgOFmU0cbzKt8/EBDFmBAJumvmGqWZVPDmDwMgNsbUdOYN1OUn1IbN8k8fUARiKEMJN/LtgbSfNjXrgNr3XiG6QNrAEYmws1Tzk9PtuB07p3ecOLQzsAHBTnjF3Iv5zoxcF7b3hxwjaAZ8WpGjPn5e+OGrw3swvHwNrE2SgAI2JmV87B+2cnrXorL4lBewDHmSv9oaAkvbVnZja8kppp/o4bTx3XABiZOP41c15+eeh/7O7nlfpkgCeOAI4S11JeOy59e2jsaze81ok3JbwApLiU7+nj3uprN7xSu45vjp0JCwCx++g5QfvDvgeEu+G1dNx06rgGwHhdyVd9zZ7+RU7lJRFeABJkVF+zZ/+vmd2XnIcBAE+Z2Wsze3TM+/phnP3p6UGpYfSWcS8AKWL1tXBcOtv9w9PwWjpuOHVcA2DcPF3Hi90/5FZeEuEFIFFc95i6bc6b3a7jD+EVZ9qnPgmYJr4eACRf13F68P+YbxfEibPxAEYqDtynWm6vf9ptlHzjXhcvvwQAvosD96ldx/3dxmjpaMfUcQ0ApB42e7Yd9/opvOJAWur2FR+YMgHAYem4Zn94RclHb4uuI4BEzu24CC8AveAa99obXnHvndQpE3QdAXikVl8T6XDlJVF9AWhH8nZcUvnwmjmuATBu69QLzGwSXnjBo6SzxPv+Gp9YAsCL4nDTfyVe9v65ykui+gJQWZysmozwAjBEk2fDK4Rwo/Snjm/MjIF7AClSp0s8H17RwtGQg2etAUAJtcLrHTtNAKjpxfCK0/dTD6SVmiO+AaCKYyovybllKzPuAdRybHh5Bu7PxNgXgEqOCq84D8MzbeKS6gtADcdWXpJvDIvqC0AVR4eX87QPieoLQAUplZdE9QWgJ5LCK+7z5Zk2QfUFoKjUykvyTZug+gJQVHJ4hRAWSj+gQ6L6AlCQp/KS/GNfnqoNAH7iCq+M6uvj9sw1AMjhrbwk/9pFqi8A2dzhlVF9vWO/LwC5ciovKaP6YvAeQI6s8IrVl2fW/RuxZQ6ADLmVl+QPoc8M3gPwyg6vOOv+1nn5Ivf9AYxTicpL8s+ef2tm80JtADAiRcIr7jjxm/Pyr3QfAaQqVXlJzfwtz9QJSVrw9BFAimLhFXdbdXcfxdNHAAlKVl7bQ2q9g/efmbwK4FhFwyuaKf2wji26jwCOUjy8Yvdx7rz8TNKyWGMAnKwalZdCCFfyzbyXmukTLN4G8Kwq4RXN5O8+fjazWbmmADg11cIrzv2aZdziivlfAA6pWXnlPn08k3TDAD6AfaqGVzSTf/LqGzGAD2CP6uEVnz7mzN96a2aLQs0BcCLaqLwUQriXf+2j1Ox9zxNIAN+0El6SFEKYyz/+JfEEEsCO1sIrmsk//iVJfxJgAKSWw2tn/Ms7/0siwACo/cprO/7l3X1i608zmxZoDoCBaj28pG8Hd+QM4EvNHDAmsQIj1Ul4Sd8G8K8zbnEmaUmAAePUWXhFl5JWGdefSfqbMTBgfDoNrziAP1VegEkM4gOj03XltQ2wmfKeQEoEGDAqnYeX9O0J5FRlAmye3SAAvdeL8JKKBthX1kICp6834SV9C7ASh3B8NLMl2+kAp6tX4SVJIYSlpE8FbvVOTKUATlbvwkv6Nom1RIC9VRNgHKkGnJhehpdUNMDOJP2TgXzgtPQ2vKRvAfZe+YP4UjOQzzgYcCJ6HV7StzGwqcoE2DtJ9yzqBoav9+ElFZ1GITX74v9FNxIYtkGEl/RDgOUuJdr6amb3ZjYpdD8ALRpMeElVAuytmm5k7v5iAFo2qPCSiu3GuutM0u9xMH9S6J4AKhtceMWnhTdqQqekpaTHwvcEUMkvXTcgRZwtf6Nm0L2UW0mXIYR1wXsCqGww4RWDa6lyFdeDpFmcigFgYAbRbawQXL+FECYEFzBcva+8CgfXSk21dV/gXgA61OvKq3Bw/RFCOCe4gNPQ28qrYHBt1FRbN9mNAtAbvQyvOB1iofzgWkm64EkicHp6122MwbVUM/s9x7WkKcEFnKY+Vl5Xyg+uP0IILPkBTlivwiuuMfyYeZtPcR8wACesN93GuMfW75m3IbiAkehFeO2sV8xBcAEj0ovwUv5Ca4ILGJnOwyvuaPou4xYEFzBCnYZXnIj6NeMWBBcwUl1XXouMa68JLmC8Oguv2F30zue6CyHMyrUGwNB0Ms8rs7u4UrMNNDoQt8qeFL7tfdzeGx2o9DVNlXyealeTVK+c120XWfON3p2Z8sYp93mvZkkYuvFa0l9dNyJV691GM5vJ/3Txki1tgLLiv6kvXbcj0WOr4RUno86dl98yQA/UEUK4UnOew1Dct115Xcp3eMaDmu4KgHpmKnekYFUhhGVr4RWrLu9OD4xzAZXFf2OzrttxhGup3TGvS/mWAN1yUAbQjrjjcN+7jwuppfDKqLo2GsZPAuCUXKq/3cfrbTHTVuXlrbrmdBeBdsXdh73TmWp60E4RVD28Mqquh/gEBEDLQghzNWHRFxs151F8K2baqLwu5Ku6ZoXbASBNX7ZSX0n66djCNsJr7rjmjkF6oFtx8P6uwyZs1Jxuf77vIJ2qy4Pi1s6eeV3zsi0B4DRX+0uH7tRsULp4bsy79trGmeMaqi6gJ0IISzO7U/qSvuvaO79U6zbGgXrPSUDzwk0BkGfhuOYiZkA1Nce8PNvWPFB1Af0S1xSnPnk8U+Wtq2qGl+dJxbx0IwAU4Zm2NLzwipubpe6SulH+8WcA6lg4rvlQs+tYq/KaOq65YTY90E/x3+a149Jq1Vet8PI0eFG6EQCK8vSMpqUbsdWXyouBeqDn4qTV1AXb0wpNkVQhvGIfN3U5EGNdwDCk/lt9U2vcq0blde64hvAChsHzb9WTCS+qEV6T1AvoMgKDsey6AVt9CK8+bbuBbky6bgCOE5869mLcq7MTs3esu24AOjfpugFI0ovjB/sQXgCQrA/hVXXxJoqbVrhnlQFdVDPpugFSnfBKnSWfuowI3aoRNNMK90Q9qXv0Velm1giv5IaaGT95ByB+nTxber/kjO+BYYgbjKaqsuyvD5WXxE/eoai5p/ms4r1Rjmfp37p0IyQp1LipmVniJQ8hhEmFpqCQOEt6rTqVl9Q8fp+wOL/fzGyttG7jJoQwmBn2Uvqm/W+c5Sja4z1781hn6s9pNdjDzGZKH+9alm9JRWZ2ael6MXcEPzOziZk9Or6mqR6t2QsOPWRma8fXdFg/kMzs3PnNO6wPOhJmtnR+PT2WXX9e/MzM5s6v56TrticzX0qb8dSpV8zsyvl1vIm/POZdf258Z/5iZJi9KfN1Hc2argMB1gNmNnN+Dc3MpvGX16zrz49vweUdMph13X4XM3ud8aEJsI6Zv+Iy2+n6mb8CN6MC65TlBdejVT76rCrz95O3H37W9WcYG2t+6OSOcU137pdTfZk1Xc/h/iMYKGt6TjkPaeZdf4Yslld9bS1tiIN+A1TgG9Zsz4C75Yfho/EwpxXWPFnO/Xqt7RR+4FjeuMmuhZlVPQdujKz5Zp1bXvdua+9UBys31WJtTVt/eg/kMbML8z9geaqVf6dVZtg/Zc1P43eFbrdRM/HtPv5iRna6qZqdAc5VdmH8lxDC3sNJramcfi/4Xis1X/+1hjYRsh8m+v49MFW5Cci3IYSTCq/aS0vQvesQwuy5F5jZjaQP7TQHHWh1iVcr+3nFD0OX73StdNzSnll8LU7TtM21qa1tRhgP2fjU1vuhNSsd+U0bXzNV+h7o6L9PIYRWJ6W2upNqCGEhAuyUHB1cWwTYSfoU/223qpUxr6esmb/1ZxfvjWKSg2tXHAddip10h66T4JI62sM+ftj34qfvUN0qc3xjpwK7LdUotGoj6X1XwSV1eABHHAM7FwO4Q/MlhHBRYmA2hPAYH6t/KdAutOdO0nnXh0V3enpQCGEdQjiX9JuowvruTtKvh+Zx5Yj3/IfSN7FEuzZqfnhNQwjrrhvTyZjXPnHW9FzSx25bgiceJF2GEG7aeLM4HjpX+o6dqOtazfdBbyaF9ya8tnZC7EJMau3SnaRFV2MaMcQuxYB+lzaSFpKu+lBpPdW78NqKT6Mu4i9mZbdjpeYJYG++Wa3ZGmmmZnCfIKtvu/zuRtJNnyqtp3obXk9Zs83Kefw1ib/oWvhs9P18zWX8/bLP36jSt6p8+z0wjX9d6yzJMXhQs2xvrbhWuOtBeAA4ef8PvoSuxvw6YJwAAAAASUVORK5CYII=","e":1},{"id":"image_9","w":48,"h":48,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAAC+ElEQVRoge2ZQXLaMBSGP4lMV8GOd110pugEoScIR8gNSk9QNt3TRff0BvQGdNnpos4N4AQKJzAmrNpY6iJAbIqNjbFxZvqtsGTJ/48lPz0JXjiiTOMg0J0L6BhJb1MmDf4j3Hueui8rLg+FDQSBvpKSvoAB8Daj55m1jI1h7HlqUUZkFoUMhKEeCBgCbpFmCPqOoyaFlOUkt4FlqMfA+xLP+ua4ql+i/V5yGVgutI/g5gTPS5h4CHQvJmRx6alp0Q4PGngI9cjCx6IdZzzwhxW8xnK99wbLHZJxFDHJM3cyDTwEumclv47UWpbQwtB11SjrpkwDJxw6x2O5iyy3aW8j1cCZ//0kglkU0dtnQqY2ktxWKqoIlutWC39fVaoBK56jayOwXIehHu4Wpw6hZahtpYKOJDKo+DIlfQg1lAvJIH794gxY6MevX5wBwF0Furu5yDIwr0HMUZgWuQz41Us5DmvpbH6nf0YRP2tRU5K9BlaB7grsl7rFHMPFbsEq0F0j8SmWtNSKNM/DO/EGgkBfGcmEBosHeIRt3pAw0JKMyMpzm4BgFl/UbQ0Ege5QLmWsi0R+sDXQkgxrl1KcueOocbwgPoSas3xOQZjkMgLWBtahudET18Lntqf83XIJydDcRAR8dV013FcnIRmaG4ewn9quGqRVN341Kqx4k1W/MVDZ3mVZrM0e3hJAGgrviNXGgW2dbU7c1BwYwHFVau4enwPfa9BycrYGhCFzC6+pbA20PeVjuTunmBTCrMrEZ1RaBocanAE/qzJh4NJTUwSpQeMsCDJPdv4JZI6jxgg+VKeoEGEUFTQATyak4R2CWTW68mFhdOiQ4+AJzXKp+1iGHM7UfgOv8ss7yDwydEsb2LAKdDeS9CR0NuFdCKYGFi3D5A8sWpIpp1mWh9LQy3NmVuqge5cT7WjkFg8nXo1eemoaGbpHxxPBrIj4pyYVUWDuAMwRDHfz3TxUZmDDeu7cCrs+8RHcIJhhWAjB1Ar8qk7x/1MHfwFd8vHuJgEm8gAAAABJRU5ErkJggg==","e":1},{"id":"image_10","w":45,"h":42,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAqCAYAAAAnH9IiAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAACrUlEQVRYhd2ZS3LTQBBAX7eMWBJ2QFKFOQG+AeYE5AY4J8AbkiyVXbA34gbODZITxLkB3ECpwq4szVaJp1lYJsIfWfJPDm+n0Xj03DXfHniCyDY+Yqevqs7JoaGHgn2YUeUnIl11dKTd+7GovY1Kj2Q1AD7n/g1yYyrBs/Nf3Xl1Nib98HW/IWIh8GKpBkS+e996zZmvVhGbx/BkP8Tsy6rtiHEl935DwmiQLtdVG55kePy6uQ5hABM+OT8OJ8tzRXo8kMSkbkIVeJ+8ujUkEqyrJpcPnuypc9frEJ7gzGv1g/FDpvT96UFdnAVzRvxWUXXv5PwugozuMTzZD9W5610QBkhmIWBGpK1Z3XN+3OWxC+wMGvsvJYwG/0R6l4UBhn5ch4nukYzUnRQGEJE6pKTvTw/qFFi5ysCMGqSkxVlQmk1BFJKpbUdmiTwogJodli1ShFH3MKuXq5EPwSJ47NM7O2OkMZMubGDDtEk8b9iFJyQtxtXCvceuMfT07xZ1LP27JJdcGHKTPn5pUrjwMFkmng4b6WcFENxlKTb5uBj35TGjxSV+3inDJg+qLpgqA0gOjhfbFsrBVJQhNXto7DfZsQE5K8qQ3uWF0cCgsS2hHJzNijJMzNOVVv/STI62opTNb439qdTBmKnFpdLudRLxMrtKOJmgSTNzRay0ex1VVzPkZnNec8mMMmQs43J+F1VavbpT/bhl+cwoQ4Fc3sPxm0OBEHi7stZ8bjX2a2uTHpNkQwM2IG8mR5V2r7Oo3tJZ0w3I33qtfjVPxaW3ppV2r6OxXwPOWMNMYyZB3rpryU8nmakm0GS5JHruKMOaDgESRgOv1Q809quMIl8IG/3Z/N8r+oFcEsXuWi68Vr9RpP1yL4oy7lWy2OKVnDbEqJkwACJV15m3Ifov+QPBfQVY6Bmh+wAAAABJRU5ErkJggg==","e":1},{"id":"image_11","w":45,"h":45,"u":"","p":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAC0AAAAtCAYAAAA6GuKaAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAJHpUWHRDcmVhdG9yAAAImXNMyU9KVXBMK0ktUnBNS0tNLikGAEF6Bs5qehXFAAACvUlEQVRYheWZT27aQBTGvzc2a7gBiRRFyqo5Aj1BfYPSPZW8TbKhyr9lLTX70hPU3MC5QVlFgkjADcraxi8Lj9skYDP2vABVvyXzZuZnM/O+eWPgHxRJDuZP+60kdgMi+qh/mjORf3d0HkrOoyQHWyZu9AwYANrE/LM3uexIziMGnYHRu3VtBOVLzQMIQitSXknzB3/ab4nNJTUQM8qgsYwbpe1VJALtj69PAbRLg6j8oapIBDpV6BqEdSTmAoSgmbljENbsPd6IvG1raP/h9qAoa6woTfcDeukujUGIaD+gAaP1nKupN62VrKCz3Gu4NLQMN22prKDr5N5N+dxEtaH9ab8FQr9G1/bnyZWVrVc+5fnTfmsZNzwNXG4oJWLmHy4oCI4vflXtawTtP9we6CzRrbqGDTRn5ghKhaZH2EJof3x9mip0M+MQBy3SgplDAkVOIw6Dw/7vdUEvoHuTy44i5enNUvuvF9QQjPD1AxCg12niRlt8o1W1AMP/dnwxAHT2SGI32GNgAGiC8D07MmjoVyXS3ip10y4gXCNuSzn0fKcUhmLGDNDQTCRaeL6NePRiI94dnYeM9D2A4S6xCjQn4IvjJp38hxVzeWbTHrISqbk9vlw8IlCoGOE6m99o473HGw9p6ukD/Fs+wD3AoZM4YXByNisLrHRg+mvtYo651vE2qfZdXna8pK/1evPISRxv0xstktUFZG98NahjTE6iDusCA5bm4oKCGt2GNsCAJbTe2dWMiWF97Wtt48wcVYl3GvHuoaFUFYj7KlmicErbAXSJtDCLZpEvAlJ3eUYwTuLsDzSBos1RPLLNGrlEoE02F4HEPhaJQOvNVXpCVAKp7s9YUgNtyL/zOpcyRRKDdpYqKmojYCA1DyAIHZyczcD4tNrCI+XGdey+UKJfbIHsCi2vmpkxy0uk/15PMQr7S45jg5AAAAAASUVORK5CYII=","e":1}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"ì
","refId":"image_0","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[0]},{"t":35,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[236.741,338.297,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[236.741,344.297,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[236.741,338.297,0]}],"ix":2},"a":{"a":0,"k":[21.062,18.237,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[150,150,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":2,"nm":"ëì¹_ì¤ 3","refId":"image_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[-20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[20]},{"t":25,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[267.622,250.328,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[267.622,281.328,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[267.622,250.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[267.622,178.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[267.622,105.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[267.622,281.328,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[267.622,250.328,0]}],"ix":2},"a":{"a":0,"k":[15.349,10.464,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":2,"nm":"ëì¹_ì¼ 3","refId":"image_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[-20]},{"t":25,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[205.934,250.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[205.934,281.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[205.934,250.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[205.934,178.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[205.934,105.254,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[205.934,281.328,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[205.934,250.328,0]}],"ix":2},"a":{"a":0,"k":[15.349,10.464,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":2,"nm":"ëëì_ì¼","parent":7,"refId":"image_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[37.549,33.498,0],"ix":2},"a":{"a":0,"k":[18.589,20.143,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":2,"nm":"ëëì_ì¤","parent":6,"refId":"image_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[23.446,33.498,0],"ix":2},"a":{"a":0,"k":[18.589,20.143,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":2,"nm":"ëì_ì¤","refId":"image_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[267.623,304.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[267.623,330.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[267.623,304.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[267.623,232.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[267.623,159.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[267.623,335.505,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[267.623,304.505,0]}],"ix":2},"a":{"a":0,"k":[30.814,30.09,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":2,"nm":"ëì_ì¼","refId":"image_6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[205.86,304.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[205.86,330.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[205.86,304.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[205.86,232.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[205.86,159.505,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[205.86,335.505,0],"to":[0,0,0],"ti":[0,0,0]},{"t":30,"s":[205.86,304.505,0]}],"ix":2},"a":{"a":0,"k":[30.814,30.09,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":35,"s":[110,110,100]},{"t":40,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":2,"nm":"몸íµ","refId":"image_7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":0,"s":[235.741,371.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":5,"s":[235.741,371.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[235.741,371.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[235.741,299.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[235.741,226.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":25,"s":[235.741,381.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[235.741,371.241,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[235.741,381.241,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[235.741,371.241,0]}],"ix":2},"a":{"a":0,"k":[138.302,240.122,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":5,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"ë°_ì¼ ì¤ê³½ì ","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":0,"s":[172.857,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[172.857,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[172.857,339.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[172.857,266.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":25,"s":[172.857,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[172.857,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[166.857,425.003,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[172.857,411.003,0]}],"ix":2},"a":{"a":0,"k":[57.555,67.225,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":5,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[{"i":[[11.558,-8.102],[-0.774,-0.613],[-26.761,0],[-3.07,0.249],[0,0.777],[0,0],[0.823,0],[0,0]],"o":[[-0.808,0.567],[19.474,15.407],[3.134,0],[0.774,-0.062],[0,0],[0,-0.823],[0,0],[-15.363,0]],"v":[[-40.309,-7.053],[-40.372,-4.67],[30.472,19.939],[39.778,19.557],[41.146,18.064],[41.146,-18.448],[39.655,-19.939],[0.815,-19.939]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[7.731,-8.289],[-0.774,-0.613],[-26.761,0],[-3.07,0.249],[0,0.777],[0,0],[0.823,0],[0,0]],"o":[[-1.371,1.47],[19.474,15.407],[3.134,0],[0.774,-0.062],[0,0],[0,-0.823],[0,0],[-15.363,0]],"v":[[-33.372,-14.553],[-39.872,-0.482],[30.472,19.939],[35.653,19.579],[36.709,18.093],[41.146,-18.448],[39.655,-19.939],[-3.56,-23.689]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[{"i":[[7.731,-8.289],[-0.774,-0.613],[-26.761,0],[-3.07,0.249],[0,0.777],[0,0],[0.823,0],[0,0]],"o":[[-1.371,1.47],[19.474,15.407],[3.134,0],[0.774,-0.062],[0,0],[0,-0.823],[0,0],[-15.363,0]],"v":[[-33.372,-14.553],[-39.872,-0.482],[30.472,19.939],[35.653,19.579],[36.709,18.093],[41.146,-18.448],[39.655,-19.939],[-3.56,-23.689]],"c":true}]},{"t":25,"s":[{"i":[[11.558,-8.102],[-0.774,-0.613],[-26.761,0],[-3.07,0.249],[0,0.777],[0,0],[0.823,0],[0,0]],"o":[[-0.808,0.567],[19.474,15.407],[3.134,0],[0.774,-0.062],[0,0],[0,-0.823],[0,0],[-15.363,0]],"v":[[-40.309,-7.053],[-40.372,-4.67],[30.472,19.939],[39.778,19.557],[41.146,18.064],[41.146,-18.448],[39.655,-19.939],[0.815,-19.939]],"c":true}]}],"ix":2},"nm":"í¨ì¤ 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.176470588235,0.149019607843,0.078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"ì¹ 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[69.713,20.189],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"ë³í"}],"nm":"그룹 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[{"i":[[0,0],[8.921,-27.609],[-10.922,0],[0,0],[0,9.701],[0,0]],"o":[[-31.466,0],[-3.26,10.089],[0,0],[9.702,0],[0,0],[0,0]],"v":[[14.974,-33.975],[-52.045,13.67],[-36.25,33.975],[37.739,33.975],[55.305,16.41],[55.305,-33.975]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[0,0],[-0.932,-39.573],[-10.922,0],[0,0],[0,9.701],[0,0]],"o":[[-31.466,0],[0.438,18.577],[0,0],[39.905,-3.103],[0,0],[0,0]],"v":[[6.474,-37.975],[-28.17,22.295],[2.751,57.475],[6.363,57.6],[51.805,-6.84],[55.305,-33.975]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[{"i":[[0,0],[-0.932,-39.573],[-10.922,0],[0,0],[0,9.701],[0,0]],"o":[[-31.466,0],[0.438,18.577],[0,0],[39.905,-3.103],[0,0],[0,0]],"v":[[6.474,-37.975],[-28.17,22.295],[2.751,57.475],[6.363,57.6],[51.805,-6.84],[55.305,-33.975]],"c":true}]},{"t":25,"s":[{"i":[[0,0],[8.921,-27.609],[-10.922,0],[0,0],[0,9.701],[0,0]],"o":[[-31.466,0],[-3.26,10.089],[0,0],[9.702,0],[0,0],[0,0]],"v":[[14.974,-33.975],[-52.045,13.67],[-36.25,33.975],[37.739,33.975],[55.305,16.41],[55.305,-33.975]],"c":true}]}],"ix":2},"nm":"í¨ì¤ 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.239215701234,0.203921583587,0.105882360421,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"ì¹ 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[55.554,34.225],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"ë³í"}],"nm":"그룹 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"ë°_ì¤ ì¤ê³½ì ","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":0,"s":[301.992,412.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[301.992,412.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[301.992,340.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":20,"s":[301.992,267.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":25,"s":[301.992,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[301.992,411.003,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[308.992,427.003,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[301.992,411.003,0]}],"ix":2},"a":{"a":0,"k":[55.555,68.225,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":5,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":25,"s":[100,80,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[{"i":[[27.5,0],[0,0],[0,-0.858],[0,0],[-0.859,0],[0,0],[0.538,1.041]],"o":[[0,0],[-0.859,0],[0,0],[0,0.858],[0,0],[1.172,0],[-11.519,-22.288]],"v":[[-11.515,-19.939],[-50.292,-19.939],[-51.846,-18.385],[-51.846,18.386],[-50.292,19.939],[49.93,19.939],[51.308,17.655]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[27.5,0],[0,0],[0,-0.858],[0,0],[-0.859,0],[0,0],[0.538,1.041]],"o":[[0,0],[-0.859,0],[0,0],[0,0.858],[0,0],[0.162,-2.059],[-11.519,-22.288]],"v":[[-11.515,-19.939],[-50.292,-19.939],[-51.846,-18.385],[-48.346,10.136],[-46.542,19.905],[33.555,19.905],[30.621,5.337]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[{"i":[[27.5,0],[0,0],[0,-0.858],[0,0],[-0.859,0],[0,0],[0.538,1.041]],"o":[[0,0],[-0.859,0],[0,0],[0,0.858],[0,0],[0.162,-2.059],[-11.519,-22.288]],"v":[[-11.515,-19.939],[-50.292,-19.939],[-51.846,-18.385],[-48.346,10.136],[-46.542,19.905],[33.555,19.905],[30.621,5.337]],"c":true}]},{"t":25,"s":[{"i":[[27.5,0],[0,0],[0,-0.858],[0,0],[-0.859,0],[0,0],[0.538,1.041]],"o":[[0,0],[-0.859,0],[0,0],[0,0.858],[0,0],[1.172,0],[-11.519,-22.288]],"v":[[-11.515,-19.939],[-50.292,-19.939],[-51.846,-18.385],[-51.846,18.386],[-50.292,19.939],[49.93,19.939],[51.308,17.655]],"c":true}]}],"ix":2},"nm":"í¨ì¤ 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.176470588235,0.149019607843,0.078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"ì¹ 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[52.096,20.188],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"ë³í"}],"nm":"그룹 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":5,"s":[{"i":[[0,0],[-8.921,-27.609],[10.922,0],[18.497,0],[0,9.701],[0,0]],"o":[[31.466,0],[3.26,10.089],[-18.497,0],[-9.702,0],[0,0],[0,0]],"v":[[-14.973,-33.975],[52.044,13.67],[36.25,33.975],[-37.738,33.975],[-55.305,16.41],[-55.305,-33.975]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[{"i":[[0,0],[0.089,-29.014],[14.509,-10.228],[21.064,25.999],[0,9.701],[0,0]],"o":[[31.466,0],[-0.036,11.827],[-5.646,3.98],[-11.628,-14.353],[0,0],[0,0]],"v":[[-14.973,-33.975],[30.294,12.67],[11.25,52.725],[-34.113,42.1],[-52.055,-6.59],[-55.305,-33.975]],"c":true}]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":23,"s":[{"i":[[0,0],[0.089,-29.014],[14.509,-10.228],[21.064,25.999],[0,9.701],[0,0]],"o":[[31.466,0],[-0.036,11.827],[-5.646,3.98],[-11.628,-14.353],[0,0],[0,0]],"v":[[-14.973,-33.975],[30.294,12.67],[11.25,52.725],[-34.113,42.1],[-52.055,-6.59],[-55.305,-33.975]],"c":true}]},{"t":25,"s":[{"i":[[0,0],[-8.921,-27.609],[10.922,0],[18.497,0],[0,9.701],[0,0]],"o":[[31.466,0],[3.26,10.089],[-18.497,0],[-9.702,0],[0,0],[0,0]],"v":[[-14.973,-33.975],[52.044,13.67],[36.25,33.975],[-37.738,33.975],[-55.305,16.41],[-55.305,-33.975]],"c":true}]}],"ix":2},"nm":"í¨ì¤ 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.239215701234,0.203921583587,0.105882360421,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"ì¹ 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[55.554,34.225],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"ë³í"}],"nm":"그룹 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":2,"nm":"ì¤ê³½ì ","refId":"image_8","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":30,"s":[0]},{"t":35,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[236.741,271.5,0],"ix":2},"a":{"a":0,"k":[151.302,152.555,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":34,"s":[110,110,100]},{"t":39,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":2,"nm":"ê½","refId":"image_9","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[0]},{"t":43,"s":[100]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":38,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":43,"s":[20]},{"t":48,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[110.636,126.893,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[76.636,126.893,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":38,"s":[110.636,126.893,0],"to":[0,0,0],"ti":[0,0,0]},{"t":42,"s":[110.636,126.4,0]}],"ix":2},"a":{"a":0,"k":[23.509,23.716,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":2,"nm":"íí¸","refId":"image_10","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":41,"s":[0]},{"t":46,"s":[100]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":41,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":46,"s":[-20]},{"t":51,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[405.506,254.946,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[427.506,254.946,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[405.506,254.946,0]}],"ix":2},"a":{"a":0,"k":[22.285,20.803,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":2,"nm":"ë³","refId":"image_11","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":45,"s":[0]},{"t":50,"s":[100]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":45,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":50,"s":[20]},{"t":55,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":30,"s":[85.898,355.168,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":34,"s":[71.898,355.168,0],"to":[0,0,0],"ti":[0,0,0]},{"t":38,"s":[85.898,355.168,0]}],"ix":2},"a":{"a":0,"k":[22.234,22.234,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":55,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/feature/match/src/main/res/values/strings.xml b/feature/match/src/main/res/values/strings.xml
new file mode 100644
index 00000000..543d69e2
--- /dev/null
+++ b/feature/match/src/main/res/values/strings.xml
@@ -0,0 +1,28 @@
+
+
+
+ 찾았다, 내 소울메이트!
+ 기막힌 타이밍에 등장한 너!
+ 끈끈한 사이로 발전할 수 있어요!
+ 서로를 알아가 볼까요?
+ 펀치가 아니면 몰랐을 사이
+
+ 쿵짝 쿵짜작~이 잘 맞아요
+ 우리는 최강의 콤비!
+ 안정적인 관계인 우리
+ 서로 다른 점을 찾는 재미
+
+ 우리 사이 케미 측정 중
+
+
+ - 어느 팀이세요?
+ - 팀에서 어떤 서비스를 만드나요?
+ - 서로의 첫인상은?
+ - 가장 인상 깊었던 여행지는?
+ - 취미를 소개해주세요!
+
+
+ %s에서 만나요
+ %s님도 %s에 살고 있어요
+
+
diff --git a/feature/onboarding/.gitignore b/feature/onboarding/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/onboarding/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts
new file mode 100644
index 00000000..0c27b069
--- /dev/null
+++ b/feature/onboarding/build.gradle.kts
@@ -0,0 +1,15 @@
+plugins {
+ alias(libs.plugins.funch.feature)
+ alias(libs.plugins.funch.compose)
+}
+
+android {
+ namespace = "com.moya.funch.onboarding"
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.domain)
+
+ implementation(libs.compose.lottie)
+}
diff --git a/feature/onboarding/proguard-rules.pro b/feature/onboarding/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/onboarding/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/feature/onboarding/src/main/AndroidManifest.xml b/feature/onboarding/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..76073216
--- /dev/null
+++ b/feature/onboarding/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/feature/onboarding/src/main/java/com/moya/funch/onboarding/OnBoardingScreen.kt b/feature/onboarding/src/main/java/com/moya/funch/onboarding/OnBoardingScreen.kt
new file mode 100644
index 00000000..cc9ae157
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/moya/funch/onboarding/OnBoardingScreen.kt
@@ -0,0 +1,82 @@
+package com.moya.funch.onboarding
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.moya.funch.component.FunchButtonType
+import com.moya.funch.component.FunchMainButton
+import com.moya.funch.onboarding.theme.Gray300
+import com.moya.funch.onboarding.theme.Gray900
+import com.moya.funch.onboarding.theme.White
+import com.moya.funch.theme.FunchTheme
+
+@Composable
+internal fun OnBoardingScreen(onNavigateToCreateProfile: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .background(Gray900)
+ .fillMaxSize()
+ .padding(horizontal = 37.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = stringResource(id = R.string.onboarding_sub_title),
+ color = Gray300,
+ style = FunchTheme.typography.b
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(id = R.string.onboarding_title),
+ color = White,
+ style = FunchTheme.typography.t1
+ )
+ Spacer(modifier = Modifier.height(28.dp))
+ OnBoardingImage()
+ Spacer(modifier = Modifier.height(28.dp))
+ Text(
+ text = stringResource(id = R.string.profile_create_recommend),
+ color = Gray300,
+ style = FunchTheme.typography.b
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ FunchMainButton(
+ buttonType = FunchButtonType.Medium,
+ onClick = onNavigateToCreateProfile,
+ text = stringResource(id = R.string.profile_create_btn_text),
+ contentHorizontalPadding = 24.dp
+ )
+ }
+}
+
+@Composable
+private fun OnBoardingImage() {
+ Image(
+ modifier = Modifier
+ .fillMaxWidth(),
+ painter = painterResource(id = R.drawable.onboarding),
+ contentDescription = "onBoard"
+ )
+}
+
+@Composable
+@Preview
+private fun Preview() {
+ FunchTheme {
+ OnBoardingScreen({})
+ }
+}
diff --git a/feature/onboarding/src/main/java/com/moya/funch/onboarding/navigation/OnBoardingNavigation.kt b/feature/onboarding/src/main/java/com/moya/funch/onboarding/navigation/OnBoardingNavigation.kt
new file mode 100644
index 00000000..48ccd658
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/moya/funch/onboarding/navigation/OnBoardingNavigation.kt
@@ -0,0 +1,13 @@
+package com.moya.funch.onboarding.navigation
+
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.moya.funch.onboarding.OnBoardingScreen
+
+const val ON_BOARDING_ROUTE = "on_boarding"
+
+fun NavGraphBuilder.onBoardingScreen(onNavigateToCreateProfile: () -> Unit) {
+ composable(route = ON_BOARDING_ROUTE) {
+ OnBoardingScreen(onNavigateToCreateProfile = onNavigateToCreateProfile)
+ }
+}
diff --git a/feature/onboarding/src/main/java/com/moya/funch/onboarding/theme/OnBoardingColors.kt b/feature/onboarding/src/main/java/com/moya/funch/onboarding/theme/OnBoardingColors.kt
new file mode 100644
index 00000000..28470440
--- /dev/null
+++ b/feature/onboarding/src/main/java/com/moya/funch/onboarding/theme/OnBoardingColors.kt
@@ -0,0 +1,18 @@
+package com.moya.funch.onboarding.theme
+
+import androidx.compose.ui.graphics.Color
+
+internal val Coral500 = Color(0xFFF86E6F)
+internal val Lemon500 = Color(0xFFFFE83B)
+internal val Lemon600 = Color(0xFFE1CA13)
+internal val Lemon900 = Color(0xFF90720A)
+internal val Yellow500 = Color(0xFFFFD240)
+internal val Yellow600 = Color(0xFFE1B012)
+internal val White = Color(0xFFFFFFFF)
+internal val Gray900 = Color(0xFF151515)
+internal val Gray800 = Color(0xFF242627)
+internal val Gray700 = Color(0xFF2C2C2C)
+internal val Gray600 = Color(0xFF363636)
+internal val Gray500 = Color(0xFF404040)
+internal val Gray400 = Color(0xFF6D6D6D)
+internal val Gray300 = Color(0xFF9B9B9B)
diff --git a/feature/onboarding/src/main/res/drawable/onboarding.xml b/feature/onboarding/src/main/res/drawable/onboarding.xml
new file mode 100644
index 00000000..f82b7f5f
--- /dev/null
+++ b/feature/onboarding/src/main/res/drawable/onboarding.xml
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b41ce7ae
--- /dev/null
+++ b/feature/onboarding/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+ 친구와 프로필 매칭하기
+ 우리 사이의 공통점을 찾아요
+ 1분만에 프로필 만들고 매칭해보기!
+ 프로필 생성 시작🚀
+
diff --git a/feature/profile/.gitignore b/feature/profile/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/profile/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts
new file mode 100644
index 00000000..e1b207f1
--- /dev/null
+++ b/feature/profile/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ alias(libs.plugins.funch.feature)
+ alias(libs.plugins.funch.compose)
+}
+
+android {
+ namespace = "com.moya.funch.profile"
+}
+
+dependencies {
+ implementation(projects.core.designsystem)
+ implementation(projects.core.domain)
+}
diff --git a/feature/profile/proguard-rules.pro b/feature/profile/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/profile/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/feature/profile/src/main/AndroidManifest.xml b/feature/profile/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..e1000761
--- /dev/null
+++ b/feature/profile/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/profile/src/main/java/com/moya/funch/CreatePofileViewModel.kt b/feature/profile/src/main/java/com/moya/funch/CreatePofileViewModel.kt
new file mode 100644
index 00000000..aff0f376
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/CreatePofileViewModel.kt
@@ -0,0 +1,183 @@
+package com.moya.funch
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.uimodel.MbtiItem
+import com.moya.funch.uimodel.ProfileUiModel
+import com.moya.funch.uimodel.SubwayTextFieldState
+import com.moya.funch.usecase.CreateUserProfileUseCase
+import com.moya.funch.usecase.LoadSubwayStationsUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class CreateProfileUiState(
+ val profile: ProfileUiModel = ProfileUiModel(),
+ val isLoading: Boolean = false
+)
+
+internal sealed class CreateProfileEvent {
+ data object NavigateToHome : CreateProfileEvent()
+ data class ShowError(val message: String) : CreateProfileEvent()
+}
+
+@HiltViewModel
+internal class CreateProfileViewModel @Inject constructor(
+ private val createUserProfileUseCase: CreateUserProfileUseCase,
+ private val loadSubwayStationsUseCase: LoadSubwayStationsUseCase
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(CreateProfileUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _event = MutableSharedFlow()
+ val event = _event.asSharedFlow()
+
+ fun setNickname(nickname: String) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ name = nickname
+ )
+ )
+ }
+
+ fun setJob(job: Job) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ job = job
+ )
+ )
+ }
+
+ fun setClub(club: Club) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ clubs = _uiState.value.profile.clubs.toggleElement(club)
+ )
+ )
+ }
+
+ fun setBloodType(blood: Blood) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ bloodType = blood
+ )
+ )
+ }
+
+ fun setMbti(item: MbtiItem) {
+ viewModelScope.launch {
+ when (item) {
+ MbtiItem.E, MbtiItem.I -> _uiState.update { uiModel ->
+ uiModel.copy(profile = uiModel.profile.copy(eOrI = item))
+ }
+
+ MbtiItem.N, MbtiItem.S -> _uiState.update { uiModel ->
+ uiModel.copy(profile = uiModel.profile.copy(nOrS = item))
+ }
+
+ MbtiItem.T, MbtiItem.F -> _uiState.update { uiModel ->
+ uiModel.copy(profile = uiModel.profile.copy(tOrF = item))
+ }
+
+ MbtiItem.J, MbtiItem.P -> _uiState.update { uiModel ->
+ uiModel.copy(profile = uiModel.profile.copy(jOrP = item))
+ }
+ }
+ }
+ }
+
+ fun setSubwayName(subway: String) {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ subway = subway
+ )
+ )
+ if (subway.isBlank()) {
+ setSubwayTextFieldState(SubwayTextFieldState.Empty)
+ setSubwayStations(emptyList())
+ } else {
+ loadSubwayStationsUseCase(subway).fold(
+ onSuccess = { response ->
+ val newState = when {
+ response.isEmpty() -> SubwayTextFieldState.Error
+ subway == response.first().name -> SubwayTextFieldState.Success
+ else -> SubwayTextFieldState.Typing
+ }
+ setSubwayStations(response)
+ setSubwayTextFieldState(newState)
+ },
+ onFailure = {
+ setSubwayTextFieldState(SubwayTextFieldState.Error)
+ setSubwayStations(emptyList())
+ }
+ )
+ }
+ }
+ }
+
+ private fun setSubwayTextFieldState(state: SubwayTextFieldState) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ subwayTextFieldState = state
+ )
+ )
+ }
+
+ private fun setSubwayStations(stations: List) {
+ _uiState.value = _uiState.value.copy(
+ profile = _uiState.value.profile.copy(
+ subwayStations = stations
+ )
+ )
+ }
+
+ fun createProfile() {
+ viewModelScope.launch {
+ _uiState.update { currentState -> currentState.copy(isLoading = true) }
+ val profile = Profile(
+ name = _uiState.value.profile.name,
+ job = _uiState.value.profile.job,
+ clubs = _uiState.value.profile.clubs,
+ mbti = Mbti.valueOf(
+ _uiState.value.profile.eOrI.name +
+ _uiState.value.profile.nOrS.name +
+ _uiState.value.profile.tOrF.name +
+ _uiState.value.profile.jOrP.name
+ ),
+ blood = _uiState.value.profile.bloodType,
+ subways = listOf(
+ SubwayStation(
+ name = _uiState.value.profile.subway
+ )
+ )
+ )
+ createUserProfileUseCase(profile).onSuccess {
+ _event.emit(CreateProfileEvent.NavigateToHome)
+ }.onFailure {
+ _uiState.update { currentState -> currentState.copy(isLoading = false) }
+ _event.emit(CreateProfileEvent.ShowError(it.message ?: "Error"))
+ }
+ }
+ }
+}
+
+private fun List.toggleElement(element: T): List {
+ return if (contains(element)) {
+ filterNot { it == element }
+ } else {
+ this + element
+ }
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/CreateProflieScreen.kt b/feature/profile/src/main/java/com/moya/funch/CreateProflieScreen.kt
new file mode 100644
index 00000000..95f51174
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/CreateProflieScreen.kt
@@ -0,0 +1,686 @@
+package com.moya.funch
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusManager
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.moya.funch.common.clubPainter
+import com.moya.funch.common.jobPainter
+import com.moya.funch.component.FunchButtonType
+import com.moya.funch.component.FunchChip
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.component.FunchIconTextField
+import com.moya.funch.component.FunchLargeLabel
+import com.moya.funch.component.FunchMainButton
+import com.moya.funch.component.FunchMaxLengthTextField
+import com.moya.funch.component.FunchSmallLabel
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.profile.R
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray300
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray500
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.Gray900
+import com.moya.funch.theme.LocalBackgroundTheme
+import com.moya.funch.theme.White
+import com.moya.funch.ui.FunchDropDownButton
+import com.moya.funch.ui.FunchDropDownMenu
+import com.moya.funch.ui.FunchErrorCaption
+import com.moya.funch.ui.FunchTopBar
+import com.moya.funch.uimodel.MbtiItem
+import com.moya.funch.uimodel.ProfileLabel
+import com.moya.funch.uimodel.ProfileUiModel
+import com.moya.funch.uimodel.SubwayTextFieldState
+
+@Composable
+internal fun CreateProfileRoute(onNavigateToHome: () -> Unit, viewModel: CreateProfileViewModel = hiltViewModel()) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.event.collect { event ->
+ when (event) {
+ is CreateProfileEvent.NavigateToHome -> {
+ onNavigateToHome()
+ }
+
+ is CreateProfileEvent.ShowError -> {
+ // @Gun Hyung TODO : 에러 메시지 호출
+ }
+ }
+ }
+ }
+
+ CreateProfileScreen(
+ profile = uiState.profile,
+ isCreateProfile = uiState.profile.isButtonEnabled,
+ onSelectJob = viewModel::setJob,
+ onSelectClub = viewModel::setClub,
+ onSelectMbti = viewModel::setMbti,
+ onSelectBloodType = viewModel::setBloodType,
+ onNicknameChange = viewModel::setNickname,
+ onSubwayStationChange = viewModel::setSubwayName,
+ onCreateProfile = viewModel::createProfile,
+ onSendFeedback = {}
+ )
+
+ if (uiState.isLoading) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(
+ onClick = { },
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ // @Gun Hyung TODO : 로딩 UI 디자인시스템에 정의하고 그리기
+ }
+ }
+}
+
+@Composable
+fun CreateProfileScreen(
+ profile: ProfileUiModel,
+ isCreateProfile: Boolean,
+ onSelectJob: (Job) -> Unit,
+ onSelectClub: (Club) -> Unit,
+ onSelectMbti: (MbtiItem) -> Unit,
+ onSelectBloodType: (Blood) -> Unit,
+ onNicknameChange: (String) -> Unit,
+ onSubwayStationChange: (String) -> Unit,
+ onCreateProfile: () -> Unit,
+ onSendFeedback: () -> Unit
+) {
+ val scrollState = rememberScrollState()
+ val backgroundColor = LocalBackgroundTheme.current.color
+ var isKeyboardVisible by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+
+ Scaffold(
+ topBar = {
+ FunchTopBar(
+ modifier = Modifier.padding(end = 20.dp),
+ leadingIcon = null,
+ onClickTrailingIcon = onSendFeedback
+ )
+ },
+ bottomBar = {
+ if (!isKeyboardVisible) {
+ BottomBar(
+ backgroundColor = backgroundColor,
+ isCreateProfile = isCreateProfile,
+ onCreateProfile = onCreateProfile
+ )
+ }
+ },
+ containerColor = backgroundColor
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .pointerInput(Unit) {
+ detectTapGestures(onTap = {
+ focusManager.clearFocus()
+ })
+ }
+ .fillMaxSize()
+ .verticalScroll(state = scrollState)
+ .padding(padding)
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp)) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(id = R.string.create_profile_title),
+ color = White,
+ style = FunchTheme.typography.t2
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(id = R.string.create_profile_sub_title),
+ color = Gray300,
+ style = FunchTheme.typography.b
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ NicknameRow(
+ nickname = profile.name,
+ onNicknameChange = onNicknameChange,
+ isKeyboardVisible = { isKeyboardVisible = it }
+ )
+ Spacer(modifier = Modifier.height(14.dp))
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ JobRow(profile = profile, onSelected = onSelectJob)
+ ClubRow(onSelectClub = onSelectClub)
+ MbtiRow(profile = profile, onSelectMbti = onSelectMbti)
+ BooldTypeRow(onSelectBloodType = onSelectBloodType)
+ SubwayRow(
+ subwayStation = profile.subway,
+ onSubwayStationChange = onSubwayStationChange,
+ isKeyboardVisible = { isKeyboardVisible = it },
+ textFieldState = profile.subwayTextFieldState,
+ subwayStations = profile.subwayStations,
+ scrollState = scrollState
+ )
+ }
+ Spacer(modifier = Modifier.height(20.dp))
+ }
+ if (isKeyboardVisible) {
+ BottomBar(
+ backgroundColor = backgroundColor,
+ isCreateProfile = isCreateProfile,
+ onCreateProfile = onCreateProfile
+ )
+ }
+ }
+ }
+}
+
+private const val MAX_NICKNAME_LENGTH = 9
+
+@Composable
+private fun NicknameRow(nickname: String, onNicknameChange: (String) -> Unit, isKeyboardVisible: (Boolean) -> Unit) {
+ var isNicknameError by remember { mutableStateOf(false) }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(isFocused) {
+ if (!isFocused || nickname.length < MAX_NICKNAME_LENGTH) {
+ isNicknameError = false
+ }
+ }
+
+ Row {
+ FunchLargeLabel(text = ProfileLabel.NICKNAME.labelName)
+ Column {
+ FunchMaxLengthTextField(
+ modifier = Modifier.onFocusChanged { focusState ->
+ isKeyboardVisible(focusState.isFocused)
+ },
+ value = nickname,
+ onValueChange = { innerText ->
+ isNicknameError = if (innerText.length <= MAX_NICKNAME_LENGTH) {
+ onNicknameChange(innerText)
+ false
+ } else {
+ true
+ }
+ },
+ maxLength = MAX_NICKNAME_LENGTH,
+ hint = stringResource(id = R.string.nickname_textfield_hint, MAX_NICKNAME_LENGTH),
+ isError = isNicknameError,
+ interactionSource = interactionSource,
+ isFocus = isFocused,
+ errorText = stringResource(id = R.string.nickname_error_caption, MAX_NICKNAME_LENGTH),
+ keyboardOptions = KeyboardOptions.Default.copy(
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ }
+ )
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun JobRow(
+ profile: ProfileUiModel,
+ onSelected: (Job) -> Unit,
+ focusManager: FocusManager = LocalFocusManager.current
+) {
+ Row {
+ FunchSmallLabel(text = ProfileLabel.JOB.labelName)
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Job.entries.filterNot { it == Job.IDLE }.forEach { job ->
+ FunchChip(
+ selected = profile.job == job,
+ enabled = true,
+ onSelected = {
+ onSelected(job)
+ focusManager.clearFocus()
+ },
+ label = {
+ Text(
+ text = job.krName,
+ style = FunchTheme.typography.b,
+ color = White
+ )
+ },
+ leadingIcon = {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(
+ color = Gray900,
+ shape = FunchTheme.shapes.extraSmall
+ )
+ .clip(FunchTheme.shapes.extraSmall),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = jobPainter(job.krName),
+ contentDescription = ""
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ClubRow(onSelectClub: (Club) -> Unit, focusManager: FocusManager = LocalFocusManager.current) {
+ Row {
+ FunchSmallLabel(text = ProfileLabel.CLUB.labelName)
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Club.entries.filterNot { it == Club.IDLE }.forEach { club ->
+ var isSelected by remember { mutableStateOf(false) }
+ FunchChip(
+ selected = isSelected,
+ enabled = true,
+ onSelected = {
+ focusManager.clearFocus()
+ onSelectClub(club)
+ isSelected = !isSelected
+ },
+ label = {
+ Text(
+ text = club.label,
+ style = FunchTheme.typography.b,
+ color = White
+ )
+ },
+ leadingIcon = {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(
+ color = Gray900,
+ shape = FunchTheme.shapes.extraSmall
+ )
+ .clip(FunchTheme.shapes.extraSmall),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = clubPainter(club.label),
+ contentDescription = ""
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun MbtiRow(
+ profile: ProfileUiModel,
+ onSelectMbti: (MbtiItem) -> Unit,
+ focusManager: FocusManager = LocalFocusManager.current
+) {
+ val eOrI = profile.eOrI
+ val nOrS = profile.nOrS
+ val tOrF = profile.tOrF
+ val jOrP = profile.jOrP
+ val currentMbti = listOf(eOrI, nOrS, tOrF, jOrP)
+
+ Row {
+ FunchSmallLabel(text = ProfileLabel.MBTI.labelName)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ MbtiItem.entries.chunked(2).forEachIndexed { i, pair ->
+ Column(
+ modifier = Modifier
+ .background(color = Gray800, shape = FunchTheme.shapes.medium)
+ .clip(FunchTheme.shapes.medium)
+ ) {
+ pair.forEach { mbti ->
+ MbtiButton(
+ mbtiItem = mbti,
+ isSelected = currentMbti[i] == mbti,
+ onSelected = {
+ focusManager.clearFocus()
+ onSelectMbti(it)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MbtiButton(mbtiItem: MbtiItem, isSelected: Boolean, onSelected: (MbtiItem) -> Unit) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(
+ color = if (isSelected) Gray500 else Color.Transparent,
+ shape = FunchTheme.shapes.medium
+ )
+ .clip(FunchTheme.shapes.medium)
+ .clickable(
+ onClick = { onSelected(mbtiItem) },
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = mbtiItem.name,
+ color = if (isSelected) White else Gray400,
+ style = FunchTheme.typography.sbt2
+ )
+ }
+}
+
+@Composable
+private fun BooldTypeRow(onSelectBloodType: (Blood) -> Unit, focusManager: FocusManager = LocalFocusManager.current) {
+ val bloodTypes = Blood.entries.filterNot { it == Blood.IDLE }.map { it.type }
+ var placeHolder by remember { mutableStateOf(bloodTypes[0]) }
+ var isDropDownMenuExpanded by remember { mutableStateOf(false) }
+ val buttonBounds = remember { mutableStateOf(Rect.Zero) }
+
+ Row {
+ FunchLargeLabel(text = ProfileLabel.BLOOD_TYPE.labelName)
+ Box {
+ FunchDropDownButton(
+ placeHolder = placeHolder,
+ onClick = {
+ focusManager.clearFocus()
+ isDropDownMenuExpanded = !isDropDownMenuExpanded
+ },
+ isDropDownMenuExpanded = isDropDownMenuExpanded,
+ indication = null,
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ buttonBounds.value = coordinates.boundsInWindow()
+ }
+ )
+ if (isDropDownMenuExpanded) {
+ FunchDropDownMenu(
+ items = bloodTypes,
+ buttonBounds = buttonBounds.value,
+ onItemSelected = { bloodType ->
+ onSelectBloodType(Blood.of(bloodType))
+ placeHolder = bloodType
+ isDropDownMenuExpanded = false
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubwayRow(
+ subwayStation: String,
+ onSubwayStationChange: (String) -> Unit,
+ isKeyboardVisible: (Boolean) -> Unit,
+ textFieldState: SubwayTextFieldState,
+ subwayStations: List,
+ scrollState: ScrollState
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val focusManager = LocalFocusManager.current
+
+ if (isFocused) {
+ LaunchedEffect(subwayStation) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+
+ Row {
+ FunchLargeLabel(text = ProfileLabel.SUBWAY.labelName)
+ Column(modifier = Modifier.height(97.dp)) {
+ FunchIconTextField(
+ modifier = Modifier.onFocusChanged { focusState ->
+ isKeyboardVisible(focusState.isFocused)
+ },
+ value = subwayStation,
+ onValueChange = { subway ->
+ onSubwayStationChange(subway)
+ },
+ hint = stringResource(id = R.string.subway_textfield_hint),
+ isError = textFieldState == SubwayTextFieldState.Error,
+ iconType = FunchIcon(
+ resId = FunchIconAsset.Search.search_24,
+ tint = Gray400,
+ description = ""
+ ),
+ interactionSource = interactionSource,
+ isFocus = isFocused,
+ keyboardOptions = KeyboardOptions.Default.copy(
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ focusManager.clearFocus()
+ }
+ )
+ )
+
+ when (textFieldState) {
+ is SubwayTextFieldState.Empty -> {
+ /* @Gun Hyung : 아무것도 표시되지 않음 */
+ }
+
+ is SubwayTextFieldState.Success -> {
+ HorizontalSubwayStations(
+ subwayStations = subwayStations,
+ onSubwayStationChange = onSubwayStationChange
+ )
+ }
+
+ is SubwayTextFieldState.Error -> {
+ FunchErrorCaption(
+ modifier = Modifier
+ .padding(
+ start = 8.dp,
+ top = 4.dp
+ ),
+ errorText = stringResource(id = R.string.subway_error_caption)
+ )
+ }
+
+ is SubwayTextFieldState.Typing -> {
+ HorizontalSubwayStations(
+ subwayStations = subwayStations,
+ onSubwayStationChange = onSubwayStationChange
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HorizontalSubwayStations(subwayStations: List, onSubwayStationChange: (String) -> Unit) {
+ val focusManager = LocalFocusManager.current
+
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ subwayStations.forEach { station ->
+ Box(
+ modifier = Modifier
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.extraLarge
+ )
+ .clip(FunchTheme.shapes.extraLarge)
+ .clickable(
+ onClick = {
+ onSubwayStationChange(station.name)
+ focusManager.clearFocus()
+ },
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ )
+ .padding(8.dp)
+ ) {
+ Text(
+ text = station.name,
+ color = White,
+ style = FunchTheme.typography.b
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BottomBar(backgroundColor: Color, isCreateProfile: Boolean, onCreateProfile: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .background(color = backgroundColor)
+ .padding(
+ top = 16.dp,
+ start = 20.dp,
+ end = 20.dp
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ FunchMainButton(
+ enabled = isCreateProfile,
+ modifier = Modifier.fillMaxWidth(),
+ buttonType = FunchButtonType.Full,
+ onClick = onCreateProfile,
+ text = stringResource(id = R.string.bottom_button_title)
+ )
+ }
+}
+
+@Preview(
+ showBackground = true,
+ name = "CreateProfileScreen"
+)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ CreateProfileScreen(
+ profile = ProfileUiModel(),
+ isCreateProfile = false,
+ onSelectJob = {},
+ onSelectClub = {},
+ onSelectMbti = {},
+ onSelectBloodType = {},
+ onNicknameChange = {},
+ onSubwayStationChange = {},
+ onCreateProfile = {},
+ onSendFeedback = {}
+ )
+ }
+ }
+}
+
+@Preview(
+ showBackground = true,
+ name = "CreateProfileScreen"
+)
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ var text by remember { mutableStateOf("삼") }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxSize(),
+ color = LocalBackgroundTheme.current.color
+ ) {
+ SubwayRow(
+ subwayStation = text,
+ onSubwayStationChange = { text = it },
+ isKeyboardVisible = {},
+ textFieldState = SubwayTextFieldState.Typing,
+ subwayStations = listOf(
+ SubwayStation("삼성역"),
+ SubwayStation("삼성중앙역")
+ ),
+ scrollState = rememberScrollState()
+ )
+ }
+ }
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/MyProfileScreen.kt b/feature/profile/src/main/java/com/moya/funch/MyProfileScreen.kt
new file mode 100644
index 00000000..08a8180b
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/MyProfileScreen.kt
@@ -0,0 +1,362 @@
+package com.moya.funch
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.moya.funch.common.clubPainter
+import com.moya.funch.common.jobPainter
+import com.moya.funch.common.subwayLinePainter
+import com.moya.funch.component.FunchChip
+import com.moya.funch.component.FunchIcon
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.Mbti
+import com.moya.funch.entity.SubwayLine
+import com.moya.funch.entity.SubwayStation
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.icon.FunchIconAsset
+import com.moya.funch.theme.FunchTheme
+import com.moya.funch.theme.Gray400
+import com.moya.funch.theme.Gray800
+import com.moya.funch.theme.Gray900
+import com.moya.funch.theme.LocalBackgroundTheme
+import com.moya.funch.theme.White
+import com.moya.funch.ui.FunchTopBar
+import com.moya.funch.uimodel.ProfileLabel
+
+@Composable
+internal fun MyProfileRoute(viewModel: MyProfileViewModel = hiltViewModel(), onCloseMyProfile: () -> Unit) {
+ val uiState = viewModel.uiState.collectAsState().value
+
+ MyProfileScreen(
+ uiState = uiState,
+ onCloseMyProfile = onCloseMyProfile
+ )
+}
+
+@Composable
+internal fun MyProfileScreen(uiState: MyProfileUiState, onCloseMyProfile: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ FunchTopBar(
+ modifier = Modifier
+ .padding(
+ start = 12.dp,
+ end = 20.dp
+ ),
+ enabledLeadingIcon = true,
+ enabledTrailingIcon = true,
+ leadingIcon = FunchIcon(
+ resId = FunchIconAsset.Arrow.arrow_left_small_24,
+ description = "Back",
+ tint = Gray400
+ ),
+ onClickLeadingIcon = onCloseMyProfile
+ )
+ Box(
+ modifier = Modifier
+ .padding(
+ top = 8.dp,
+ bottom = 14.dp,
+ start = 20.dp,
+ end = 20.dp
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(FunchTheme.shapes.large)
+ .background(
+ color = Gray800,
+ shape = FunchTheme.shapes.large
+ )
+ .padding(
+ vertical = 24.dp,
+ horizontal = 20.dp
+ )
+ ) {
+ when (uiState) {
+ is MyProfileUiState.Loading -> {
+ LoadingContent()
+ }
+
+ is MyProfileUiState.Success -> {
+ LoadMyProfile(profile = uiState.profile)
+ }
+
+ is MyProfileUiState.Error -> {
+ ErrorContent()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadMyProfile(profile: Profile) {
+ Text(
+ text = profile.code,
+ style = FunchTheme.typography.b,
+ color = Gray400
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = profile.name,
+ style = FunchTheme.typography.t2,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.height(20.dp))
+ UsersDistinct(profile = profile)
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun UsersDistinct(profile: Profile) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ ProfileLabel.entries.filterNot { it == ProfileLabel.NICKNAME }.forEach { profileLabel ->
+ val labelValues = when (profileLabel) {
+ ProfileLabel.JOB -> listOf(profile.job.krName)
+ ProfileLabel.CLUB -> profile.clubs.map { it.label }
+ ProfileLabel.MBTI -> listOf(profile.mbti.name)
+ ProfileLabel.BLOOD_TYPE -> listOf(profile.blood.type)
+ ProfileLabel.SUBWAY -> profile.subways.map { it.name }
+ ProfileLabel.NICKNAME -> emptyList()
+ }
+
+ if (labelValues.isNotEmpty()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .width(52.dp)
+ .height(48.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ text = profileLabel.labelName,
+ color = Gray400,
+ style = FunchTheme.typography.b
+ )
+ }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ labelValues.forEach { value ->
+ val leadingIcon = when (profileLabel) {
+ ProfileLabel.JOB -> jobPainter(value)
+ ProfileLabel.CLUB -> clubPainter(value)
+ else -> null
+ }
+ val trailingIcon = when (profileLabel) {
+ ProfileLabel.SUBWAY ->
+ profile.subways.find { it.name == value }?.lines?.map {
+ subwayLinePainter(it.name)
+ }
+
+ else -> null
+ }
+
+ FunchChip(
+ leadingIcon = leadingIcon?.let { icon ->
+ {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .background(
+ color = Gray900,
+ shape = FunchTheme.shapes.extraSmall
+ )
+ .clip(FunchTheme.shapes.extraSmall),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier.size(18.dp),
+ painter = icon,
+ contentDescription = ""
+ )
+ }
+ }
+ },
+ selected = true,
+ enabled = false,
+ label = {
+ Text(
+ text = if (profileLabel == ProfileLabel.SUBWAY) value + "역" else value,
+ style = FunchTheme.typography.b,
+ color = White
+ )
+ },
+ trailingIcon = trailingIcon?.let { trailingIcons ->
+ {
+ Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+ trailingIcons.forEach { icon ->
+ Image(
+ painter = icon,
+ contentDescription = ""
+ )
+ }
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadingContent() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Loading...",
+ color = White
+ )
+ }
+}
+
+@Composable
+private fun ErrorContent() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Error",
+ color = White
+ )
+ }
+}
+
+@Preview(
+ showBackground = true,
+ device = Devices.NEXUS_6
+)
+@Composable
+private fun Preview1() {
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ MyProfileScreen(
+ uiState = MyProfileUiState.Success(
+ profile = Profile(
+ id = "QW2E213EEADF",
+ code = "U23C",
+ name = "김민수",
+ job = Job.DEVELOPER,
+ clubs = listOf(Club.NEXTERS, Club.SOPT, Club.DEPROMEET),
+ mbti = Mbti.ENFP,
+ blood = Blood.A,
+ subways = listOf(
+ SubwayStation(
+ "동대문역사문화공원",
+ listOf(
+ SubwayLine.ONE,
+ SubwayLine.FOUR
+ )
+ ),
+ SubwayStation(
+ "초지역",
+ listOf(
+ SubwayLine.TWO,
+ SubwayLine.THREE
+ )
+ )
+ )
+ )
+ ),
+ onCloseMyProfile = {}
+ )
+ }
+ }
+}
+
+@Preview(
+ name = "My Profile Loading",
+ showBackground = true,
+ device = Devices.NEXUS_6,
+ showSystemUi = true
+)
+@Composable
+private fun Preview2() {
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ MyProfileScreen(
+ uiState = MyProfileUiState.Loading,
+ onCloseMyProfile = {}
+ )
+ }
+ }
+}
+
+@Preview(
+ name = "My Profile Loading",
+ showBackground = true,
+ device = Devices.NEXUS_6,
+ showSystemUi = true
+)
+@Composable
+private fun Preview3() {
+ FunchTheme {
+ val backgroundColor = LocalBackgroundTheme.current.color
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = backgroundColor
+ ) {
+ MyProfileScreen(
+ uiState = MyProfileUiState.Error,
+ onCloseMyProfile = {}
+ )
+ }
+ }
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/MyProfileViewModel.kt b/feature/profile/src/main/java/com/moya/funch/MyProfileViewModel.kt
new file mode 100644
index 00000000..77148885
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/MyProfileViewModel.kt
@@ -0,0 +1,43 @@
+package com.moya.funch
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.moya.funch.entity.profile.Profile
+import com.moya.funch.usecase.LoadUserProfileUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+sealed class MyProfileUiState {
+ data object Loading : MyProfileUiState()
+ data class Success(val profile: Profile) : MyProfileUiState()
+ data object Error : MyProfileUiState()
+}
+
+@HiltViewModel
+internal class MyProfileViewModel @Inject constructor(
+ private val loadUserProfileUseCase: LoadUserProfileUseCase
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MyProfileUiState.Loading)
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ loadUserProfile()
+ }
+
+ private fun loadUserProfile() {
+ viewModelScope.launch {
+ val result = loadUserProfileUseCase()
+ result.onSuccess { profile ->
+ _uiState.value = MyProfileUiState.Success(profile)
+ }.onFailure { exception ->
+ _uiState.value = MyProfileUiState.Error
+ Timber.e(exception)
+ }
+ }
+ }
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/navigation/MyProfileNavigatoin.kt b/feature/profile/src/main/java/com/moya/funch/navigation/MyProfileNavigatoin.kt
new file mode 100644
index 00000000..5671809d
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/navigation/MyProfileNavigatoin.kt
@@ -0,0 +1,43 @@
+package com.moya.funch.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import androidx.navigation.navigation
+import com.moya.funch.CreateProfileRoute
+import com.moya.funch.MyProfileRoute
+
+const val PROFILE_GRAPH_ROUTE = "profile_graph"
+
+fun NavController.navigateToMyProfile(navOptions: NavOptions? = null) =
+ navigate(ProfileScreens.MyProfile.route, navOptions)
+
+fun NavController.navigateToCreateProfile(navOptions: NavOptions? = null) =
+ navigate(ProfileScreens.Create.route, navOptions)
+
+fun NavController.onCloseMyProfile() = popBackStack()
+
+fun NavGraphBuilder.profileGraph(onNavigateToHome: () -> Unit, onCloseMyProfile: () -> Unit) {
+ navigation(
+ route = PROFILE_GRAPH_ROUTE,
+ startDestination = ProfileScreens.Create.route
+ ) {
+ composable(route = ProfileScreens.Create.route) {
+ CreateProfileRoute(
+ onNavigateToHome = onNavigateToHome
+ )
+ }
+ composable(route = ProfileScreens.MyProfile.route) {
+ MyProfileRoute(
+ onCloseMyProfile = onCloseMyProfile
+ )
+ }
+ }
+}
+
+internal sealed class ProfileScreens(val route: String) {
+ data object MyProfile : ProfileScreens("my_profile")
+
+ data object Create : ProfileScreens("create_profile")
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/theme/ProfileColors.kt b/feature/profile/src/main/java/com/moya/funch/theme/ProfileColors.kt
new file mode 100644
index 00000000..7e60f825
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/theme/ProfileColors.kt
@@ -0,0 +1,19 @@
+package com.moya.funch.theme
+
+import androidx.compose.ui.graphics.Color
+
+// @Gun Hyung TODO : 디자인 시스템 컬러 정리되면 해당 파일 삭제 후 적용
+internal val Coral500 = Color(0xFFF86E6F)
+internal val Lemon500 = Color(0xFFFFE83B)
+internal val Lemon600 = Color(0xFFE1CA13)
+internal val Lemon900 = Color(0xFF90720A)
+internal val Yellow500 = Color(0xFFFFD240)
+internal val Yellow600 = Color(0xFFE1B012)
+internal val White = Color(0xFFFFFFFF)
+internal val Gray900 = Color(0xFF151515)
+internal val Gray800 = Color(0xFF242627)
+internal val Gray700 = Color(0xFF2C2C2C)
+internal val Gray600 = Color(0xFF363636)
+internal val Gray500 = Color(0xFF404040)
+internal val Gray400 = Color(0xFF6D6D6D)
+internal val Gray300 = Color(0xFF9B9B9B)
diff --git a/feature/profile/src/main/java/com/moya/funch/uimodel/MbtiItem.kt b/feature/profile/src/main/java/com/moya/funch/uimodel/MbtiItem.kt
new file mode 100644
index 00000000..cccf49df
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/uimodel/MbtiItem.kt
@@ -0,0 +1,12 @@
+package com.moya.funch.uimodel
+
+enum class MbtiItem {
+ E,
+ I,
+ N,
+ S,
+ T,
+ F,
+ J,
+ P
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileLabel.kt b/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileLabel.kt
new file mode 100644
index 00000000..54bbc7a4
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileLabel.kt
@@ -0,0 +1,10 @@
+package com.moya.funch.uimodel
+
+enum class ProfileLabel(val labelName: String) {
+ NICKNAME("닉네임"),
+ JOB("직군"),
+ CLUB("동아리"),
+ MBTI("MBTI"),
+ BLOOD_TYPE("혈액형"),
+ SUBWAY("지하철")
+}
diff --git a/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileUiModel.kt b/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileUiModel.kt
new file mode 100644
index 00000000..4d6c7d6f
--- /dev/null
+++ b/feature/profile/src/main/java/com/moya/funch/uimodel/ProfileUiModel.kt
@@ -0,0 +1,33 @@
+package com.moya.funch.uimodel
+
+import com.moya.funch.entity.Blood
+import com.moya.funch.entity.Club
+import com.moya.funch.entity.Job
+import com.moya.funch.entity.SubwayStation
+
+data class ProfileUiModel(
+ val name: String = "",
+ val job: Job = Job.IDLE,
+ val clubs: List = emptyList(),
+ val eOrI: MbtiItem = MbtiItem.E,
+ val nOrS: MbtiItem = MbtiItem.N,
+ val tOrF: MbtiItem = MbtiItem.T,
+ val jOrP: MbtiItem = MbtiItem.J,
+ val bloodType: Blood = Blood.A,
+ val subway: String = "",
+ val subwayTextFieldState: SubwayTextFieldState = SubwayTextFieldState.Empty,
+ val subwayStations: List = emptyList()
+) {
+ val isButtonEnabled: Boolean
+ get() = name.isNotBlank() &&
+ job != Job.IDLE &&
+ clubs.isNotEmpty() &&
+ subwayTextFieldState == SubwayTextFieldState.Success
+}
+
+sealed class SubwayTextFieldState {
+ data object Empty : SubwayTextFieldState()
+ data object Error : SubwayTextFieldState()
+ data object Success : SubwayTextFieldState()
+ data object Typing : SubwayTextFieldState()
+}
diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml
new file mode 100644
index 00000000..de631896
--- /dev/null
+++ b/feature/profile/src/main/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+
+ 프로필 만들기
+ 프로필을 만들어 공통점을 찾을 수 있어요
+ 최대 %d글자
+ 최대 %d글자까지 입력할 수 있어요
+ 가까운 지하철역 검색
+ 존재하지 않는 지하철역이에요
+ 이제 매칭할래요!
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4e59bb1d..c1e713a2 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,22 +1,201 @@
[versions]
-agp = "8.2.1"
+compileSdk = "34"
+minSdk = "28"
+targetSdk = "34"
+appVersion = "1.0.0"
+versionCode = "10000"
+
+# kotlin
kotlin = "1.9.22"
+kotlinx-serialization-json = "1.6.2"
+kotlinx-serialization-converter = "1.0.0"
+kotlinx-coroutines = "1.7.3"
+
+# android
core-ktx = "1.12.0"
-junit = "4.13.2"
-androidx-test-ext-junit = "1.1.5"
-espresso-core = "3.5.1"
appcompat = "1.6.1"
+activity = "1.8.2"
+lifecycle = "2.7.0"
+navigation = "2.7.6"
+startup = "1.1.1"
+security = "1.1.0-alpha06"
+desugarJdk = "2.0.4"
+dagger-hilt = "2.50"
+dagger-hilt-navigation-compose = "1.0.0"
+composeCompiler = "1.5.8"
+compose-bom = "2024.01.00"
+
+# material + google
material = "1.11.0"
+accompanist = "0.30.1"
+in-app-update = "2.1.0"
+secret-gradle-plugin = "2.0.1"
+google-services = "4.4.0"
+crashlytics = "2.9.9"
+firebase = "32.1.1"
+app-distribution = "4.0.1"
+admob = "22.1.0"
+
+# test
+junit = "4.13.2"
+androidx-test-junit = "1.1.5"
+androidx-test-espresso = "3.5.1"
+junit5-plugin = "1.10.0.0"
+junit5-jupiter = "5.10.1"
+truth = "1.2.0"
+robolectric = "4.11.1"
+androidx-uiautomator = "2.3.0-alpha03"
+androidx-test = "1.5.2"
+androidx-test-core = "1.5.0"
+espressoIntents = "3.5.1"
+runner = "1.5.2"
+mockk = "1.13.9"
+mock-webserver = "4.9.1"
+
+# gradle
+agp = "8.2.1"
+ksp = "1.9.22-1.0.17"
+
+# third party
+okhttp = "4.12.0"
+retrofit = "2.9.0"
+timber = "5.0.1"
+coil = "2.5.0"
+lottie = "6.0.1"
+kakao = "2.14.0"
+splash-screen = "1.0.1"
+ktlint = "12.1.0"
+javax-inject = "1"
[libraries]
+agp = { module = "com.android.tools.build:gradle", version.ref = "agp" }
+
+# kotlin
+kotlin = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
+kotlin-gradleplugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
+ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
+kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
+
+# coroutines
+kotlin-coroutines-google-play = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }
+kotlin-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
+kotlin-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
+
+# android
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
-junit = { group = "junit", name = "junit", version.ref = "junit" }
-androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
+lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
+security = { module = "androidx.security:security-crypto", version.ref = "security" }
+desugarLibs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdk" }
+javax-inject = { group = "javax.inject", name = "javax.inject", version.ref = "javax-inject" }
+
+# compose
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+ui = { group = "androidx.compose.ui", name = "ui" }
+ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+ui-foundation = { group = "androidx.compose.foundation", name = "foundation" }
+material3-compose = { group = "androidx.compose.material3", name = "material3" }
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
+navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "dagger-hilt-navigation-compose" }
+lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+compose-lottie = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" }
+
+# dagger-hilt
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" }
+hilt-compiler = { module = "com.google.dagger:hilt-compiler", name = "hilt-compiler", version.ref = "dagger-hilt" }
+hilt-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "dagger-hilt" }
+hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger-hilt" }
+hilt-testing-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger-hilt" }
+
+# google
+google-services = { module = "com.google.gms:google-services", version.ref = "google-services" }
+crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "crashlytics" }
+firebase = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" }
+firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" }
+firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" }
+firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
+accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
+google-android-gms = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "admob" }
+
+# test
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlin-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
+androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
+androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
+androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
+androidx-test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espressoIntents" }
+androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-core" }
+androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-core" }
+androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test" }
+androidx-test-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidx-test-junit" }
+junit5 = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5-jupiter" }
+junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5-jupiter" }
+junit5-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5-jupiter" }
+junit5-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5-jupiter" }
+junit5-android-test-core = { module = "de.mannodermaus.junit5:android-test-core", version = "1.4.0" }
+junit5-android-test-runner = { module = "de.mannodermaus.junit5:android-test-runner", version = "1.4.0" }
+truth = { module = "com.google.truth:truth", version.ref = "truth" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
+android-mockk = { module = "io.mockk:mockk-android", version.ref = "mockk" }
+mockk-webserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+
+# third party
+ktlint = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" }
+okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
+okhttp = { module = "com.squareup.okhttp3:okhttp" }
+okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" }
+retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
+retrofit-kotlin-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter" }
+
+timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
+
+coil-core = { module = "io.coil-kt:coil", version.ref = "coil" }
+
+kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" }
+
+splash-screen = { module = "androidx.core:core-splashscreen", version.ref = "splash-screen" }
+
[plugins]
-androidApplication = { id = "com.android.application", version.ref = "agp" }
-kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
+dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" }
+google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
+crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+secret = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secret-gradle-plugin" }
+kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "junit5-plugin" }
+app-distribution = { id = "com.google.firebase.appdistribution", version.ref = "app-distribution" }
+ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
+
+# Plugins defined by moya
+funch-application = { id = "com.moya.funch.application", version = "unspecified" }
+funch-android-library = { id = "com.moya.funch.library", version = "unspecified" }
+funch-feature = { id = "com.moya.funch.feature", version = "unspecified" }
+funch-hilt = { id = "com.moya.funch.hilt", version = "unspecified" }
+funch-kotlinx-serialization = { id = "com.moya.funch.kotlinx_serialization", version = "unspecified" }
+funch-android-test = { id = "com.moya.funch.android.test", version = "unspecified" }
+funch-junit5 = { id = "com.moya.funch.junit5", version = "unspecified" }
+funch-compose = { id = "com.moya.funch.compose", version = "unspecified" }
+funch-jvm-library = { id = "com.moya.funch.jvm.library", version = "unspecified" }
+[bundles]
+firebase = ["firebase-analytics", "firebase-crashlytics"]
+lifecycle = ["lifecycle", "lifecycle-viewmodel"]
+compose = ["ui", "ui-graphics", "ui-tooling", "ui-tooling-preview", "material3-compose", "coil-compose", "ui-foundation", "activity-compose", "navigation-compose", "lifecycle-compose"]
+retrofit = ["retrofit", "retrofit-kotlin-serialization-converter"]
+junit5 = ["junit5", "junit5-engine", "junit5-params", "junit5-vintage"]
+androidx-android-test = ["androidx-test-core", "androidx-test-espresso", "androidx-test-espresso-intents", "androidx-test-junit", "androidx-test-junit-ktx", "androidx-test-rules", "androidx-test-runner", "androidx-test-truth"]
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9156d436..14caf443 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,4 +1,7 @@
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+
pluginManagement {
+ includeBuild("build-logic")
repositories {
google()
mavenCentral()
@@ -15,4 +18,17 @@ dependencyResolutionManagement {
rootProject.name = "Funch-AOS"
include(":app")
-
\ No newline at end of file
+
+// core
+include(":core:designsystem")
+include(":core:testing")
+include(":core:network")
+include(":core:datastore")
+include(":core:domain")
+include(":core:data")
+
+// feature
+include(":feature:profile")
+include(":feature:home")
+include(":feature:match")
+include(":feature:onboarding")