diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..77c75f79
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,4 @@
+[*.{kt,kts}]
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ktlint_function_naming_ignore_when_annotated_with = Composable, Test
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..baf3b77e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,46 @@
+name: "\U0001F41B 버그 제보"
+description: 버그를 발견하셨나요?
+labels: bug, parent
+
+body:
+ - type: textarea
+ id: bug-description
+ attributes:
+ label: 버그 설명
+ placeholder: 버그에 대해 설명해주세요.
+
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: 예상 동작
+ placeholder: 예상했던 동작에 대해 설명해주세요.
+
+ - type: textarea
+ id: actual-behavior
+ attributes:
+ label: 실제 동작
+ placeholder: 실제로 일어난 동작을 설명해주세요.
+
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: 재현 방법
+ placeholder: 순서대로 설명해주세요.
+
+ - type: textarea
+ id: screenshot
+ attributes:
+ label: 스크린샷 첨부
+ placeholder: 스크린샷이 있으면 첨부해주세요.
+
+ - type: textarea
+ id: environment
+ attributes:
+ label: 환경
+ placeholder: 디바이스, 운영체제, 앱 버전 등을 명시해주세요.
+
+ - type: textarea
+ id: additional-info
+ attributes:
+ label: 비고
+ placeholder: 추가적인 정보를 기입해주세요.
diff --git a/.github/ISSUE_TEMPLATE/feat.yml b/.github/ISSUE_TEMPLATE/feat.yml
new file mode 100644
index 00000000..52907c32
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feat.yml
@@ -0,0 +1,19 @@
+name: "⚙️ 기능 개발"
+description: "개발 시간 🫡"
+body:
+ - type: textarea
+ attributes:
+ label: 기능 설명
+ - type: textarea
+ attributes:
+ label: 개발 일정
+ description: 개발 일정을 적어주세요.
+ - type: textarea
+ attributes:
+ label: 자식 이슈
+ description: 현재 이슈의 하위 이슈를 링크해주세요.
+ description: 개발할 기능에 대한 설명을 적어주세요.
+ - type: textarea
+ attributes:
+ label: 부가 설명
+ description: 기타 부가적인 설명을 적어주세요.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..f7a94a9f
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+## 관련 이슈번호
+
+
close #
+
+## 작업 사항
+
+
+
+## 기타 사항
+
+
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 00000000..a648b184
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,61 @@
+name: Android CI
+
+on:
+ pull_request:
+ branches: [ "main", "develop" ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ # Gradle 캐싱: 빌드 시간과 네트워크 통신을 줄이기 위해 의존성 패키지들을 캐싱하여 재사용
+ - name: Cache Gradle packages
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Access Google Client Id
+ env:
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
+ run: |
+ echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties
+
+ - name: Access YOUTH POLICY API KEY
+ env:
+ YOUTH_POLICY_API_KEY: ${{ secrets.YOUTH_POLICY_API_KEY }}
+ run: |
+ echo "YOUTH_POLICY_API_KEY=\"YOUTH_POLICY_API_KEY\"" >> local.properties
+
+ - name: Access BaseUrl
+ env:
+ YOUTH_POLICY_API_KEY: ${{ secrets.BASE_URL }}
+ run: |
+ echo "BASE_URL=\"BASE_URL\"" >> local.properties
+
+ - name: Create google-services
+ run: |
+ echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json
+
+ - name: Run test
+ run: ./gradlew test --parallel
+
+ - name: Run ktlint
+ run: ./gradlew ktlintCheck
diff --git a/.github/workflows/build_cache.yml b/.github/workflows/build_cache.yml
new file mode 100644
index 00000000..577ca9da
--- /dev/null
+++ b/.github/workflows/build_cache.yml
@@ -0,0 +1,59 @@
+name: Android CI
+
+on:
+ push:
+ branches: ["develop"]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ # Gradle 캐싱: 빌드 시간과 네트워크 통신을 줄이기 위해 의존성 패키지들을 캐싱하여 재사용
+ - name: Cache Gradle packages
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: set up JDK 17
+ uses: actions/setup-java@v3
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Access Google Client Id
+ env:
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
+ run: |
+ echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties
+
+ - name: Access YOUTH POLICY API KEY
+ env:
+ YOUTH_POLICY_API_KEY: ${{ secrets.YOUTH_POLICY_API_KEY }}
+ run: |
+ echo "YOUTH_POLICY_API_KEY=\"YOUTH_POLICY_API_KEY\"" >> local.properties
+
+ - name: Access BaseUrl
+ env:
+ YOUTH_POLICY_API_KEY: ${{ secrets.BASE_URL }}
+ run: |
+ echo "BASE_URL=\"BASE_URL\"" >> local.properties
+
+ - name: Create google-services
+ run: |
+ echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json
+
+ - name: Build with Gradle
+ run: ./gradlew build
+
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..093ea0b8
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,246 @@
+# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin,macos,windows
+# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,kotlin,macos,windows
+
+### Android ###
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+### Android Patch ###
+gen-external-apklibs
+
+# Replacement of .externalNativeBuild directories introduced
+# with Android Studio 3.5.
+
+### Kotlin ###
+# Compiled class file
+*.class
+
+# Log file
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+.navigation/
+*.ipr
+*~
+*.swp
+
+# Keystore files
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iws
+/out/
+
+# User-specific configurations
+.idea/caches/
+.idea/libraries/
+.idea/shelf/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/.name
+.idea/compiler.xml
+.idea/copyright/profiles_settings.xml
+.idea/encodings.xml
+.idea/misc.xml
+.idea/modules.xml
+.idea/scopes/scope_settings.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+.idea/datasources.xml
+.idea/dataSources.ids
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+.idea/assetWizardSettings.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/navEditor.xml
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+
+# Package Files #
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+
+## Plugin-specific files:
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Mongo Explorer plugin
+.idea/mongoSettings.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,kotlin,macos,windows
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..9280464b
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,64 @@
+plugins {
+ id("convention.application")
+ alias(libs.plugins.firebase.services)
+ alias(libs.plugins.firebase.crashlytics)
+}
+
+android {
+ namespace = "com.withpeace.withpeace"
+
+ defaultConfig {
+ applicationId = "com.withpeace.withpeace"
+ targetSdk = 34
+ versionCode = 2
+ versionName = "1.0.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ merges += "META-INF/LICENSE.md"
+ merges += "META-INF/LICENSE-notice.md"
+ }
+ }
+ buildTypes {
+ getByName("release") {
+ signingConfig = signingConfigs.getByName("debug")
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.splashscreen)
+ implementation(project(":feature:login"))
+ implementation(project(":feature:signup"))
+ implementation(project(":feature:home"))
+ implementation(project(":feature:postlist"))
+ implementation(project(":feature:mypage"))
+ implementation(project(":feature:registerpost"))
+ implementation(project(":feature:gallery"))
+ implementation(project(":feature:postdetail"))
+ implementation(project(":feature:profileeditor"))
+ implementation(project(":core:ui"))
+ implementation(project(":core:interceptor"))
+ implementation(project(":core:data"))
+ implementation(project(":core:network"))
+ implementation(project(":core:datastore"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:designsystem"))
+ testImplementation(project(":core:testing"))
+
+ //firebase
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+ implementation(libs.firebase.crashlytics)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..f848d393
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,37 @@
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
+
+# Fix for Retrofit issue https://github.com/square/retrofit/issues/3751
+# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
+# With R8 full mode generic signatures are stripped for classes that are not
+# kept. Suspend functions are wrapped in continuations where the type argument
+# is used.
+-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
+
+# https://stackoverflow.com/questions/70037537/proguard-missing-classes-detected-while-running-r8-after-adding-package-names-in
+-dontwarn com.sun.jna.FunctionMapper
+-dontwarn com.sun.jna.JNIEnv
+-dontwarn com.sun.jna.Library
+-dontwarn com.sun.jna.Native
+-dontwarn com.sun.jna.Platform
+-dontwarn com.sun.jna.Pointer
+-dontwarn com.sun.jna.Structure
+-dontwarn com.sun.jna.platform.win32.Kernel32
+-dontwarn com.sun.jna.platform.win32.Win32Exception
+-dontwarn com.sun.jna.platform.win32.WinDef$LPVOID
+-dontwarn com.sun.jna.platform.win32.WinNT$HANDLE
+-dontwarn com.sun.jna.win32.StdCallLibrary
+-dontwarn com.sun.jna.win32.W32APIOptions
+-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings
+-dontwarn java.lang.instrument.ClassDefinition
+-dontwarn java.lang.instrument.IllegalClassFormatException
+-dontwarn java.lang.instrument.UnmodifiableClassException
+-dontwarn org.apiguardian.api.API$Status
+-dontwarn org.apiguardian.api.API
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 00000000..7844f73b
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.withpeace.withpeace",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 1,
+ "versionName": "1.0",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/withpeace/withpeace/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/withpeace/withpeace/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..fabc1c37
--- /dev/null
+++ b/app/src/androidTest/java/com/withpeace/withpeace/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..267336b7
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/ic_app_logo-playstore.png b/app/src/main/ic_app_logo-playstore.png
new file mode 100644
index 00000000..76b85999
Binary files /dev/null and b/app/src/main/ic_app_logo-playstore.png differ
diff --git a/app/src/main/java/com/withpeace/withpeace/MainActivity.kt b/app/src/main/java/com/withpeace/withpeace/MainActivity.kt
new file mode 100644
index 00000000..5e2e09b2
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/MainActivity.kt
@@ -0,0 +1,56 @@
+package com.withpeace.withpeace
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.core.splashscreen.SplashScreen
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.feature.home.navigation.HOME_ROUTE
+import com.withpeace.withpeace.feature.login.navigation.LOGIN_ROUTE
+import com.withpeace.withpeace.feature.registerpost.navigation.REGISTER_POST_ROUTE
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ private val viewModel by viewModels()
+ private lateinit var splashScreen: SplashScreen
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // System Bar에 가려지는 뷰 영역을 개발자가 제어하겠다.
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ lifecycleScope.launch {
+ splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { true }
+ if (savedInstanceState == null) delay(2000L) // 처음 앱 켰을때만 2초기다림, 화면회전에는 기다리면 안됨
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.isLogin.collect { isLogin ->
+ when (isLogin) {
+ true -> composeStart(HOME_ROUTE)
+ false -> composeStart(LOGIN_ROUTE)
+ else -> {} // StateFlow의 상태를 Null로 설정함으로서, 로그인 상태가 업데이트 된 이후로 화면을 보여주도록 하기위함
+ }
+ splashScreen.setKeepOnScreenCondition { false }
+ }
+ }
+ }
+ }
+
+ private fun ComponentActivity.composeStart(startDestination: String) {
+ setContent {
+ WithpeaceTheme {
+ WithpeaceApp(startDestination = startDestination)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt b/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt
new file mode 100644
index 00000000..3423ef06
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt
@@ -0,0 +1,123 @@
+package com.withpeace.withpeace
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Image
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.navigation.NavController
+import androidx.navigation.NavDestination
+import androidx.navigation.NavHostController
+import androidx.navigation.navOptions
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.feature.home.navigation.HOME_ROUTE
+import com.withpeace.withpeace.feature.home.navigation.navigateHome
+import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE
+import com.withpeace.withpeace.feature.postlist.navigation.navigateToPostList
+import com.withpeace.withpeace.feature.registerpost.navigation.REGISTER_POST_ROUTE
+import com.withpeace.withpeace.feature.registerpost.navigation.navigateToRegisterPost
+import com.withpeace.withpeace.navigation.MY_PAGE_NESTED_ROUTE
+
+@Composable
+fun MainBottomBar(
+ modifier: Modifier = Modifier,
+ currentDestination: NavDestination,
+ navController: NavHostController,
+) {
+ val context = LocalContext.current
+ NavigationBar(
+ modifier = modifier,
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ ) {
+ BottomTab.entries.forEach { tab ->
+ NavigationBarItem(
+ colors =
+ NavigationBarItemDefaults.colors(
+ selectedTextColor = WithpeaceTheme.colors.SystemBlack,
+ unselectedTextColor = WithpeaceTheme.colors.SystemGray2,
+ indicatorColor = WithpeaceTheme.colors.SystemWhite,
+ ),
+ selected = currentDestination.route == tab.route,
+ onClick = { navController.navigateToTabScreen(tab) },
+ icon = {
+ Image(
+ painter =
+ painterResource(
+ id =
+ if (currentDestination.route == tab.route) {
+ tab.iconSelectedResId
+ } else {
+ tab.iconUnSelectedResId
+ },
+ ),
+ contentDescription = context.getString(tab.contentDescription),
+ )
+ },
+ label = { Text(text = context.getString(tab.contentDescription)) },
+ )
+ }
+ }
+}
+
+private fun NavController.navigateToTabScreen(bottomTab: BottomTab) {
+ val tabNavOptions =
+ navOptions {
+ popUpTo(HOME_ROUTE) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+
+ when (bottomTab) {
+ BottomTab.HOME -> navigateHome(tabNavOptions)
+ BottomTab.POST -> navigateToPostList(tabNavOptions)
+ BottomTab.REGISTER_POST -> navigateToRegisterPost()
+ BottomTab.MY_PAGE -> navigate(MY_PAGE_NESTED_ROUTE, tabNavOptions)
+ }
+}
+
+enum class BottomTab(
+ val iconUnSelectedResId: Int,
+ val iconSelectedResId: Int,
+ @StringRes val contentDescription: Int,
+ val route: String,
+) {
+ HOME(
+ iconUnSelectedResId = R.drawable.ic_bottom_home,
+ iconSelectedResId = R.drawable.ic_bottom_home_select,
+ contentDescription = R.string.home,
+ HOME_ROUTE,
+ ),
+ POST(
+ iconUnSelectedResId = R.drawable.ic_bottom_post,
+ iconSelectedResId = R.drawable.ic_bottom_post_select,
+ contentDescription = R.string.post,
+ POST_LIST_ROUTE,
+ ),
+ REGISTER_POST(
+ iconUnSelectedResId = R.drawable.ic_upload,
+ iconSelectedResId = R.drawable.ic_upload,
+ contentDescription = R.string.register,
+ REGISTER_POST_ROUTE,
+ ),
+ MY_PAGE(
+ iconUnSelectedResId = R.drawable.ic_bottom_my_page,
+ iconSelectedResId = R.drawable.ic_bottom_my_page_select,
+ contentDescription = R.string.my_page,
+ MY_PAGE_NESTED_ROUTE,
+ ),
+ ;
+
+ companion object {
+ operator fun contains(route: String): Boolean {
+ if (route == REGISTER_POST_ROUTE) return false
+ return entries.map { it.route }.contains(route)
+ }
+ }
+}
diff --git a/app/src/main/java/com/withpeace/withpeace/MainViewModel.kt b/app/src/main/java/com/withpeace/withpeace/MainViewModel.kt
new file mode 100644
index 00000000..237ce678
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/MainViewModel.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.usecase.IsLoginUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val isLoginUseCase: IsLoginUseCase,
+) : ViewModel() {
+ private val _isLogin: MutableStateFlow = MutableStateFlow(null)
+ val isLogin = _isLogin.asStateFlow()
+
+ init {
+ viewModelScope.launch { _isLogin.value = isLoginUseCase() }
+ }
+}
diff --git a/app/src/main/java/com/withpeace/withpeace/WithPeaceApplication.kt b/app/src/main/java/com/withpeace/withpeace/WithPeaceApplication.kt
new file mode 100644
index 00000000..8390979d
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/WithPeaceApplication.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class WithPeaceApplication : Application() {
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt b/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt
new file mode 100644
index 00000000..873b8363
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/WithpeaceApp.kt
@@ -0,0 +1,62 @@
+package com.withpeace.withpeace
+
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.navigation.WithpeaceNavHost
+import kotlinx.coroutines.launch
+
+@Composable
+fun WithpeaceApp(
+ startDestination: String,
+ navController: NavHostController = rememberNavController(),
+) {
+ val snackBarHostState = remember { SnackbarHostState() }
+ val coroutineScope = rememberCoroutineScope()
+ fun showSnackBar(message: String) = coroutineScope.launch {
+ snackBarHostState.showSnackbar(message)
+ }
+
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry?.destination
+ val parentDestination = currentDestination?.parent
+
+ Scaffold(
+ bottomBar = {
+ if (
+ BottomTab.contains(parentDestination?.route ?: currentDestination?.route ?: "")
+ ) {
+ MainBottomBar(
+ currentDestination = if (parentDestination?.route == null) {
+ currentDestination ?: return@Scaffold
+ } else parentDestination,
+ navController = navController,
+ )
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ snackbarHost = { SnackbarHost(snackBarHostState) },
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ ) { innerPadding ->
+ WithpeaceNavHost(
+ modifier = Modifier
+ .padding(innerPadding)
+ .consumeWindowInsets(innerPadding),
+ onShowSnackBar = ::showSnackBar,
+ startDestination = startDestination,
+ navController = navController,
+ )
+ }
+}
diff --git a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt
new file mode 100644
index 00000000..106dd4cc
--- /dev/null
+++ b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt
@@ -0,0 +1,222 @@
+package com.withpeace.withpeace.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.navOptions
+import androidx.navigation.navigation
+import com.app.profileeditor.navigation.navigateProfileEditor
+import com.app.profileeditor.navigation.profileEditorNavGraph
+import com.withpeace.withpeace.feature.gallery.navigation.galleryNavGraph
+import com.withpeace.withpeace.feature.gallery.navigation.navigateToGallery
+import com.withpeace.withpeace.feature.home.navigation.homeNavGraph
+import com.withpeace.withpeace.feature.home.navigation.navigateHome
+import com.withpeace.withpeace.feature.login.navigation.LOGIN_ROUTE
+import com.withpeace.withpeace.feature.login.navigation.loginNavGraph
+import com.withpeace.withpeace.feature.login.navigation.navigateLogin
+import com.withpeace.withpeace.feature.mypage.navigation.MY_PAGE_CHANGED_IMAGE_ARGUMENT
+import com.withpeace.withpeace.feature.mypage.navigation.MY_PAGE_CHANGED_NICKNAME_ARGUMENT
+import com.withpeace.withpeace.feature.mypage.navigation.MY_PAGE_ROUTE
+import com.withpeace.withpeace.feature.mypage.navigation.myPageNavGraph
+import com.withpeace.withpeace.feature.postdetail.navigation.POST_DETAIL_ROUTE_WITH_ARGUMENT
+import com.withpeace.withpeace.feature.postdetail.navigation.navigateToPostDetail
+import com.withpeace.withpeace.feature.postdetail.navigation.postDetailGraph
+import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE
+import com.withpeace.withpeace.feature.postlist.navigation.postListGraph
+import com.withpeace.withpeace.feature.registerpost.navigation.IMAGE_LIST_ARGUMENT
+import com.withpeace.withpeace.feature.registerpost.navigation.REGISTER_POST_ARGUMENT
+import com.withpeace.withpeace.feature.registerpost.navigation.REGISTER_POST_ROUTE
+import com.withpeace.withpeace.feature.registerpost.navigation.navigateToRegisterPost
+import com.withpeace.withpeace.feature.registerpost.navigation.registerPostNavGraph
+import com.withpeace.withpeace.feature.signup.navigation.navigateSignUp
+import com.withpeace.withpeace.feature.signup.navigation.signUpNavGraph
+
+@Composable
+fun WithpeaceNavHost(
+ modifier: Modifier = Modifier,
+ navController: NavHostController,
+ startDestination: String = LOGIN_ROUTE,
+ onShowSnackBar: (message: String) -> Unit,
+) {
+ NavHost(
+ modifier = modifier,
+ navController = navController,
+ startDestination = startDestination,
+ ) {
+ loginNavGraph(
+ onShowSnackBar = onShowSnackBar,
+ onSignUpNeeded = {
+ navController.navigateSignUp()
+ },
+ onLoginSuccess = {
+ navController.navigateHome(
+ navOptions =
+ navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ },
+ )
+ },
+ )
+ signUpNavGraph(
+ onShowSnackBar = onShowSnackBar,
+ onNavigateToGallery = {
+ navController.navigateToGallery(imageLimit = 1)
+ },
+ onSignUpSuccess = {
+ navController.navigateHome(
+ navOptions =
+ navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ },
+ )
+ },
+ )
+ registerPostNavGraph(
+ onShowSnackBar = onShowSnackBar,
+ onCompleteRegisterPost = { postId ->
+ navController.navigateToPostDetail(
+ postId,
+ navOptions = navOptions {
+ // 수정일 경우 : 이전 화면이 상세화면이다
+ if (navController.previousBackStackEntry?.destination?.route == POST_DETAIL_ROUTE_WITH_ARGUMENT) {
+ popUpTo(POST_LIST_ROUTE) {
+ inclusive = false
+ }
+ } else {
+ // 새로 등록인 경우
+ popUpTo(REGISTER_POST_ROUTE) {
+ inclusive = true
+ }
+ }
+ },
+ )
+ },
+ onClickBackButton = navController::popBackStack,
+ onNavigateToGallery = { imageLimit, imageCount ->
+ navController.navigateToGallery(
+ imageLimit = imageLimit,
+ currentImageCount = imageCount,
+ )
+ },
+ originPost = navController.previousBackStackEntry?.savedStateHandle?.get(
+ REGISTER_POST_ARGUMENT,
+ ),
+ onAuthExpired = {
+ onAuthExpired(onShowSnackBar, navController)
+ },
+ )
+ galleryNavGraph(
+ onClickBackButton = {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ IMAGE_LIST_ARGUMENT,
+ emptyList(),
+ )
+ navController.popBackStack()
+ },
+ onCompleteRegisterImages = {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ IMAGE_LIST_ARGUMENT,
+ it,
+ )
+ navController.popBackStack()
+ },
+ onShowSnackBar = onShowSnackBar,
+ )
+ homeNavGraph(onShowSnackBar)
+ navigation(startDestination = MY_PAGE_ROUTE, MY_PAGE_NESTED_ROUTE) {
+ myPageNavGraph(
+ onShowSnackBar = onShowSnackBar,
+ onEditProfile = { nickname, profileImageUrl ->
+ navController.navigateProfileEditor(
+ nickname = nickname,
+ profileImageUrl = profileImageUrl,
+ )
+ },
+ onLogoutSuccess = {
+ navController.navigateLogin(
+ navOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ },
+ )
+ },
+ onWithdrawSuccess = {
+ navController.navigateLogin(
+ navOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ },
+ )
+ },
+ onAuthExpired = {
+ onAuthExpired(onShowSnackBar, navController)
+ },
+ )
+ profileEditorNavGraph(
+ onShowSnackBar = onShowSnackBar,
+ onClickBackButton = {
+ navController.popBackStack()
+ },
+ onNavigateToGallery = {
+ navController.navigateToGallery(imageLimit = 1)
+ },
+ onAuthExpired = {
+ onAuthExpired(onShowSnackBar, navController)
+ },
+ onUpdateSuccess = { nickname, imageUrl ->
+ navController.previousBackStackEntry?.savedStateHandle?.apply {
+ set(MY_PAGE_CHANGED_NICKNAME_ARGUMENT, nickname)
+ set(MY_PAGE_CHANGED_IMAGE_ARGUMENT, imageUrl)
+ }
+ navController.popBackStack()
+
+ },
+ )
+ }
+ postDetailGraph(
+ onShowSnackBar = onShowSnackBar,
+ onClickBackButton = navController::popBackStack,
+ onClickEditButton = {
+ navController.currentBackStackEntry?.savedStateHandle?.set(
+ key = REGISTER_POST_ARGUMENT,
+ value = it,
+ )
+ navController.navigateToRegisterPost()
+ },
+ onAuthExpired = {
+ onAuthExpired(onShowSnackBar, navController)
+ },
+ )
+ postListGraph(
+ onShowSnackBar = onShowSnackBar,
+ navigateToPostDetail = navController::navigateToPostDetail,
+ onAuthExpired = {
+ onAuthExpired(onShowSnackBar, navController)
+ },
+ )
+ }
+}
+
+private fun onAuthExpired(
+ onShowSnackBar: (message: String) -> Unit,
+ navController: NavHostController,
+) {
+ onShowSnackBar("세션이 만료되었습니다. 로그인 후 다시 시도해 주세요.")
+ navController.navigateLogin(
+ navOptions = navOptions {
+ popUpTo(navController.graph.id) {
+ inclusive = true
+ }
+ },
+ )
+}
+
+const val POST_NESTED_ROUTE = "post_nested_route"
+const val MY_PAGE_NESTED_ROUTE = "my_page_nested_route"
diff --git a/app/src/main/res/drawable/ic_app_logo_foreground.xml b/app/src/main/res/drawable/ic_app_logo_foreground.xml
new file mode 100644
index 00000000..f698d98f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_app_logo_foreground.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_home.xml b/app/src/main/res/drawable/ic_bottom_home.xml
new file mode 100644
index 00000000..867e85f0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_home.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_home_select.xml b/app/src/main/res/drawable/ic_bottom_home_select.xml
new file mode 100644
index 00000000..aabc8c78
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_home_select.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_my_page.xml b/app/src/main/res/drawable/ic_bottom_my_page.xml
new file mode 100644
index 00000000..275242c3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_my_page.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_my_page_select.xml b/app/src/main/res/drawable/ic_bottom_my_page_select.xml
new file mode 100644
index 00000000..260275af
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_my_page_select.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_post.xml b/app/src/main/res/drawable/ic_bottom_post.xml
new file mode 100644
index 00000000..869c6014
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_post.xml
@@ -0,0 +1,20 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_post_register.xml b/app/src/main/res/drawable/ic_bottom_post_register.xml
new file mode 100644
index 00000000..cb4f4241
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_post_register.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bottom_post_select.xml b/app/src/main/res/drawable/ic_bottom_post_select.xml
new file mode 100644
index 00000000..7650aeaa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bottom_post_select.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..07d5da9c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..2b068d11
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml
new file mode 100644
index 00000000..cb4f4241
--- /dev/null
+++ b/app/src/main/res/drawable/ic_upload.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/splash_inset.xml b/app/src/main/res/drawable/splash_inset.xml
new file mode 100644
index 00000000..a2701bfc
--- /dev/null
+++ b/app/src/main/res/drawable/splash_inset.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_app_logo.xml b/app/src/main/res/mipmap-anydpi-v26/ic_app_logo.xml
new file mode 100644
index 00000000..d7971328
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_app_logo.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_app_logo_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_app_logo_round.xml
new file mode 100644
index 00000000..d7971328
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_app_logo_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..6f3b755b
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_app_logo.webp b/app/src/main/res/mipmap-hdpi/ic_app_logo.webp
new file mode 100644
index 00000000..c7aa54f3
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_app_logo.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_app_logo_round.webp b/app/src/main/res/mipmap-hdpi/ic_app_logo_round.webp
new file mode 100644
index 00000000..d5c40d7d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_app_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_app_logo.webp b/app/src/main/res/mipmap-mdpi/ic_app_logo.webp
new file mode 100644
index 00000000..cd316349
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_app_logo.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_app_logo_round.webp b/app/src/main/res/mipmap-mdpi/ic_app_logo_round.webp
new file mode 100644
index 00000000..e6f48ff2
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_app_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_app_logo.webp b/app/src/main/res/mipmap-xhdpi/ic_app_logo.webp
new file mode 100644
index 00000000..ab35ea76
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_app_logo.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_app_logo_round.webp b/app/src/main/res/mipmap-xhdpi/ic_app_logo_round.webp
new file mode 100644
index 00000000..de69c399
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_app_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_app_logo.webp b/app/src/main/res/mipmap-xxhdpi/ic_app_logo.webp
new file mode 100644
index 00000000..570a9038
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_app_logo.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_app_logo_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_app_logo_round.webp
new file mode 100644
index 00000000..70922adc
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_app_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_app_logo.webp b/app/src/main/res/mipmap-xxxhdpi/ic_app_logo.webp
new file mode 100644
index 00000000..8effcbe6
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_app_logo.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_app_logo_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_app_logo_round.webp
new file mode 100644
index 00000000..89f6eb8c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_app_logo_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f8c6127d
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_app_logo_background.xml b/app/src/main/res/values/ic_app_logo_background.xml
new file mode 100644
index 00000000..3858c442
--- /dev/null
+++ b/app/src/main/res/values/ic_app_logo_background.xml
@@ -0,0 +1,4 @@
+
+
+ #E8E8FC
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..85131293
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+ 청하
+ 홈
+ 게시판
+ 마이페이지
+ 등록
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..60ba5a5a
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 00000000..fa0f996d
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 00000000..9ee9997b
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/withpeace/withpeace/MainViewModelTest.kt b/app/src/test/java/com/withpeace/withpeace/MainViewModelTest.kt
new file mode 100644
index 00000000..cca87298
--- /dev/null
+++ b/app/src/test/java/com/withpeace/withpeace/MainViewModelTest.kt
@@ -0,0 +1,49 @@
+package com.withpeace.withpeace
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.usecase.IsLoginUseCase
+import com.withpeace.withpeace.core.testing.MainDispatcherRule
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class MainViewModelTest {
+
+ @get:Rule
+ val dispatcherRule = MainDispatcherRule()
+
+ private lateinit var mainViewModel: MainViewModel
+ private val isLoginUseCase: IsLoginUseCase = mockk()
+ private fun initialize(): MainViewModel {
+ return MainViewModel(isLoginUseCase)
+ }
+
+ @Test
+ fun `현재 로그인된 상태가 아니라면, 로그인 상태가 아니다`() = runTest {
+ // given
+ coEvery { isLoginUseCase() } returns false
+ mainViewModel = initialize()
+
+ // when && test
+ mainViewModel.isLogin.test {
+ val actual = awaitItem()
+ assertThat(actual).isFalse()
+ }
+ }
+
+ @Test
+ fun `현재 로그인된 상태라면, 로그인 상태이다`() = runTest {
+ // given
+ coEvery { isLoginUseCase() } returns true
+ mainViewModel = initialize()
+
+ // when && test
+ mainViewModel.isLogin.test {
+ val actual = awaitItem()
+ assertThat(actual).isTrue()
+ }
+ }
+}
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts
new file mode 100644
index 00000000..4c7fe412
--- /dev/null
+++ b/build-logic/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ `kotlin-dsl`
+ `kotlin-dsl-precompiled-script-plugins`
+}
+
+dependencies {
+ implementation(libs.android.gradlePlugin)
+ implementation(libs.kotlin.gradlePlugin)
+ implementation(libs.hilt.gradlePlugin)
+}
+
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
new file mode 100644
index 00000000..77de64b6
--- /dev/null
+++ b/build-logic/settings.gradle.kts
@@ -0,0 +1,18 @@
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+@Suppress("UnstableApiUsage")
+dependencyResolutionManagement {
+ versionCatalogs {
+ create("libs") {
+ from(files("../gradle/libs.versions.toml"))
+ }
+ }
+}
+
+rootProject.name = "build-logic"
diff --git a/build-logic/src/main/kotlin/Extension.kt b/build-logic/src/main/kotlin/Extension.kt
new file mode 100644
index 00000000..42f44d39
--- /dev/null
+++ b/build-logic/src/main/kotlin/Extension.kt
@@ -0,0 +1,23 @@
+import com.android.build.api.dsl.CommonExtension
+import org.gradle.api.Project
+import org.gradle.api.artifacts.VersionCatalog
+import org.gradle.api.artifacts.VersionCatalogsExtension
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.kotlin.dsl.getByType
+
+internal fun Project.android(action: CommonExtension<*, *, *, *, *>.() -> Unit) {
+ val androidExtension = extensions.getByName("android")
+ if (androidExtension is CommonExtension<*, *, *, *, *>) {
+ androidExtension.apply(action)
+ }
+}
+
+internal fun Project.java(action: JavaPluginExtension.() -> Unit) {
+ val javaPluginExtension = extensions.getByType()
+ javaPluginExtension.apply {
+ action()
+ }
+}
+
+internal val Project.libs
+ get(): VersionCatalog = extensions.getByType().named("libs")
diff --git a/build-logic/src/main/kotlin/convention.android.base.gradle.kts b/build-logic/src/main/kotlin/convention.android.base.gradle.kts
new file mode 100644
index 00000000..0d81001b
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.android.base.gradle.kts
@@ -0,0 +1,31 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ kotlin("android")
+}
+android {
+ compileSdk = 34
+ defaultConfig {
+ minSdk = 26
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ tasks.withType().configureEach {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ "androidTestImplementation"(libs.findLibrary("androidx.test.ext").get())
+ "androidTestImplementation"(libs.findLibrary("androidx-test-espresso-core").get())
+ "androidTestImplementation"(libs.findLibrary("junit4").get())
+ "debugImplementation"(libs.findLibrary("androidx-test-core").get())
+}
diff --git a/build-logic/src/main/kotlin/convention.android.compose.gradle.kts b/build-logic/src/main/kotlin/convention.android.compose.gradle.kts
new file mode 100644
index 00000000..79c6314b
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.android.compose.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("convention.android.base")
+}
+android {
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion =
+ libs.findVersion("androidxComposeCompiler").get().toString()
+ }
+}
+
+dependencies {
+ val bom = libs.findLibrary("androidx-compose-bom").get()
+ "implementation"(platform(bom))
+ "androidTestImplementation"(platform(bom))
+
+ "implementation"(libs.findLibrary("androidx.activity.compose").get())
+ "implementation"(libs.findLibrary("androidx.compose.material3").get())
+ "implementation"(libs.findLibrary("androidx.constraintlayout").get())
+ "implementation"(libs.findLibrary("androidx.compose.ui").get())
+ "implementation"(libs.findLibrary("androidx.compose.ui.tooling.preview").get())
+ "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
+ "implementation"(libs.findLibrary("androidx.compose.icon").get())
+ "implementation"(libs.findLibrary("androidx.compose.navigation").get())
+
+ "androidTestImplementation"(libs.findLibrary("androidx.compose.ui.test").get())
+ "androidTestImplementation"(libs.findLibrary("androidx-compose-navigation-test").get())
+
+ "debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
+ "debugImplementation"(libs.findLibrary("androidx-compose-ui-testManifest").get())
+}
diff --git a/build-logic/src/main/kotlin/convention.android.hilt.gradle.kts b/build-logic/src/main/kotlin/convention.android.hilt.gradle.kts
new file mode 100644
index 00000000..f04e2c6c
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.android.hilt.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ id("dagger.hilt.android.plugin")
+ kotlin("kapt")
+}
+
+dependencies {
+ "implementation"(libs.findLibrary("hilt.core").get())
+ "implementation"(libs.findLibrary("hilt.android").get())
+ "implementation"(libs.findLibrary("hilt.navigation.compose").get())
+ "implementation"(libs.findLibrary("hilt.android.testing").get())
+ "kapt"(libs.findLibrary("hilt.android.compiler").get())
+ "kaptAndroidTest"(libs.findLibrary("hilt.android.compiler").get())
+ "androidTestImplementation"(libs.findLibrary("hilt.android.testing").get())
+}
diff --git a/build-logic/src/main/kotlin/convention.application.gradle.kts b/build-logic/src/main/kotlin/convention.application.gradle.kts
new file mode 100644
index 00000000..b822c8d9
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.application.gradle.kts
@@ -0,0 +1,10 @@
+plugins {
+ id("com.android.application")
+ id("convention.android.compose")
+ id("convention.android.hilt")
+ id("convention.test.library")
+ id("convention.coroutine")
+ id("kotlin-parcelize")
+ id ("kotlin-kapt")
+}
+
diff --git a/build-logic/src/main/kotlin/convention.coroutine.gradle.kts b/build-logic/src/main/kotlin/convention.coroutine.gradle.kts
new file mode 100644
index 00000000..52838623
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.coroutine.gradle.kts
@@ -0,0 +1,6 @@
+dependencies {
+ "implementation"(libs.findLibrary("coroutines.core").get())
+ "implementation"(libs.findLibrary("coroutines.android").get())
+ "testImplementation"(libs.findLibrary("coroutines.android").get())
+ "testImplementation"(libs.findLibrary("coroutines.test").get())
+}
diff --git a/build-logic/src/main/kotlin/convention.data.gradle.kts b/build-logic/src/main/kotlin/convention.data.gradle.kts
new file mode 100644
index 00000000..bf34df86
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.data.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.test.library")
+ id("convention.coroutine")
+ id("convention.android.hilt")
+}
diff --git a/build-logic/src/main/kotlin/convention.feature.gradle.kts b/build-logic/src/main/kotlin/convention.feature.gradle.kts
new file mode 100644
index 00000000..b0b6ffd7
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.feature.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.test.library")
+ id("convention.coroutine")
+ id("convention.android.compose")
+ id("convention.android.hilt")
+ id("kotlin-parcelize")
+ id ("kotlin-kapt")
+}
+
+dependencies{
+ implementation(project(":core:data"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:designsystem"))
+ implementation(project(":core:ui"))
+ testImplementation(project(":core:testing"))
+}
diff --git a/build-logic/src/main/kotlin/convention.kotlin.library.gradle.kts b/build-logic/src/main/kotlin/convention.kotlin.library.gradle.kts
new file mode 100644
index 00000000..0ae81841
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.kotlin.library.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ kotlin("jvm")
+ id("convention.coroutine")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
diff --git a/build-logic/src/main/kotlin/convention.test.library.gradle.kts b/build-logic/src/main/kotlin/convention.test.library.gradle.kts
new file mode 100644
index 00000000..7669cb7f
--- /dev/null
+++ b/build-logic/src/main/kotlin/convention.test.library.gradle.kts
@@ -0,0 +1,6 @@
+dependencies {
+ "testImplementation"(libs.findLibrary("junit4").get())
+ "testImplementation"(libs.findLibrary("mockk").get())
+ "testImplementation"(libs.findLibrary("truth").get())
+ "testImplementation"(libs.findLibrary("turbine").get())
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 00000000..b96f6b1a
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,14 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.jvm) apply false
+ alias(libs.plugins.kotlin.parcelize) apply false
+ alias(libs.plugins.ktlint)
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.hilt) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.dependency.graph) apply true
+ alias(libs.plugins.firebase.services) apply false
+ alias(libs.plugins.firebase.crashlytics) apply false
+}
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..c3b10daa
--- /dev/null
+++ b/core/data/build.gradle.kts
@@ -0,0 +1,43 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
+fun getLocalPropertyString(propertyKey: String): String {
+ return gradleLocalProperties(rootDir).getProperty(propertyKey)
+}
+
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.hilt")
+ id("convention.coroutine")
+ id("convention.test.library")
+}
+
+android {
+ defaultConfig {
+ consumerProguardFiles("consumer-rules.pro")
+
+ buildConfigField(
+ "String",
+ "YOUTH_POLICY_API_KEY",
+ getLocalPropertyString("YOUTH_POLICY_API_KEY"),
+ )
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ //https://developer.android.com/reference/tools/gradle-api/4.1/com/android/build/api/dsl/UnitTestOptions#isreturndefaultvalues
+ }
+ namespace = "com.withpeace.withpeace.core.data"
+}
+
+dependencies {
+ implementation(project(":core:network"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:datastore"))
+ implementation(project(":core:imagestorage"))
+ implementation(project(":core:testing"))
+ implementation(libs.skydoves.sandwich)
+ implementation(libs.androidx.paging)
+ testImplementation(libs.androidx.paging.testing)
+ implementation("androidx.exifinterface:exifinterface:1.3.7")
+}
diff --git a/core/data/consumer-rules.pro b/core/data/consumer-rules.pro
new file mode 100644
index 00000000..e23983da
--- /dev/null
+++ b/core/data/consumer-rules.pro
@@ -0,0 +1 @@
+-keep class com.skydoves.sandwich.** {*;}
diff --git a/core/data/proguard-rules.pro b/core/data/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/data/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..44008a43
--- /dev/null
+++ b/core/data/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt
new file mode 100644
index 00000000..542c8fb5
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt
@@ -0,0 +1,46 @@
+package com.withpeace.withpeace.core.data.di
+
+import com.withpeace.withpeace.core.data.repository.DefaultImageRepository
+import com.withpeace.withpeace.core.data.repository.DefaultPostRepository
+import com.withpeace.withpeace.core.data.repository.DefaultTokenRepository
+import com.withpeace.withpeace.core.data.repository.DefaultUserRepository
+import com.withpeace.withpeace.core.data.repository.DefaultYouthPolicyRepository
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface RepositoryModule {
+
+ @Binds
+ @Singleton
+ fun bindsTokenRepository(defaultTokenRepository: DefaultTokenRepository): TokenRepository
+
+ @Binds
+ @Singleton
+ fun bindsImageRepository(defaultImageRepository: DefaultImageRepository): ImageRepository
+
+ @Binds
+ @Singleton
+ fun bindsPostRepository(defaultPostRepository: DefaultPostRepository): PostRepository
+
+ @Binds
+ @Singleton
+ fun bindsUserRepository(
+ defaultUserRepository: DefaultUserRepository,
+ ): UserRepository
+
+ @Binds
+ @Singleton
+ fun bindsYouthPolicyRepository(
+ defaultYouthPolicyRepository: DefaultYouthPolicyRepository
+ ): YouthPolicyRepository
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ChangedProfileMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ChangedProfileMapper.kt
new file mode 100644
index 00000000..3122c492
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ChangedProfileMapper.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile
+import com.withpeace.withpeace.core.domain.model.profile.Nickname
+import com.withpeace.withpeace.core.network.di.response.ChangedProfileResponse
+
+fun ChangedProfileResponse.toDomain(): ChangedProfile {
+ return ChangedProfile(
+ nickname = Nickname(this.nickname),
+ profileImageUrl = profileImageUrl,
+ )
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/DateMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/DateMapper.kt
new file mode 100644
index 00000000..4af8f33e
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/DateMapper.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+fun String.toLocalDateTime(): LocalDateTime = LocalDateTime.parse(
+ this,
+ DateTimeFormatter.ofPattern(SERVER_DATE_FORMAT),
+)
+
+private const val SERVER_DATE_FORMAT = "yyyy/MM/dd HH:mm:ss"
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ImageInfoMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ImageInfoMapper.kt
new file mode 100644
index 00000000..f4c389b9
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ImageInfoMapper.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.imagestorage.ImageInfoEntity
+
+fun ImageInfoEntity.toDomain(): ImageInfo {
+ return ImageInfo(
+ uri = imageUri.toString(),
+ mimeType = mimeType,
+ byteSize = byteSize,
+ )
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostDetailMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostDetailMapper.kt
new file mode 100644
index 00000000..75b6737a
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostDetailMapper.kt
@@ -0,0 +1,37 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.date.Date
+import com.withpeace.withpeace.core.domain.model.post.Comment
+import com.withpeace.withpeace.core.domain.model.post.CommentUser
+import com.withpeace.withpeace.core.domain.model.post.PostContent
+import com.withpeace.withpeace.core.domain.model.post.PostDetail
+import com.withpeace.withpeace.core.domain.model.post.PostTitle
+import com.withpeace.withpeace.core.domain.model.post.PostUser
+import com.withpeace.withpeace.core.network.di.response.post.CommentResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostDetailResponse
+
+fun PostDetailResponse.toDomain() = PostDetail(
+ user = PostUser(
+ id = userId,
+ name = nickname,
+ profileImageUrl = profileImageUrl,
+ ),
+ id = postId,
+ title = PostTitle(title),
+ content = PostContent(content),
+ postTopic = type.toDomain(),
+ imageUrls = postImageUrls,
+ createDate = Date(createDate.toLocalDateTime()),
+ comments = comments.map { it.toDomain() },
+)
+
+fun CommentResponse.toDomain() = Comment(
+ commentId = commentId,
+ content = content,
+ createDate = Date(createDate.toLocalDateTime()),
+ commentUser = CommentUser(
+ id = userId,
+ nickname = nickname,
+ profileImageUrl = profileImageUrl,
+ ),
+)
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostMapper.kt
new file mode 100644
index 00000000..db403598
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostMapper.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.date.Date
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.network.di.response.post.PostResponse
+
+fun PostResponse.toDomain() =
+ Post(
+ postId = postId,
+ title = title,
+ content = content,
+ postTopic = type.toDomain(),
+ createDate = Date(createDate.toLocalDateTime()),
+ postImageUrl = postImageUrl,
+ )
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostTopicMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostTopicMapper.kt
new file mode 100644
index 00000000..3624f22c
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/PostTopicMapper.kt
@@ -0,0 +1,21 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.ECONOMY
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.FREEDOM
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.HOBBY
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.INFORMATION
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.LIVING
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.QUESTION
+
+fun PostTopicResponse.toDomain(): PostTopic {
+ return when (this) {
+ FREEDOM -> PostTopic.FREEDOM
+ INFORMATION -> PostTopic.INFORMATION
+ QUESTION -> PostTopic.QUESTION
+ LIVING -> PostTopic.LIVING
+ HOBBY -> PostTopic.HOBBY
+ ECONOMY -> PostTopic.ECONOMY
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ProfileInfoMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ProfileInfoMapper.kt
new file mode 100644
index 00000000..de9a2080
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/ProfileInfoMapper.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.profile.Nickname
+import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo
+import com.withpeace.withpeace.core.network.di.response.ProfileResponse
+
+fun ProfileResponse.toDomain(): ProfileInfo {
+ return ProfileInfo(
+ nickname = Nickname(nickname),
+ profileImageUrl = profileImageUrl,
+ email = email,
+ )
+}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt
new file mode 100644
index 00000000..b37af56b
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RoleMapper.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.role.Role
+
+internal fun String.roleToDomain(): Role {
+ return when (this) {
+ "GUEST" -> Role.GUEST
+ "USER" -> Role.USER
+ else -> Role.UNKNOWN
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/YouthPolicyMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/YouthPolicyMapper.kt
new file mode 100644
index 00000000..71e18eb5
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/YouthPolicyMapper.kt
@@ -0,0 +1,89 @@
+package com.withpeace.withpeace.core.data.mapper
+
+import com.withpeace.withpeace.core.domain.model.policy.PolicyClassification
+import com.withpeace.withpeace.core.domain.model.policy.PolicyRegion
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import com.withpeace.withpeace.core.network.di.response.YouthPolicyEntity
+
+fun YouthPolicyEntity.toDomain(): YouthPolicy {
+ return YouthPolicy(
+ id = id,
+ title = title ?: "",
+ introduce = introduce ?: "",
+ region = regionCode.codeToRegion(),
+ policyClassification = classification.codeToPolicyClassification(),
+ ageInfo = ageInfo ?: "",
+ )
+}
+
+private fun String?.codeToRegion(): PolicyRegion {
+ if (this?.slice(0..5) == "003001") {
+ return PolicyRegion.중앙부처
+ }
+ return when (this?.slice(0..8)) {
+ "003002001" -> PolicyRegion.서울
+ "003002002" -> PolicyRegion.부산
+ "003002003" -> PolicyRegion.대구
+ "003002004" -> PolicyRegion.인천
+ "003002005" -> PolicyRegion.광주
+ "003002006" -> PolicyRegion.대전
+ "003002007" -> PolicyRegion.울산
+ "003002008" -> PolicyRegion.경기
+ "003002009" -> PolicyRegion.강원
+ "003002010" -> PolicyRegion.충북
+ "003002011" -> PolicyRegion.충남
+ "003002012" -> PolicyRegion.전북
+ "003002013" -> PolicyRegion.전남
+ "003002014" -> PolicyRegion.경북
+ "003002015" -> PolicyRegion.경남
+ "003002016" -> PolicyRegion.제주
+ "003002017" -> PolicyRegion.세종
+ else -> PolicyRegion.기타
+ }
+}
+
+fun PolicyRegion.toCode(): String {
+ return when (this) {
+ PolicyRegion.중앙부처 -> "003001"
+ PolicyRegion.서울 -> "003002001"
+ PolicyRegion.부산 -> "003002002"
+ PolicyRegion.대구 -> "003002003"
+ PolicyRegion.인천 -> "003002004"
+ PolicyRegion.광주 -> "003002005"
+ PolicyRegion.대전 -> "003002006"
+ PolicyRegion.울산 -> "003002007"
+ PolicyRegion.경기 -> "003002008"
+ PolicyRegion.강원 -> "003002009"
+ PolicyRegion.충북 -> "003002010"
+ PolicyRegion.충남 -> "003002011"
+ PolicyRegion.전북 -> "003002012"
+ PolicyRegion.전남 -> "003002013"
+ PolicyRegion.경북 -> "003002014"
+ PolicyRegion.경남 -> "003002015"
+ PolicyRegion.제주 -> "003002016"
+ PolicyRegion.세종 -> "003002017"
+ PolicyRegion.기타 -> throw IllegalStateException("")
+ }
+}
+
+fun String?.codeToPolicyClassification(): PolicyClassification {
+ return when (this) {
+ "023010" -> PolicyClassification.JOB
+ "023020" -> PolicyClassification.RESIDENT
+ "023030" -> PolicyClassification.EDUCATION
+ "023040" -> PolicyClassification.WELFARE_AND_CULTURE
+ "023050" -> PolicyClassification.PARTICIPATION_AND_RIGHT
+ else -> PolicyClassification.ETC
+ }
+}
+
+fun PolicyClassification.toCode(): String {
+ return when (this) {
+ PolicyClassification.JOB -> "023010"
+ PolicyClassification.RESIDENT -> "023020"
+ PolicyClassification.EDUCATION -> "023030"
+ PolicyClassification.WELFARE_AND_CULTURE -> "023040"
+ PolicyClassification.PARTICIPATION_AND_RIGHT -> "023050"
+ PolicyClassification.ETC -> throw IllegalStateException("")
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSource.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSource.kt
new file mode 100644
index 00000000..4d9c550b
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSource.kt
@@ -0,0 +1,43 @@
+package com.withpeace.withpeace.core.data.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.imagestorage.ImageDataSource
+
+data class ImagePagingSource(
+ private val imageDataSource: ImageDataSource,
+ private val folderName: String?,
+ private val pageSize: Int,
+) : PagingSource() {
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: STARTING_PAGE_INDEX
+ val data = imageDataSource.getImages(
+ page = currentPage,
+ loadSize = params.loadSize,
+ folder = folderName,
+ ).map { it.toDomain() }
+ val endOfPaginationReached = data.isEmpty()
+ val prevKey = if (currentPage == STARTING_PAGE_INDEX) null else currentPage - 1
+ val nextKey =
+ if (endOfPaginationReached) null else currentPage + (params.loadSize / pageSize)
+ LoadResult.Page(data, prevKey, nextKey)
+ } catch (exception: Exception) {
+ LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { achorPosition ->
+ state.closestPageToPosition(achorPosition)?.prevKey?.plus(1)
+ ?: state.closestPageToPosition(achorPosition)?.nextKey?.minus(1)
+ }
+ }
+
+ companion object {
+ const val STARTING_PAGE_INDEX = 1
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSource.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSource.kt
new file mode 100644
index 00000000..efb08ddc
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSource.kt
@@ -0,0 +1,85 @@
+package com.withpeace.withpeace.core.data.paging
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.skydoves.sandwich.suspendMapSuccess
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.data.util.handleApiFailure
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import com.withpeace.withpeace.core.network.di.service.PostService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+
+class PostPagingSource(
+ private val postService: PostService,
+ private val userRepository: UserRepository,
+ private val postTopic: PostTopic,
+ private val pageSize: Int,
+ private val onError: suspend (CheonghaError) -> Unit,
+) : PagingSource() {
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val currentPage = params.key ?: STARTING_PAGE_INDEX
+ val data = getPosts(
+ postTopic = postTopic,
+ pageIndex = currentPage,
+ pageSize = params.loadSize,
+ onError = onError,
+ ).firstOrNull() ?: emptyList()
+ val endOfPaginationReached = data.isEmpty()
+ val prevKey = if (currentPage == STARTING_PAGE_INDEX) null else currentPage - 1
+ val nextKey =
+ if (endOfPaginationReached) null else currentPage + (params.loadSize / pageSize)
+ LoadResult.Page(data, prevKey, nextKey)
+ } catch (exception: Exception) {
+ LoadResult.Error(exception)
+ }
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition
+ }
+
+ private fun getPosts(
+ postTopic: PostTopic,
+ pageIndex: Int,
+ pageSize: Int,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow> =
+ flow {
+ postService.getPosts(
+ postTopic = postTopic.name,
+ pageIndex = pageIndex, pageSize = pageSize,
+ ).suspendMapSuccess {
+ emit(data.map { it.toDomain() })
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }.flowOn(Dispatchers.IO)
+
+ private suspend fun onErrorWithAuthExpired(
+ it: ResponseError,
+ onError: suspend (CheonghaError) -> Unit,
+ ) {
+ if (it == ResponseError.INVALID_TOKEN_ERROR) {
+ userRepository.logout(onError).collect {
+ onError(ClientError.AuthExpired)
+ }
+ } else {
+ onError(it)
+ }
+ }
+
+ companion object {
+ const val STARTING_PAGE_INDEX = 0
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/YouthPolicyPagingSource.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/YouthPolicyPagingSource.kt
new file mode 100644
index 00000000..63020aae
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/YouthPolicyPagingSource.kt
@@ -0,0 +1,61 @@
+package com.withpeace.withpeace.core.data.paging
+
+import android.util.Log
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.data.BuildConfig
+import com.withpeace.withpeace.core.data.mapper.toCode
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import com.withpeace.withpeace.core.network.di.service.YouthPolicyService
+
+class YouthPolicyPagingSource(
+ private val pageSize: Int,
+ private val youthPolicyService: YouthPolicyService,
+ private val filterInfo: PolicyFilters,
+ private val onError: suspend (CheonghaError) -> Unit,
+) :
+ PagingSource() {
+ override suspend fun load(params: LoadParams): LoadResult {
+ val pageIndex = params.key ?: 1
+ val response = youthPolicyService.getPolicies(
+ apiKey = BuildConfig.YOUTH_POLICY_API_KEY,
+ pageSize = params.loadSize,
+ pageIndex = pageIndex,
+ classification = filterInfo.classifications.joinToString(",") { it.toCode() },
+ region = filterInfo.regions.joinToString(",") { it.toCode() },
+ )
+
+
+
+ if (response is ApiResponse.Failure) {
+ val error: CheonghaError =
+ if (response is ApiResponse.Failure.Exception) ResponseError.HTTP_EXCEPTION_ERROR
+ else ResponseError.UNKNOWN_ERROR //TODO("오류에 대해서 Firebase Logging")
+ onError(error)
+ return LoadResult.Error(IllegalStateException("API response error that $error"))
+ }
+
+ val data = (response as ApiResponse.Success).data
+ return LoadResult.Page(
+ data = data.youthPolicyEntity.map { it.toDomain() },
+ prevKey = if (pageIndex == STARTING_PAGE_INDEX) null else pageIndex - 1,
+ nextKey = if (response.data.youthPolicyEntity.isEmpty()) null else pageIndex + (params.loadSize / pageSize),
+ )
+ }
+
+ override fun getRefreshKey(state: PagingState): Int? { // 현재 포지션에서 Refresh pageKey 설정
+ return state.anchorPosition?.let { anchorPosition ->
+ val anchorPage = state.closestPageToPosition(anchorPosition)
+ anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+ }
+ }
+
+ companion object {
+ private const val STARTING_PAGE_INDEX = 1
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultImageRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultImageRepository.kt
new file mode 100644
index 00000000..ee678aa0
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultImageRepository.kt
@@ -0,0 +1,44 @@
+package com.withpeace.withpeace.core.data.repository
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.data.paging.ImagePagingSource
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import com.withpeace.withpeace.core.imagestorage.ImageDataSource
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class DefaultImageRepository @Inject constructor(
+ private val imageDataSource: ImageDataSource,
+) : ImageRepository {
+ override suspend fun getFolders(): List =
+ withContext(Dispatchers.IO) {
+ imageDataSource.getFolders().map {
+ ImageFolder(
+ it.folderName,
+ it.representativeImageUri.toString(),
+ it.count,
+ )
+ }
+ }
+
+ override fun getImages(
+ folderName: String?,
+ pageSize: Int,
+ ): Flow> =
+ Pager(
+ config = PagingConfig(pageSize),
+ pagingSourceFactory = {
+ ImagePagingSource(
+ imageDataSource = imageDataSource,
+ folderName = folderName,
+ pageSize = pageSize,
+ )
+ },
+ ).flow
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt
new file mode 100644
index 00000000..0c7e77fc
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt
@@ -0,0 +1,189 @@
+package com.withpeace.withpeace.core.data.repository
+
+import android.content.Context
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.skydoves.sandwich.suspendMapSuccess
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.data.paging.PostPagingSource
+import com.withpeace.withpeace.core.data.util.convertToFile
+import com.withpeace.withpeace.core.data.util.handleApiFailure
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostDetail
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.model.post.ReportType
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import com.withpeace.withpeace.core.network.di.request.CommentRequest
+import com.withpeace.withpeace.core.network.di.request.ReportTypeRequest
+import com.withpeace.withpeace.core.network.di.service.PostService
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import javax.inject.Inject
+
+class DefaultPostRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val postService: PostService,
+ private val userRepository: UserRepository,
+) : PostRepository {
+ override fun getPosts(
+ postTopic: PostTopic,
+ pageSize: Int,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow> = Pager(
+ config = PagingConfig(pageSize),
+ pagingSourceFactory = {
+ PostPagingSource(
+ postService = postService,
+ postTopic = postTopic,
+ pageSize = pageSize,
+ onError = onError,
+ userRepository = userRepository,
+ )
+ },
+ ).flow
+
+ override fun registerPost(
+ post: RegisterPost,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow =
+ flow {
+ val imageRequestBodies = getImageRequestBodies(post.images.urls)
+ val postRequestBodies = getPostRequestBodies(post)
+ if (post.id == null) {
+ postService.registerPost(postRequestBodies, imageRequestBodies)
+ .suspendMapSuccess {
+ emit(data.postId)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ } else {
+ postService.editPost(post.id!!, postRequestBodies, imageRequestBodies)
+ .suspendMapSuccess {
+ emit(data.postId)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+ }.flowOn(Dispatchers.IO)
+
+ override fun getPostDetail(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ postService.getPost(postId)
+ .suspendMapSuccess {
+ emit(data.toDomain())
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun deletePost(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ postService.deletePost(postId).suspendMapSuccess {
+ emit(data)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun registerComment(
+ postId: Long,
+ content: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ postService.registerComment(postId = postId, CommentRequest(content))
+ .suspendMapSuccess {
+ emit(data)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun reportPost(
+ postId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ postService.reportPost(postId = postId, ReportTypeRequest(reportType.name))
+ .suspendMapSuccess {
+ emit(data)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun reportComment(
+ commentId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ postService.reportComment(commentId = commentId, ReportTypeRequest(reportType.name))
+ .suspendMapSuccess {
+ emit(data)
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ private fun getImageRequestBodies(
+ imageUris: List,
+ ): List {
+ val imageFiles = imageUris.map { it.convertToFile(context) }
+ return imageFiles.map { file ->
+ val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
+ MultipartBody.Part.createFormData(
+ IMAGES_COLUMN,
+ file.name,
+ requestFile,
+ )
+ }
+ }
+
+ private fun getPostRequestBodies(post: RegisterPost): HashMap {
+ return HashMap().apply {
+ set(
+ TYPE_COLUMN,
+ post.topic.toString().toRequestBody("application/json".toMediaTypeOrNull()),
+ )
+ set(TITLE_COLUMN, post.title.toRequestBody("application/json".toMediaTypeOrNull()))
+ set(CONTENT_COLUMN, post.content.toRequestBody("application/json".toMediaTypeOrNull()))
+ }
+ }
+
+ private suspend fun onErrorWithAuthExpired(
+ it: ResponseError,
+ onError: suspend (CheonghaError) -> Unit,
+ ) {
+ if (it == ResponseError.INVALID_TOKEN_ERROR) {
+ userRepository.logout(onError).collect {
+ onError(ClientError.AuthExpired)
+ }
+ } else {
+ onError(it)
+ }
+ }
+
+ companion object {
+ const val TITLE_COLUMN = "title"
+ const val CONTENT_COLUMN = "content"
+ const val TYPE_COLUMN = "type"
+ const val IMAGES_COLUMN = "imageFiles"
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt
new file mode 100644
index 00000000..acd094a7
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultTokenRepository.kt
@@ -0,0 +1,52 @@
+package com.withpeace.withpeace.core.data.repository
+
+import com.skydoves.sandwich.suspendMapSuccess
+import com.withpeace.withpeace.core.data.mapper.roleToDomain
+import com.withpeace.withpeace.core.data.util.handleApiFailure
+import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource
+import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.role.Role
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import com.withpeace.withpeace.core.network.di.response.LoginResponse
+import com.withpeace.withpeace.core.network.di.service.AuthService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import javax.inject.Inject
+
+class DefaultTokenRepository @Inject constructor(
+ private val tokenPreferenceDataSource: TokenPreferenceDataSource,
+ private val userPreferenceDataSource: UserPreferenceDataSource,
+ private val authService: AuthService,
+) : TokenRepository {
+ override suspend fun isLogin(): Boolean {
+ val token = tokenPreferenceDataSource.accessToken.firstOrNull()
+ val userRole = userPreferenceDataSource.userRole.firstOrNull()
+ return token != null && userRole?.roleToDomain() == Role.USER //TODO("토큰이 만료되었는지 확인 필요")
+ }
+
+ override fun getTokenByGoogle(
+ idToken: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ authService.googleLogin(AUTHORIZATION_FORMAT.format(idToken)).suspendMapSuccess {
+ val data = this.data
+ saveLocalLoginInfo(data)
+ emit(data.role.roleToDomain())
+ }.handleApiFailure(onError)
+ }.flowOn(Dispatchers.IO)
+
+ private suspend fun saveLocalLoginInfo(data: LoginResponse) {
+ tokenPreferenceDataSource.updateAccessToken(data.tokenResponse.accessToken)
+ tokenPreferenceDataSource.updateRefreshToken(data.tokenResponse.refreshToken)
+ userPreferenceDataSource.updateUserId(data.userId)
+ userPreferenceDataSource.updateUserRole(data.role)
+ }
+
+ companion object {
+ private const val AUTHORIZATION_FORMAT = "Bearer %s"
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt
new file mode 100644
index 00000000..aae8ab5d
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt
@@ -0,0 +1,177 @@
+package com.withpeace.withpeace.core.data.repository
+
+import android.content.Context
+import android.net.Uri
+import com.skydoves.sandwich.suspendMapSuccess
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.data.util.convertToFile
+import com.withpeace.withpeace.core.data.util.handleApiFailure
+import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource
+import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource
+import com.withpeace.withpeace.core.domain.model.SignUpInfo
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile
+import com.withpeace.withpeace.core.domain.model.profile.Nickname
+import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo
+import com.withpeace.withpeace.core.domain.model.role.Role
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import com.withpeace.withpeace.core.network.di.request.NicknameRequest
+import com.withpeace.withpeace.core.network.di.service.UserService
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.asRequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
+import javax.inject.Inject
+
+class DefaultUserRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val userService: UserService,
+ private val tokenPreferenceDataSource: TokenPreferenceDataSource,
+ private val userPreferenceDataSource: UserPreferenceDataSource,
+) : UserRepository {
+ override fun getProfile(
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ userService.getProfile().suspendMapSuccess {
+ emit(this.data.toDomain())
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override suspend fun signUp(
+ signUpInfo: SignUpInfo,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ val nicknameRequestBody =
+ signUpInfo.nickname.toRequestBody("text/plain".toMediaTypeOrNull())
+ val request =
+ if (signUpInfo.profileImage.isNullOrEmpty()) {
+ userService.signUp(
+ nicknameRequestBody,
+ )
+ } else {
+ val profileImagePart = getImagePart(signUpInfo.profileImage!!)
+ userService.signUp(nicknameRequestBody, profileImagePart)
+ }
+
+ request.suspendMapSuccess {
+ val data = this.data
+ userPreferenceDataSource.updateUserRole(Role.USER.name)
+ tokenPreferenceDataSource.updateAccessToken(data.accessToken)
+ tokenPreferenceDataSource.updateRefreshToken(data.refreshToken)
+ emit(Unit)
+ }.handleApiFailure(onError)
+ }
+
+ override suspend fun getCurrentUserId(): Long = withContext(Dispatchers.IO) {
+ userPreferenceDataSource.userId.firstOrNull() ?: throw IllegalStateException("로그인 되있지 않아요")
+ }
+
+ override fun updateProfile(
+ nickname: String,
+ profileImage: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ val imagePart = getImagePart(profileImage)
+ userService.updateProfile(
+ nickname.toRequestBody("text/plain".toMediaTypeOrNull()), imagePart,
+ ).suspendMapSuccess {
+ emit(this.data.toDomain())
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun updateNickname(
+ nickname: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow =
+ flow {
+ userService.updateNickname(NicknameRequest(nickname)).suspendMapSuccess {
+ emit(ChangedProfile(nickname = Nickname(this.data)))
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun updateProfileImage(
+ profileImage: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ val imagePart = getImagePart(profileImage)
+ userService.updateImage(imagePart).suspendMapSuccess {
+ emit(ChangedProfile(profileImageUrl = this.data))
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun verifyNicknameDuplicated(
+ nickname: Nickname,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = flow {
+ userService.isNicknameDuplicate(nickname.value).suspendMapSuccess {
+ if (this.data) {
+ onError(ClientError.NicknameError.Duplicated)
+ } else {
+ emit(Unit)
+ }
+ }.handleApiFailure {
+ onErrorWithAuthExpired(it, onError)
+ }
+ }
+
+ override fun withdraw(onError: suspend (CheonghaError) -> Unit): Flow =
+ flow {
+ userService.withdraw().suspendMapSuccess {
+ if(this.data) {
+ tokenPreferenceDataSource.removeAll()
+ userPreferenceDataSource.removeAll()
+ emit(Unit)
+ } else {
+ onError(ResponseError.UNKNOWN_ERROR)
+ }
+ }.handleApiFailure(onError)
+ }
+
+ override fun logout(onError: suspend (CheonghaError) -> Unit): Flow = flow {
+ userService.logout().suspendMapSuccess {
+ tokenPreferenceDataSource.removeAll()
+ userPreferenceDataSource.removeAll()
+ emit(Unit)
+ }.handleApiFailure(onError)
+ }
+
+ private fun getImagePart(profileImage: String): MultipartBody.Part {
+ val requestFile: File = Uri.parse(profileImage).convertToFile(context)
+ val imageRequestBody = requestFile.asRequestBody("image/*".toMediaTypeOrNull())
+ return MultipartBody.Part.createFormData(
+ "imageFile",
+ requestFile.name,
+ imageRequestBody,
+ )
+ }
+
+ private suspend fun onErrorWithAuthExpired(
+ it: ResponseError,
+ onError: suspend (CheonghaError) -> Unit,
+ ) {
+ if (it == ResponseError.INVALID_TOKEN_ERROR) {
+ logout(onError).collect {
+ onError(ClientError.AuthExpired)
+ }
+ } else {
+ onError(it)
+ }
+ }
+}
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt
new file mode 100644
index 00000000..d49d62ce
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt
@@ -0,0 +1,36 @@
+package com.withpeace.withpeace.core.data.repository
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.data.paging.YouthPolicyPagingSource
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository
+import com.withpeace.withpeace.core.network.di.service.YouthPolicyService
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class DefaultYouthPolicyRepository @Inject constructor(
+ private val youthPolicyService: YouthPolicyService,
+) : YouthPolicyRepository {
+ override fun getPolicies(
+ filterInfo: PolicyFilters,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow> = Pager(
+ config = PagingConfig(PAGE_SIZE),
+ pagingSourceFactory = {
+ YouthPolicyPagingSource(
+ youthPolicyService = youthPolicyService,
+ onError = onError,
+ pageSize = PAGE_SIZE,
+ filterInfo = filterInfo,
+ )
+ },
+ ).flow
+
+ companion object {
+ private const val PAGE_SIZE = 10
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/ApiResponseExtension.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/ApiResponseExtension.kt
new file mode 100644
index 00000000..cb73796d
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/ApiResponseExtension.kt
@@ -0,0 +1,20 @@
+package com.withpeace.withpeace.core.data.util
+
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.network.di.common.getErrorBody
+
+suspend inline fun ApiResponse.handleApiFailure(
+ noinline onError: suspend (ResponseError) -> Unit,
+): ApiResponse {
+ if (this is ApiResponse.Failure.Error) {
+ val errorBody = errorBody?.getErrorBody()
+ onError(
+ errorBody?.code?.let { ResponseError.findByCode(it) }
+ ?: ResponseError.UNKNOWN_ERROR,
+ )
+ } else if (this is ApiResponse.Failure.Exception) {
+ onError(ResponseError.HTTP_EXCEPTION_ERROR)
+ }
+ return this
+}
\ No newline at end of file
diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/image.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/image.kt
new file mode 100644
index 00000000..e6901e2f
--- /dev/null
+++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/util/image.kt
@@ -0,0 +1,78 @@
+package com.withpeace.withpeace.core.data.util
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.webkit.URLUtil
+import androidx.exifinterface.media.ExifInterface
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URL
+
+
+fun String.convertToFile(context: Context): File {
+ return if (URLUtil.isHttpUrl(this)) {
+ return URL(this).convertToFile(context)
+ } else {
+ Uri.parse(this).convertToFile(context)
+ }
+}
+
+fun URL.convertToFile(context: Context): File {
+ val bitmap = convertToBitmap()
+ val file = bitmap.convertToFile(context)
+ file.updateExifOrientation(this)
+ return file
+}
+
+fun URL.convertToBitmap(): Bitmap {
+ val stream = openStream()
+ return BitmapFactory.decodeStream(stream)
+}
+
+fun Uri.convertToFile(context: Context): File {
+ val bitmap = convertToBitmap(context)
+ val file = bitmap?.convertToFile(context)
+ file?.updateExifOrientation(context,this)
+ return file ?: throw IllegalStateException("이미지를 파일로 바꾸는데에 실패하였습니다")
+}
+
+private fun Uri.convertToBitmap(context: Context): Bitmap? {
+ return context.contentResolver.openInputStream(this)?.use {
+ BitmapFactory.decodeStream(it)
+ }
+}
+
+private fun Bitmap.convertToFile(context: Context): File {
+ val tempFile = File.createTempFile("imageFile", ".jpg", context.cacheDir)
+
+ FileOutputStream(tempFile).use { fileOutputStream ->
+ compress(Bitmap.CompressFormat.JPEG, HIGHEST_COMPRESS_QUALITY, fileOutputStream)
+ }
+ return tempFile
+}
+
+private fun File.updateExifOrientation(context: Context, uri: Uri) {
+ context.contentResolver.openInputStream(uri)?.use {
+ val exif = ExifInterface(it)
+ exif.getAttribute(ExifInterface.TAG_ORIENTATION)?.let { attribute ->
+ val newExif = ExifInterface(absolutePath)
+ newExif.setAttribute(ExifInterface.TAG_ORIENTATION, attribute)
+ newExif.saveAttributes()
+ }
+ }
+}
+
+private fun File.updateExifOrientation(url: URL) {
+ url.openStream()?.use {
+ val exif = ExifInterface(it)
+ exif.getAttribute(ExifInterface.TAG_ORIENTATION)?.let { attribute ->
+ val newExif = ExifInterface(absolutePath)
+ newExif.setAttribute(ExifInterface.TAG_ORIENTATION, attribute)
+ newExif.saveAttributes()
+ }
+ }
+}
+
+private const val HIGHEST_COMPRESS_QUALITY = 20
diff --git a/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSourceTest.kt b/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSourceTest.kt
new file mode 100644
index 00000000..15cdc92d
--- /dev/null
+++ b/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/ImagePagingSourceTest.kt
@@ -0,0 +1,85 @@
+package com.withpeace.withpeace.core.data.paging
+
+
+import android.net.Uri
+import androidx.paging.PagingConfig
+import androidx.paging.PagingSource.LoadResult
+import androidx.paging.testing.TestPager
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.data.mapper.toDomain
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.imagestorage.ImageDataSource
+import com.withpeace.withpeace.core.imagestorage.ImageInfoEntity
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class ImagePagingSourceTest {
+
+ private lateinit var imagePagingSource: ImagePagingSource
+ private val imageDataSource = mockk(relaxed = true)
+ private var mockImageInfoList = listOf()
+
+ @Before
+ fun setup() {
+ mockImageInfoList = listOf()
+ val uri = mockingUri()
+ coEvery { imageDataSource.getImages(any(), any(), any()) } returns List(20) {
+ ImageInfoEntity(
+ imageUri = uri,
+ mimeType = "jpg",
+ byteSize = 100,
+ )
+ }.apply {
+ mockImageInfoList = mockImageInfoList + this.map { it.toDomain() }
+ }
+ }
+
+ @Test
+ fun `refresh 테스트`() = runTest {
+ // when
+ imagePagingSource = ImagePagingSource(imageDataSource, "test", 20)
+ val pager = TestPager(PagingConfig(20), imagePagingSource)
+ val result = pager.refresh() as LoadResult.Page
+ // then
+ assertThat(result.data).containsExactlyElementsIn(
+ List(20) {
+ ImageInfo(
+ uri = "",
+ mimeType = "jpg",
+ byteSize = 100,
+ )
+ },
+ ).inOrder()
+ }
+
+ @Test
+ fun `append 테스트`() = runTest {
+ // when
+ imagePagingSource = ImagePagingSource(imageDataSource, "test", 20)
+ val pager = TestPager(PagingConfig(20), imagePagingSource)
+ var result = listOf()
+ result = result + (pager.refresh() as LoadResult.Page) + (pager.append() as LoadResult.Page)
+ // then
+ assertThat(result).containsExactlyElementsIn(
+ List(40) {
+ ImageInfo(
+ uri = "",
+ mimeType = "jpg",
+ byteSize = 100,
+ )
+ },
+ ).inOrder()
+ }
+
+ private fun mockingUri(): Uri {
+ mockkStatic(Uri::class)
+ val uri = mockk()
+ every { uri.toString() } returns ""
+ return uri
+ }
+}
diff --git a/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSourceTest.kt b/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSourceTest.kt
new file mode 100644
index 00000000..091ae7c2
--- /dev/null
+++ b/core/data/src/test/kotlin/com/withpeace/withpeace/core/data/paging/PostPagingSourceTest.kt
@@ -0,0 +1,125 @@
+package com.withpeace.withpeace.core.data.paging
+
+import androidx.paging.PagingConfig
+import androidx.paging.PagingSource.LoadResult
+import androidx.paging.testing.TestPager
+import com.google.common.truth.Truth.assertThat
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.domain.model.date.Date
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import com.withpeace.withpeace.core.network.di.response.BaseResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse
+import com.withpeace.withpeace.core.network.di.service.PostService
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import retrofit2.Response
+import java.time.LocalDate
+import java.time.LocalDateTime
+import java.time.LocalTime
+
+class PostPagingSourceTest {
+ private lateinit var postPagingSource: PostPagingSource
+ private val postService = mockk()
+ private val userRepository = mockk(relaxed = true)
+
+ @Before
+ fun setup() {
+ coEvery {
+ postService.getPosts(
+ postTopic = any(),
+ pageIndex = any(),
+ pageSize = any(),
+ )
+ } returns ApiResponse.Success>>(
+ response = Response.success(
+ BaseResponse(
+ data = List(20) {
+ PostResponse(
+ postId = 0,
+ title = "title",
+ content = "content",
+ type = PostTopicResponse.FREEDOM,
+ postImageUrl = null,
+ createDate = "2024/04/12 00:00:00",
+ )
+ },
+ error = null,
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `refrsh 테스트`() = runTest {
+ // given
+ postPagingSource = PostPagingSource(
+ postService,
+ postTopic = PostTopic.FREEDOM,
+ pageSize = 20,
+ onError = {},
+ userRepository = userRepository
+ )
+ // when
+ val pager = TestPager(PagingConfig(20), postPagingSource)
+ val result = pager.refresh() as LoadResult.Page
+ // then
+ assertThat(result.data).containsExactlyElementsIn(
+ List(20) {
+ Post(
+ postId = 0,
+ title = "title",
+ content = "content",
+ postTopic = PostTopic.FREEDOM,
+ postImageUrl = null,
+ createDate = Date(
+ LocalDateTime.of(
+ LocalDate.of(2024, 4, 12),
+ LocalTime.of(0, 0, 0),
+ ),
+ ),
+ )
+ },
+ ).inOrder()
+ }
+
+ @Test
+ fun `append 테스트`() = runTest {
+ // given
+ postPagingSource = PostPagingSource(
+ postService,
+ postTopic = PostTopic.FREEDOM,
+ pageSize = 20,
+ onError = {},
+ userRepository = userRepository
+ )
+ // when
+ var result = listOf()
+ val pager = TestPager(PagingConfig(20), postPagingSource)
+ result =
+ result + (pager.refresh() as LoadResult.Page).data + (pager.append() as LoadResult.Page).data
+ // then
+ assertThat(result).containsExactlyElementsIn(
+ List(40) {
+ Post(
+ postId = 0,
+ title = "title",
+ content = "content",
+ postTopic = PostTopic.FREEDOM,
+ postImageUrl = null,
+ createDate = Date(
+ LocalDateTime.of(
+ LocalDate.of(2024, 4, 12),
+ LocalTime.of(0, 0, 0),
+ ),
+ ),
+ )
+ },
+ ).inOrder()
+ }
+}
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..481a59a5
--- /dev/null
+++ b/core/datastore/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.hilt")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.datastore"
+ defaultConfig {
+ consumerProguardFiles("consumer-rules.pro")
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.datastore)
+}
diff --git a/core/datastore/consumer-rules.pro b/core/datastore/consumer-rules.pro
new file mode 100644
index 00000000..45026be5
--- /dev/null
+++ b/core/datastore/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class *
diff --git a/core/datastore/proguard-rules.pro b/core/datastore/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/datastore/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/datastore/src/androidTest/java/com/withpeace/withpeace/core/datastore/ExampleInstrumentedTest.kt b/core/datastore/src/androidTest/java/com/withpeace/withpeace/core/datastore/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..1e831671
--- /dev/null
+++ b/core/datastore/src/androidTest/java/com/withpeace/withpeace/core/datastore/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace.core.datastore
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.core.datastore.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/AndroidManifest.xml b/core/datastore/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..44008a43
--- /dev/null
+++ b/core/datastore/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt
new file mode 100644
index 00000000..2d382b5b
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/DefaultTokenPreferenceDataSource.kt
@@ -0,0 +1,46 @@
+package com.withpeace.withpeace.core.datastore.dataStore.token
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Named
+
+class DefaultTokenPreferenceDataSource @Inject constructor(
+ @Named("auth") private val dataStore: DataStore,
+) : TokenPreferenceDataSource {
+
+ override val accessToken: Flow = dataStore.data.map { preferences ->
+ preferences[ACCESS_TOKEN]
+ }
+
+ override val refreshToken: Flow = dataStore.data.map { preferences ->
+ preferences[REFRESH_TOKEN]
+ }
+
+ override suspend fun updateAccessToken(accessToken: String) {
+ dataStore.edit { preferences ->
+ preferences[ACCESS_TOKEN] = accessToken
+ }
+ }
+
+ override suspend fun updateRefreshToken(refreshToken: String) {
+ dataStore.edit { preferences ->
+ preferences[REFRESH_TOKEN] = refreshToken
+ }
+ }
+
+ override suspend fun removeAll() {
+ dataStore.edit { preferences ->
+ preferences.clear()
+ }
+ }
+
+ companion object {
+ private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN")
+ private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN")
+ }
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt
new file mode 100644
index 00000000..a764d6b8
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/token/TokenPreferenceDataSource.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.core.datastore.dataStore.token
+
+import kotlinx.coroutines.flow.Flow
+
+interface TokenPreferenceDataSource {
+
+ val accessToken: Flow
+
+ val refreshToken: Flow
+
+ suspend fun updateRefreshToken(refreshToken: String)
+
+ suspend fun updateAccessToken(accessToken: String)
+ suspend fun removeAll()
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt
new file mode 100644
index 00000000..0f6ea29b
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/DefaultUserPreferenceDataSource.kt
@@ -0,0 +1,45 @@
+package com.withpeace.withpeace.core.datastore.dataStore.user
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Named
+
+class DefaultUserPreferenceDataSource @Inject constructor(
+ @Named("user") private val dataStore: DataStore,
+) : UserPreferenceDataSource {
+ override val userId: Flow = dataStore.data.map { preferences ->
+ preferences[USER_ID]
+ }
+ override val userRole: Flow = dataStore.data.map { preferences ->
+ preferences[USER_ROLE]
+ }
+
+ override suspend fun updateUserId(userId: Long) {
+ dataStore.edit { preferences ->
+ preferences[USER_ID] = userId
+ }
+ }
+
+ override suspend fun updateUserRole(userRole: String) {
+ dataStore.edit { preferences ->
+ preferences[USER_ROLE] = userRole
+ }
+ }
+
+ override suspend fun removeAll() {
+ dataStore.edit { preferences ->
+ preferences.clear()
+ }
+ }
+
+ companion object {
+ private val USER_ID = longPreferencesKey("USER_ID")
+ private val USER_ROLE = stringPreferencesKey("USER_ROLE")
+ }
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt
new file mode 100644
index 00000000..6e68a496
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/dataStore/user/UserPreferenceDataSource.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.core.datastore.dataStore.user
+
+import kotlinx.coroutines.flow.Flow
+
+interface UserPreferenceDataSource {
+
+ val userId: Flow
+
+ val userRole: Flow
+
+ suspend fun updateUserId(userId: Long)
+
+ suspend fun updateUserRole(userRole: String)
+ suspend fun removeAll()
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt
new file mode 100644
index 00000000..ae814fcf
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/DataStoreModule.kt
@@ -0,0 +1,38 @@
+package com.withpeace.withpeace.core.datastore.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+
+ private const val AUTH_DATASTORE_NAME = "AUTH_PREFERENCES"
+ private const val USER_DATASTORE_NAME = "USER_PREFERENCES"
+
+ private val Context.authDataStore: DataStore by preferencesDataStore(name = AUTH_DATASTORE_NAME)
+ private val Context.userDataStore: DataStore by preferencesDataStore(name = USER_DATASTORE_NAME)
+
+ @Provides
+ @Singleton
+ @Named("auth")
+ fun providesTokenDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.authDataStore
+
+ @Provides
+ @Singleton
+ @Named("user")
+ fun providesUserDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.userDataStore
+}
\ No newline at end of file
diff --git a/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt
new file mode 100644
index 00000000..fc1d0ef2
--- /dev/null
+++ b/core/datastore/src/main/java/com/withpeace/withpeace/core/datastore/di/PreferenceDataSourceModule.kt
@@ -0,0 +1,28 @@
+package com.withpeace.withpeace.core.datastore.di
+
+import com.withpeace.withpeace.core.datastore.dataStore.token.DefaultTokenPreferenceDataSource
+import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource
+import com.withpeace.withpeace.core.datastore.dataStore.user.DefaultUserPreferenceDataSource
+import com.withpeace.withpeace.core.datastore.dataStore.user.UserPreferenceDataSource
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface PreferenceDataSourceModule {
+
+ @Binds
+ @Singleton
+ fun bindsTokenPreferenceDataSource(
+ defaultTokenPreferenceDataSource: DefaultTokenPreferenceDataSource,
+ ): TokenPreferenceDataSource
+
+ @Binds
+ @Singleton
+ fun bindsUserPreferenceDataSource(
+ defaultUserPreferenceDataSource: DefaultUserPreferenceDataSource,
+ ): UserPreferenceDataSource
+}
\ No newline at end of file
diff --git a/core/datastore/src/test/java/com/withpeace/withpeace/core/datastore/ExampleUnitTest.kt b/core/datastore/src/test/java/com/withpeace/withpeace/core/datastore/ExampleUnitTest.kt
new file mode 100644
index 00000000..c98a0772
--- /dev/null
+++ b/core/datastore/src/test/java/com/withpeace/withpeace/core/datastore/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.datastore
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
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..d518a6ce
--- /dev/null
+++ b/core/designsystem/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.compose")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.designsystem"
+}
diff --git a/core/designsystem/consumer-rules.pro b/core/designsystem/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/designsystem/proguard-rules.pro b/core/designsystem/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/designsystem/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/designsystem/src/main/AndroidManifest.xml b/core/designsystem/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/core/designsystem/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt
new file mode 100644
index 00000000..678dcbf8
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt
@@ -0,0 +1,47 @@
+package com.withpeace.withpeace.core.designsystem.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
+
+val mainPurple = Color(0xFF9A70E2)
+val subPurple = Color(0xFFE8E8FC)
+val subSkyBlue = Color(0xFF90EFEF)
+val subBlueGreen = Color(0xFFBDE4DF)
+val subBlue1 = Color(0xFFDFF2F9)
+val subBlue2 = Color(0xFF0575E6)
+
+val systemBlack = Color(0xFF212529)
+val systemWhite = Color.White
+val systemGray1 = Color(0xFF3D3D3D)
+val systemGray2 = Color(0xFFA7A7A7)
+val systemGray3 = Color(0xFFECECEF)
+val systemGray4 = Color(0xFF696969)
+val systemError = Color(0xFFF0474B)
+val systemSuccess = Color(0xFF3BD569)
+
+data class WithPeaceColor(
+ val MainPurple: Color = mainPurple,
+ val SubPurple: Color = subPurple,
+ val SubSkyBlue: Color = subSkyBlue,
+ val SubBlueGreen: Color = subBlueGreen,
+ val SubBlue1: Color = subBlue1,
+ val SubBlue2:Color = subBlue2,
+ val SystemBlack: Color = systemBlack,
+ val SystemWhite: Color = systemWhite,
+ val SystemGray1: Color = systemGray1,
+ val SystemGray2: Color = systemGray2,
+ val SystemGray3: Color = systemGray3,
+ val SystemError: Color = systemError,
+ val SystemGray4: Color = systemGray4,
+ val SystemSuccess: Color = systemSuccess,
+)
+
+val lightColor = WithPeaceColor()
+val darkColor = WithPeaceColor()
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Padding.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Padding.kt
new file mode 100644
index 00000000..6323cee1
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Padding.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.designsystem.theme
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+data class WithPeacePadding(
+ val BasicHorizontalPadding: Dp = 24.dp,
+ val BasicContentPadding: Dp = 8.dp,
+)
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Theme.kt
new file mode 100644
index 00000000..c6dfdb62
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Theme.kt
@@ -0,0 +1,50 @@
+package com.withpeace.withpeace.core.designsystem.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+
+val LocalCustomColors =
+ staticCompositionLocalOf {
+ WithPeaceColor()
+ }
+
+val LocalCustomTypography =
+ staticCompositionLocalOf {
+ WithPeaceTypography()
+ }
+val LocalCustomPadding =
+ staticCompositionLocalOf {
+ WithPeacePadding()
+ }
+
+@Composable
+fun WithpeaceTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ val colorScheme =
+ when {
+ darkTheme -> darkColor
+ else -> lightColor
+ }
+ CompositionLocalProvider(
+ LocalCustomColors provides colorScheme,
+ LocalCustomTypography provides WithPeaceTypography(),
+ LocalCustomPadding provides WithPeacePadding(),
+ content = content,
+ )
+}
+
+object WithpeaceTheme {
+ val colors: WithPeaceColor
+ @Composable
+ get() = LocalCustomColors.current
+ val typography: WithPeaceTypography
+ @Composable
+ get() = LocalCustomTypography.current
+ val padding: WithPeacePadding
+ @Composable
+ get() = LocalCustomPadding.current
+}
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Type.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Type.kt
new file mode 100644
index 00000000..7a455086
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Type.kt
@@ -0,0 +1,155 @@
+package com.withpeace.withpeace.core.designsystem.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Modifier
+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.FontWeight
+import androidx.compose.ui.unit.sp
+import com.withpeace.withpeace.core.designsystem.R
+
+// Set of Material typography styles to start with
+val Typography =
+ Typography(
+ bodyLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+ )
+
+val NotoSansFont =
+ FontFamily(
+ Font(
+ resId = R.font.notosans_kr_medium,
+ weight = FontWeight.Bold,
+ ),
+ )
+
+val PretendardFont =
+ FontFamily(
+ Font(
+ resId = R.font.pretendard_bold,
+ weight = FontWeight.Bold,
+ ),
+ Font(
+ resId = R.font.pretendard_regular,
+ weight = FontWeight.Normal,
+ ),
+ Font(
+ resId = R.font.pretendard_extra_bold,
+ weight = FontWeight.ExtraBold,
+ ),
+ Font(
+ resId = R.font.pretendard_extra_light,
+ weight = FontWeight.ExtraLight,
+ ),
+ Font(
+ resId = R.font.pretendard_extra_light,
+ weight = FontWeight.Light,
+ ),
+ Font(
+ resId = R.font.pretendard_medium,
+ weight = FontWeight.Medium,
+ ),
+ Font(
+ resId = R.font.pretendard_semi_bold,
+ weight = FontWeight.SemiBold,
+ ),
+ Font(
+ resId = R.font.pretendard_thin,
+ weight = FontWeight.Thin,
+ ),
+ )
+
+@Immutable
+data class WithPeaceTypography(
+ val notoSans: TextStyle =
+ TextStyle(
+ fontFamily = NotoSansFont,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 23.17.sp,
+ ),
+ val heading: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ lineHeight = 33.6.sp,
+ letterSpacing = (-0.096).sp,
+ ),
+ val title1: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ lineHeight = 28.sp,
+ letterSpacing = (-0.08).sp,
+ ),
+ val title2: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ lineHeight = 25.2.sp,
+ letterSpacing = (-0.4).sp,
+ ),
+ val body: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 22.4.sp,
+ letterSpacing = (-0.4).sp,
+ ),
+ val caption: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 19.6.sp,
+ letterSpacing = (-0.4).sp,
+ ),
+
+ val homePolicyTitle: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ letterSpacing = (0.16).sp,
+ ),
+ val homePolicyContent: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ ),
+ val homePolicyTag: TextStyle =
+ TextStyle(
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ fontSize = 10.sp,
+ ),
+)
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Box.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Box.kt
new file mode 100644
index 00000000..39eb1971
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Box.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun KeyboardAware(
+ content: @Composable () -> Unit,
+) {
+ Box(modifier = Modifier.imePadding()) {
+ content()
+ }
+}
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Button.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Button.kt
new file mode 100644
index 00000000..77bcded6
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Button.kt
@@ -0,0 +1,45 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.R
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@Composable
+fun WithPeaceCompleteButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ enabled: Boolean,
+) {
+ Button(
+ modifier = modifier,
+ onClick = onClick,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = WithpeaceTheme.colors.MainPurple,
+ disabledContainerColor = WithpeaceTheme.colors.SystemGray2,
+ ),
+ enabled = enabled,
+ ) {
+ Text(
+ text = stringResource(R.string.complete_button_text),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun WithPeaceCompleteButtonPreview() {
+ WithPeaceCompleteButton(onClick = {}, enabled = true)
+}
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Card.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Card.kt
new file mode 100644
index 00000000..a5339809
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/Card.kt
@@ -0,0 +1,41 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@Composable
+fun WithpeaceCard(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Card(
+ modifier = modifier,
+ shape = RoundedCornerShape(5.dp),
+ border = BorderStroke(width = 1.dp, color = WithpeaceTheme.colors.SystemGray2),
+ colors = CardDefaults.cardColors(
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ ),
+ ) {
+ content()
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WithpeaceCardPreview() {
+ WithpeaceTheme {
+ WithpeaceCard {
+ Text("haha")
+ }
+ }
+}
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/NoTitleDialog.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/NoTitleDialog.kt
new file mode 100644
index 00000000..9a5b10c6
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/NoTitleDialog.kt
@@ -0,0 +1,90 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@Composable
+fun NoTitleDialog(
+ modifier: Modifier = Modifier,
+ onClickPositive: () -> Unit,
+ onClickNegative: () -> Unit,
+ contentText: String,
+ positiveText: String,
+ negativeText: String,
+) {
+ Surface(
+ modifier = modifier
+ .width(327.dp)
+ .clip(RoundedCornerShape(10.dp)),
+ ){
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = modifier.height(24.dp))
+ Text(
+ text = contentText,
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemGray1,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ TextButton(
+ modifier = modifier
+ .width(136.dp)
+ .border(
+ width = 1.dp,
+ shape = RoundedCornerShape(10.dp),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ .background(WithpeaceTheme.colors.SystemWhite),
+ onClick = { onClickNegative() },
+ content = {
+ Text(
+ text = negativeText,
+ color = WithpeaceTheme.colors.MainPurple,
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ TextButton(
+ modifier = modifier
+ .width(136.dp)
+ .background(
+ WithpeaceTheme.colors.MainPurple,
+ shape = RoundedCornerShape(10.dp),
+ ),
+ onClick = { onClickPositive() },
+ content = {
+ Text(
+ text = positiveText,
+ color = WithpeaceTheme.colors.SystemWhite,
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ )
+ }
+ Spacer(modifier = modifier.height(24.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TitleBar.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TitleBar.kt
new file mode 100644
index 00000000..9cadc15b
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TitleBar.kt
@@ -0,0 +1,29 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+import androidx.compose.foundation.layout.Row
+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.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@Composable
+fun TitleBar(title: String, modifier: Modifier = Modifier) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(start = 24.dp),
+ ) {
+ Text(
+ text = title,
+ style = WithpeaceTheme.typography.title1,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ }
+}
\ No newline at end of file
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TopAppBar.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TopAppBar.kt
new file mode 100644
index 00000000..4ea7d759
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/ui/TopAppBar.kt
@@ -0,0 +1,62 @@
+package com.withpeace.withpeace.core.designsystem.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+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.withpeace.withpeace.core.designsystem.R
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WithPeaceBackButtonTopAppBar(
+ modifier: Modifier = Modifier,
+ onClickBackButton: () -> Unit,
+ title: @Composable () -> Unit,
+ actions: @Composable (RowScope.() -> Unit) = {},
+ windowInsets: WindowInsets = WindowInsets(0, 0, 0, 0),
+) {
+ TopAppBar(
+ title = title,
+ modifier = modifier,
+ navigationIcon = {
+ Icon(
+ modifier =
+ Modifier
+ .padding(start = 20.dp, bottom = 16.dp, top = 16.dp, end = 28.dp)
+ .clickable {
+ onClickBackButton()
+ }
+ .padding(4.dp),
+ painter = painterResource(id = R.drawable.ic_backarrow_left),
+ contentDescription = "BackArrowLeft",
+ )
+ },
+ windowInsets = windowInsets,
+ actions = actions,
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = WithpeaceTheme.colors.SystemWhite),
+ )
+}
+
+@Preview
+@Composable
+private fun BackButtonTopAppBarPreview() {
+ WithpeaceTheme {
+ WithPeaceBackButtonTopAppBar(
+ onClickBackButton = {},
+ title = {
+ Text(text = "글 쓰기")
+ },
+ )
+ }
+}
diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/util/ModifierExtension.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/util/ModifierExtension.kt
new file mode 100644
index 00000000..4e322109
--- /dev/null
+++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/util/ModifierExtension.kt
@@ -0,0 +1,54 @@
+package com.withpeace.withpeace.core.designsystem.util
+
+import android.graphics.BlurMaskFilter
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+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.unit.Dp
+import androidx.compose.ui.unit.dp
+
+fun Modifier.dropShadow(
+ color: Color = Color.Black,
+ borderRadius: Dp = 0.dp,
+ offsetX: Dp = 0.dp,
+ offsetY: Dp = 0.dp,
+ blurRadius: Dp = 0.dp,
+ spreadRadius: Dp = 0.dp,
+ modifier: Modifier = Modifier
+) = then(
+ modifier.drawBehind {
+ drawIntoCanvas { canvas ->
+ val paint = Paint()
+ val frameworkPaint = paint.asFrameworkPaint()
+ val spreadPixel = spreadRadius.toPx()
+ val leftPixel = (0f - spreadPixel) + offsetX.toPx()
+ val topPixel = (0f - spreadPixel) + offsetY.toPx()
+ val rightPixel = size.width + spreadPixel
+ val bottomPixel = size.height + spreadPixel
+
+ frameworkPaint.color = color.toArgb()
+
+ if (blurRadius != 0.dp) {
+ frameworkPaint.maskFilter = BlurMaskFilter(
+ blurRadius.toPx(),
+ BlurMaskFilter.Blur.NORMAL
+ )
+ }
+
+ canvas.drawRoundRect(
+ left = leftPixel,
+ top = topPixel,
+ right = rightPixel,
+ bottom = bottomPixel,
+ radiusX = borderRadius.toPx(),
+ radiusY = borderRadius.toPx(),
+ paint = paint
+ )
+ }
+ }
+)
+// Figma dropShadow 스펙을 구현하는 확장함수입니다.
+// ref: https://spoqa.github.io/2023/10/30/android-jetpack-compose.html
diff --git a/core/designsystem/src/main/res/drawable/ic_backarrow_left.xml b/core/designsystem/src/main/res/drawable/ic_backarrow_left.xml
new file mode 100644
index 00000000..06eb983d
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_backarrow_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_backarrow_right.xml b/core/designsystem/src/main/res/drawable/ic_backarrow_right.xml
new file mode 100644
index 00000000..2e068475
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_backarrow_right.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/core/designsystem/src/main/res/font/notosans_kr_medium.ttf b/core/designsystem/src/main/res/font/notosans_kr_medium.ttf
new file mode 100644
index 00000000..5311c8a3
Binary files /dev/null and b/core/designsystem/src/main/res/font/notosans_kr_medium.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_black.ttf b/core/designsystem/src/main/res/font/pretendard_black.ttf
new file mode 100644
index 00000000..d0c1db81
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_black.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_bold.ttf b/core/designsystem/src/main/res/font/pretendard_bold.ttf
new file mode 100644
index 00000000..fb07fc65
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_bold.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_extra_bold.ttf b/core/designsystem/src/main/res/font/pretendard_extra_bold.ttf
new file mode 100644
index 00000000..9d5fe072
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_extra_bold.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_extra_light.ttf b/core/designsystem/src/main/res/font/pretendard_extra_light.ttf
new file mode 100644
index 00000000..09e65428
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_extra_light.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_light.ttf b/core/designsystem/src/main/res/font/pretendard_light.ttf
new file mode 100644
index 00000000..2e8541d6
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_light.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_medium.ttf b/core/designsystem/src/main/res/font/pretendard_medium.ttf
new file mode 100644
index 00000000..1db67c68
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_medium.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_regular.ttf b/core/designsystem/src/main/res/font/pretendard_regular.ttf
new file mode 100644
index 00000000..01147e99
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_regular.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_semi_bold.ttf b/core/designsystem/src/main/res/font/pretendard_semi_bold.ttf
new file mode 100644
index 00000000..9f2690f0
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_semi_bold.ttf differ
diff --git a/core/designsystem/src/main/res/font/pretendard_thin.ttf b/core/designsystem/src/main/res/font/pretendard_thin.ttf
new file mode 100644
index 00000000..fe9825f1
Binary files /dev/null and b/core/designsystem/src/main/res/font/pretendard_thin.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..ea3f18ee
--- /dev/null
+++ b/core/designsystem/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 완료
+
diff --git a/core/domain/.gitignore b/core/domain/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/domain/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 00000000..0c462382
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+ id("convention.kotlin.library")
+ id("convention.test.library")
+}
+
+dependencies {
+ implementation(libs.inject)
+ implementation(libs.androidx.paging.common) // without android dependencies paging
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt
new file mode 100644
index 00000000..3269c107
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/SignUpInfo.kt
@@ -0,0 +1,6 @@
+package com.withpeace.withpeace.core.domain.model
+
+data class SignUpInfo(
+ val nickname: String,
+ val profileImage: String?,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/Date.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/Date.kt
new file mode 100644
index 00000000..26e36d70
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/Date.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.core.domain.model.date;
+
+import java.time.LocalDateTime
+
+@JvmInline
+value class Date(
+ val date: LocalDateTime,
+) {
+ val durationFromNow: DurationFromNow
+ get() = DurationFromNow.from(date)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/DurationFromNow.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/DurationFromNow.kt
new file mode 100644
index 00000000..ec7a7d77
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/date/DurationFromNow.kt
@@ -0,0 +1,39 @@
+package com.withpeace.withpeace.core.domain.model.date
+
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.ZoneId
+
+sealed class DurationFromNow(
+ val value: Duration,
+) {
+ class LessThanOneMinute(duration: Duration) : DurationFromNow(duration)
+ class OneMinuteToOneHour(duration: Duration) : DurationFromNow(duration)
+ class OneHourToOneDay(duration: Duration) : DurationFromNow(duration)
+ class OneDayToSevenDay(duration: Duration) : DurationFromNow(duration)
+ class SevenDayToOneYear(duration: Duration) : DurationFromNow(duration)
+ class OverOneYear(duration: Duration) : DurationFromNow(duration)
+
+ companion object {
+ fun from(date: LocalDateTime): DurationFromNow {
+ val duration = Duration.between(date, LocalDateTime.now(ZoneId.of("Asia/Seoul")))
+ return when {
+ duration.isOverOneYear() -> OverOneYear(duration)
+ duration.isOverWeekDays() -> SevenDayToOneYear(duration)
+ duration.isOverOneDay() -> OneDayToSevenDay(duration)
+ duration.isOverOneHour() -> OneHourToOneDay(duration)
+ duration.isOverOneMinute() -> OneMinuteToOneHour(duration)
+ else -> LessThanOneMinute(duration)
+ }
+ }
+
+ private fun Duration.isOverOneMinute() = toMinutes() >= 1
+ private fun Duration.isOverOneHour() = toHours() >= 1
+ private fun Duration.isOverOneDay() = toDays() >= 1
+ private fun Duration.isOverWeekDays() = toDays() > DAYS_FOR_WEEK
+ private fun Duration.isOverOneYear() = toDays() > DAYS_FOR_YEAR
+
+ private const val DAYS_FOR_YEAR = 365
+ private const val DAYS_FOR_WEEK = 7
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/CheonghaError.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/CheonghaError.kt
new file mode 100644
index 00000000..7d8d3005
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/CheonghaError.kt
@@ -0,0 +1,3 @@
+package com.withpeace.withpeace.core.domain.model.error
+
+sealed interface CheonghaError
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt
new file mode 100644
index 00000000..29cab42b
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.core.domain.model.error
+
+sealed interface ClientError : CheonghaError {
+ sealed interface NicknameError : ClientError {
+ data object Duplicated : NicknameError
+ data object FormatInvalid : NicknameError
+ }
+
+ data object AuthExpired : ClientError
+ data object ProfileNotChanged : ClientError
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ResponseError.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ResponseError.kt
new file mode 100644
index 00000000..36cb16cb
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ResponseError.kt
@@ -0,0 +1,54 @@
+package com.withpeace.withpeace.core.domain.model.error
+
+enum class ResponseError(val serverErrorCode: Int? = null) : CheonghaError {
+ /* Server 400 */
+ NOT_FOUND_RESOURCE(40000), // NOT_END_POINT 으로 쓰일 수도 있음
+ INVALID_ARGUMENT(40001),
+ INVALID_PROVIDER(40002),
+ METHOD_NOT_ALLOWED(40003),
+ UNSUPPORTED_MEDIA_TYPE(40004),
+ MISSING_REQUEST_PARAMETER(40005),
+ METHOD_ARGUMENT_TYPE_MISMATCH(40006),
+ DUPLICATE_RESOURCE(40007),
+
+ /* Server 401 */
+ EXPIRED_TOKEN_ERROR(40100),
+ INVALID_TOKEN_ERROR(40101), // Refresh Token 만료시
+ TOKEN_MALFORMED_ERROR(40102),
+ TOKEN_TYPE_ERROR(40103),
+ TOKEN_UNSUPPORTED_ERROR(40104),
+ TOKEN_GENERATION_ERROR(40105),
+ FAILURE_LOGIN(40106),
+ FAILURE_LOGOUT(40107),
+ TOKEN_UNKNOWN_ERROR(40106),
+
+ /* Server 403 */
+ ACCESS_DENIED_ERROR(40300),
+
+ /* Server 404 */
+ NOT_FOUND_USER(40401),
+ NOT_FOUND_POST(40402),
+
+ /* Server 422 */
+ POST_DUPLICATED_ERROR(42202),
+ COMMENT_DUPLICATED_ERROR(42203),
+
+ /* Server 500 */
+ SERVER_ERROR(50000),
+ AUTH_SERVER_USER_INFO_ERROR(50001),
+
+ /* UnKnown Error */
+ UNKNOWN_ERROR,
+
+ /* Network Fail */
+ HTTP_EXCEPTION_ERROR;
+
+ companion object {
+ fun findByCode(code: Int): ResponseError {
+ return entries.find {
+ it.serverErrorCode == code
+ } ?: UNKNOWN_ERROR
+ }
+ }
+}
+
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageFolder.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageFolder.kt
new file mode 100644
index 00000000..f77be917
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageFolder.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.core.domain.model.image
+
+data class ImageFolder(
+ val folderName: String,
+ val representativeImageUri: String,
+ val imageCount: Int,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageInfo.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageInfo.kt
new file mode 100644
index 00000000..e03d47f4
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/ImageInfo.kt
@@ -0,0 +1,29 @@
+package com.withpeace.withpeace.core.domain.model.image
+
+data class ImageInfo(
+ val uri: String,
+ val mimeType: String,
+ val byteSize: Long,
+) {
+ fun isUploadType(): Boolean {
+ val imageMimeType = mimeType.split("/").last().lowercase() // image/png
+ return uploadType.contains(imageMimeType)
+ }
+
+ fun isSizeOver(): Boolean {
+ return byteSize.bytesToMegabytes() > MAX_MEGA_BYTE_SIZE
+ }
+
+ private fun Long.bytesToMegabytes(): Double {
+ return this / (BYTE_TO_KB_UNIT * KB_TO_MB_UNIT)
+ }
+
+ companion object {
+ private val uploadType = arrayOf(
+ "jpg", "png", "webp", "jpeg",
+ )
+ private const val BYTE_TO_KB_UNIT = 1024.0
+ private const val KB_TO_MB_UNIT = 1024.0
+ private const val MAX_MEGA_BYTE_SIZE = 10
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/LimitedImages.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/LimitedImages.kt
new file mode 100644
index 00000000..4114a8eb
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/image/LimitedImages.kt
@@ -0,0 +1,35 @@
+package com.withpeace.withpeace.core.domain.model.image
+
+data class LimitedImages(
+ val urls: List,
+ val maxCount: Int = DEFAULT_MAX_COUNT,
+ val alreadyExistCount: Int = DEFAULT_ALREADY_EXIST_COUNT, // 이미지를 선택하고 다시 갤러리에 온 상황 대비
+) {
+
+ val additionalCount: Int
+ get() = maxCount - urls.size - alreadyExistCount
+
+ val currentCount = urls.size + alreadyExistCount
+
+ init {
+ check(currentCount <= maxCount) { "이미지의 최대 개수는 ${maxCount}장입니다" }
+ }
+
+ fun contains(url: String)=urls.contains(url)
+
+ fun canAddImage() = additionalCount > 0
+
+ fun addImage(url: String) = copy(urls = urls + listOf(url))
+
+ fun addImages(inputUrls: List) = copy(urls = urls + inputUrls)
+
+ fun deleteImage(url: String) = copy(urls = urls.filter { it != url })
+
+ fun deleteImage(deletedIndex: Int) =
+ copy(urls = urls.filterIndexed { index, s -> index != deletedIndex })
+
+ companion object {
+ const val DEFAULT_MAX_COUNT = 5
+ const val DEFAULT_ALREADY_EXIST_COUNT = 0
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyClassification.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyClassification.kt
new file mode 100644
index 00000000..b71979ac
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyClassification.kt
@@ -0,0 +1,5 @@
+package com.withpeace.withpeace.core.domain.model.policy
+
+enum class PolicyClassification {
+ JOB, RESIDENT, EDUCATION, WELFARE_AND_CULTURE, PARTICIPATION_AND_RIGHT, ETC
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyFilters.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyFilters.kt
new file mode 100644
index 00000000..c6ccc836
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyFilters.kt
@@ -0,0 +1,28 @@
+package com.withpeace.withpeace.core.domain.model.policy
+
+data class PolicyFilters(
+ val regions: List = emptyList(),
+ val classifications: List = emptyList(),
+) {
+ fun addRegion(region: PolicyRegion): PolicyFilters = copy(regions = regions + region)
+
+ fun removeRegion(region: PolicyRegion): PolicyFilters = copy(regions = regions - region)
+
+ fun updateRegion(region: PolicyRegion): PolicyFilters {
+ return if (!regions.contains(region)) addRegion(region)
+ else removeRegion(region)
+ }
+
+ fun addClassification(classification: PolicyClassification): PolicyFilters =
+ copy(classifications = classifications + classification)
+
+ fun removeClassification(classification: PolicyClassification): PolicyFilters =
+ copy(classifications = classifications - classification)
+
+ fun updateClassification(classification: PolicyClassification): PolicyFilters {
+ return if (!classifications.contains(classification)) addClassification(classification)
+ else removeClassification(classification)
+ }
+
+ fun removeAll(): PolicyFilters = copy(regions = emptyList(), classifications = emptyList())
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyRegion.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyRegion.kt
new file mode 100644
index 00000000..7d7b0232
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicyRegion.kt
@@ -0,0 +1,5 @@
+package com.withpeace.withpeace.core.domain.model.policy
+
+enum class PolicyRegion {
+ 중앙부처, 서울, 부산, 대구, 인천, 광주, 대전, 울산, 경기, 강원, 충북, 충남, 전북, 전남, 경북, 경남, 제주, 세종, 기타
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/YouthPolicy.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/YouthPolicy.kt
new file mode 100644
index 00000000..74243635
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/YouthPolicy.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.core.domain.model.policy
+
+data class YouthPolicy(
+ val id: String,
+ val title: String,
+ val introduce: String,
+ val region: PolicyRegion,
+ val policyClassification: PolicyClassification,
+ val ageInfo: String,
+)
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Comment.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Comment.kt
new file mode 100644
index 00000000..abfcdeae
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Comment.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+import com.withpeace.withpeace.core.domain.model.date.Date
+
+data class Comment(
+ val commentId: Long,
+ val content: String,
+ val createDate: Date,
+ val commentUser: CommentUser,
+)
+
+data class CommentUser(
+ val id: Long,
+ val nickname: String,
+ val profileImageUrl: String,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Post.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Post.kt
new file mode 100644
index 00000000..24b07a55
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/Post.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+import com.withpeace.withpeace.core.domain.model.date.Date
+
+data class Post(
+ val postId: Long,
+ val title: String,
+ val content: String,
+ val postTopic: PostTopic,
+ val createDate: Date,
+ val postImageUrl: String?,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostContent.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostContent.kt
new file mode 100644
index 00000000..af2df956
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostContent.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+@JvmInline
+value class PostContent(
+ val value: String,
+) {
+ init {
+ require(value.isNotBlank()) { "게시글의 내용은 빈칸이어서는 안됩니다" }
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostDetail.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostDetail.kt
new file mode 100644
index 00000000..594b3943
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostDetail.kt
@@ -0,0 +1,20 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+import com.withpeace.withpeace.core.domain.model.date.Date
+
+data class PostDetail(
+ val user: PostUser,
+ val id: Long,
+ val title: PostTitle,
+ val content: PostContent,
+ val postTopic: PostTopic,
+ val imageUrls: List,
+ val createDate: Date,
+ val comments: List,
+)
+
+data class PostUser(
+ val id: Long,
+ val name: String,
+ val profileImageUrl: String,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTitle.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTitle.kt
new file mode 100644
index 00000000..d42d6465
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTitle.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+@JvmInline
+value class PostTitle(
+ val value: String,
+) {
+ init {
+ require(value.isNotBlank()) { "게시글의 제목은 빈칸이어서는 안됩니다" }
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTopic.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTopic.kt
new file mode 100644
index 00000000..e24b22ec
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/PostTopic.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+enum class PostTopic {
+ FREEDOM, INFORMATION, QUESTION, LIVING, HOBBY, ECONOMY;
+
+ companion object {
+ fun findIndex(postTopic: PostTopic) = entries.indexOf(postTopic)
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RegisterPost.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RegisterPost.kt
new file mode 100644
index 00000000..df5015f9
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RegisterPost.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+
+data class RegisterPost(
+ val id: Long?,
+ val title: String,
+ val content: String,
+ val topic: PostTopic?,
+ val images: LimitedImages,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/ReportType.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/ReportType.kt
new file mode 100644
index 00000000..f6a08546
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/ReportType.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.domain.model.post
+
+enum class ReportType {
+ DUPLICATE,
+ ADVERTISEMENT,
+ INAPPROPRIATE,
+ PROFANITY,
+ OBSCENITY,
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangedProfile.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangedProfile.kt
new file mode 100644
index 00000000..ba3e9da3
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangedProfile.kt
@@ -0,0 +1,6 @@
+package com.withpeace.withpeace.core.domain.model.profile
+
+data class ChangedProfile(
+ val nickname: Nickname? = null,
+ val profileImageUrl: String? = null,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangingProfileInfo.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangingProfileInfo.kt
new file mode 100644
index 00000000..9fbd7f0d
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ChangingProfileInfo.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.core.domain.model.profile
+
+data class ChangingProfileInfo(val nickname: String, val profileImage: String) {
+ fun getChangingStatus(baseProfileInfo: ChangingProfileInfo): ProfileChangingStatus {
+ return ProfileChangingStatus.getStatus(baseProfileInfo, this)
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/Nickname.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/Nickname.kt
new file mode 100644
index 00000000..622fb79f
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/Nickname.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.core.domain.model.profile
+
+@JvmInline
+value class Nickname(val value: String) {
+
+ init {
+ require(verifyFormat(value)) { "잘못된 닉네임 형식입니다" }
+ }
+ companion object {
+ private const val NICKNAME_REGEX_PATTERN = "[a-zA-Z가-힣]{2,10}"
+
+ fun verifyFormat(nickname: String): Boolean {
+ val regex = Regex(NICKNAME_REGEX_PATTERN)
+ return regex.matches(nickname)
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileChangingStatus.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileChangingStatus.kt
new file mode 100644
index 00000000..d125d1c5
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileChangingStatus.kt
@@ -0,0 +1,30 @@
+package com.withpeace.withpeace.core.domain.model.profile
+
+sealed interface ProfileChangingStatus {
+ data object OnlyNicknameChanging : ProfileChangingStatus
+
+ data object OnlyImageChanging : ProfileChangingStatus
+
+ data object AllChanging : ProfileChangingStatus
+
+ data object Same : ProfileChangingStatus
+
+ companion object {
+ fun getStatus(
+ beforeProfile: ChangingProfileInfo,
+ afterProfile: ChangingProfileInfo,
+ ): ProfileChangingStatus {
+ return when {
+ beforeProfile.profileImage != afterProfile.profileImage &&
+ afterProfile.nickname != beforeProfile.nickname -> AllChanging
+
+ beforeProfile.nickname == afterProfile.nickname &&
+ beforeProfile.profileImage != afterProfile.profileImage -> OnlyImageChanging
+
+ beforeProfile.nickname != afterProfile.nickname -> OnlyNicknameChanging
+
+ else -> Same
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileInfo.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileInfo.kt
new file mode 100644
index 00000000..ccee3468
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/profile/ProfileInfo.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.core.domain.model.profile
+
+data class ProfileInfo(
+ val nickname: Nickname,
+ val profileImageUrl: String,
+ val email: String,
+)
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt
new file mode 100644
index 00000000..1fc76939
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/role/Role.kt
@@ -0,0 +1,5 @@
+package com.withpeace.withpeace.core.domain.model.role
+
+enum class Role {
+ GUEST, USER, UNKNOWN
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/ImageRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/ImageRepository.kt
new file mode 100644
index 00000000..7872ab72
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/ImageRepository.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.domain.repository
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import kotlinx.coroutines.flow.Flow
+
+interface ImageRepository {
+
+ suspend fun getFolders(): List
+
+ fun getImages(
+ folderName: String?,
+ pageSize: Int,
+ ): Flow>
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt
new file mode 100644
index 00000000..7221785c
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt
@@ -0,0 +1,51 @@
+package com.withpeace.withpeace.core.domain.repository
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostDetail
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.model.post.ReportType
+import kotlinx.coroutines.flow.Flow
+
+interface PostRepository {
+ fun getPosts(
+ postTopic: PostTopic,
+ pageSize: Int,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow>
+
+ fun registerPost(
+ post: RegisterPost,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun getPostDetail(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun deletePost(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun registerComment(
+ postId: Long,
+ content: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun reportPost(
+ postId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit
+ ): Flow
+
+ fun reportComment(
+ commentId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt
new file mode 100644
index 00000000..5a64fc83
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/TokenRepository.kt
@@ -0,0 +1,14 @@
+package com.withpeace.withpeace.core.domain.repository
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.role.Role
+import kotlinx.coroutines.flow.Flow
+
+interface TokenRepository {
+ suspend fun isLogin(): Boolean
+
+ fun getTokenByGoogle(
+ idToken: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt
new file mode 100644
index 00000000..65f1cdbc
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt
@@ -0,0 +1,45 @@
+package com.withpeace.withpeace.core.domain.repository
+
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.SignUpInfo
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile
+import com.withpeace.withpeace.core.domain.model.profile.Nickname
+import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo
+import kotlinx.coroutines.flow.Flow
+
+interface UserRepository {
+ fun getProfile(onError: suspend (CheonghaError) -> Unit): Flow
+
+ fun updateProfileImage(
+ profileImage: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun updateNickname(
+ nickname: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun updateProfile(
+ nickname: String, profileImage: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun verifyNicknameDuplicated(
+ nickname: Nickname,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+
+ fun logout(onError: suspend (CheonghaError) -> Unit): Flow
+
+ suspend fun signUp(
+ signUpInfo: SignUpInfo,
+ onError: suspend (CheonghaError) -> Unit
+ ): Flow
+
+ suspend fun getCurrentUserId(): Long
+ fun withdraw(
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt
new file mode 100644
index 00000000..dced8a55
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt
@@ -0,0 +1,14 @@
+package com.withpeace.withpeace.core.domain.repository
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import kotlinx.coroutines.flow.Flow
+
+interface YouthPolicyRepository {
+ fun getPolicies(
+ filterInfo: PolicyFilters,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow>
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeletePostUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeletePostUseCase.kt
new file mode 100644
index 00000000..139f9c08
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeletePostUseCase.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class DeletePostUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = postRepository.deletePost(postId, onError)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCase.kt
new file mode 100644
index 00000000..435b8fcc
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCase.kt
@@ -0,0 +1,14 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetAlbumImagesUseCase @Inject constructor(
+ private val imageRepository: ImageRepository,
+) {
+ operator fun invoke(selectedFolderName: String,pageSize:Int): Flow> =
+ imageRepository.getImages(selectedFolderName,pageSize)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCase.kt
new file mode 100644
index 00000000..387706c9
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCase.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import javax.inject.Inject
+
+class GetAllFoldersUseCase @Inject constructor(
+ private val imageRepository: ImageRepository,
+) {
+ suspend operator fun invoke(): List {
+ return imageRepository.getFolders()
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetCurrentUserIdUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetCurrentUserIdUseCase.kt
new file mode 100644
index 00000000..6e82c18d
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetCurrentUserIdUseCase.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import javax.inject.Inject
+
+class GetCurrentUserIdUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ suspend operator fun invoke(): Long {
+ return userRepository.getCurrentUserId()
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostDetailUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostDetailUseCase.kt
new file mode 100644
index 00000000..ed9b5c09
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostDetailUseCase.kt
@@ -0,0 +1,19 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.PostDetail
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetPostDetailUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ postId: Long,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = postRepository.getPostDetail(
+ postId,
+ onError,
+ )
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCase.kt
new file mode 100644
index 00000000..a64c6141
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCase.kt
@@ -0,0 +1,23 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetPostsUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ postTopic: PostTopic,
+ pageSize: Int,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow> = postRepository.getPosts(
+ postTopic = postTopic,
+ pageSize = pageSize,
+ onError = onError,
+ )
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetProfileInfoUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetProfileInfoUseCase.kt
new file mode 100644
index 00000000..8e610c04
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetProfileInfoUseCase.kt
@@ -0,0 +1,19 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import javax.inject.Inject
+
+class GetProfileInfoUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ operator fun invoke(onError: suspend (CheonghaError) -> Unit): Flow {
+ return userRepository.getProfile(
+ onError = onError,
+ )
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetYouthPoliciesUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetYouthPoliciesUseCase.kt
new file mode 100644
index 00000000..82be0a50
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetYouthPoliciesUseCase.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import androidx.paging.PagingData
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetYouthPoliciesUseCase @Inject constructor(
+ private val youthPolicyRepository: YouthPolicyRepository,
+) {
+ operator fun invoke(
+ filterInfo: PolicyFilters,
+ onError: (CheonghaError) -> Unit,
+ ): Flow> =
+ youthPolicyRepository.getPolicies(
+ filterInfo = filterInfo,
+ onError = onError,
+ )
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCase.kt
new file mode 100644
index 00000000..341d5900
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCase.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import javax.inject.Inject
+
+class GoogleLoginUseCase @Inject constructor(
+ private val tokenRepository: TokenRepository,
+) {
+ operator fun invoke(
+ idToken: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ) = tokenRepository.getTokenByGoogle(
+ idToken = idToken,
+ onError = onError,
+ )
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt
new file mode 100644
index 00000000..94b03e6f
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCase.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import javax.inject.Inject
+
+class IsLoginUseCase @Inject constructor(
+ private val tokenRepository: TokenRepository,
+) {
+ suspend operator fun invoke(): Boolean {
+ return tokenRepository.isLogin()
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/LogoutUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/LogoutUseCase.kt
new file mode 100644
index 00000000..0875ae6a
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/LogoutUseCase.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import javax.inject.Inject
+
+class LogoutUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ operator fun invoke(onError: suspend (CheonghaError) -> Unit) = userRepository.logout(
+ onError = onError,
+ )
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterCommentUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterCommentUseCase.kt
new file mode 100644
index 00000000..33959938
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterCommentUseCase.kt
@@ -0,0 +1,19 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import javax.inject.Inject
+
+class RegisterCommentUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ postId: Long,
+ content: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ) = postRepository.registerComment(
+ postId = postId,
+ content = content,
+ onError = onError,
+ )
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCase.kt
new file mode 100644
index 00000000..2d8f32b8
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCase.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class RegisterPostUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ post: RegisterPost,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow = postRepository.registerPost(post = post, onError = onError)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportCommentUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportCommentUseCase.kt
new file mode 100644
index 00000000..c2471b0f
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportCommentUseCase.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.ReportType
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import javax.inject.Inject
+
+class ReportCommentUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ commentId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit,
+ ) = postRepository.reportComment(commentId, reportType, onError)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportPostUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportPostUseCase.kt
new file mode 100644
index 00000000..18428917
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ReportPostUseCase.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.ReportType
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import javax.inject.Inject
+
+class ReportPostUseCase @Inject constructor(
+ private val postRepository: PostRepository,
+) {
+ operator fun invoke(
+ postId: Long,
+ reportType: ReportType,
+ onError: suspend (CheonghaError) -> Unit,
+ ) = postRepository.reportPost(postId, reportType, onError)
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt
new file mode 100644
index 00000000..76f12ba5
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SignUpUseCase.kt
@@ -0,0 +1,18 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.SignUpInfo
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import javax.inject.Inject
+
+class SignUpUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ suspend operator fun invoke(
+ signUpInfo: SignUpInfo,
+ onError: suspend (CheonghaError) -> Unit,
+ ) = userRepository.signUp(
+ signUpInfo,
+ onError = onError,
+ )
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdateProfileUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdateProfileUseCase.kt
new file mode 100644
index 00000000..b4cbe839
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdateProfileUseCase.kt
@@ -0,0 +1,41 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile
+import com.withpeace.withpeace.core.domain.model.profile.ChangingProfileInfo
+import com.withpeace.withpeace.core.domain.model.profile.ProfileChangingStatus
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+class UpdateProfileUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ operator fun invoke(
+ beforeProfile: ChangingProfileInfo,
+ afterProfile: ChangingProfileInfo,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow {
+ return when (ProfileChangingStatus.getStatus(beforeProfile, afterProfile)) {
+ ProfileChangingStatus.AllChanging -> {
+ userRepository.updateProfile(
+ afterProfile.nickname, afterProfile.profileImage,
+ onError = onError,
+ )
+ }
+ ProfileChangingStatus.OnlyImageChanging -> {
+ userRepository.updateProfileImage(
+ profileImage = afterProfile.profileImage,
+ onError = onError,
+ )
+ }
+ ProfileChangingStatus.OnlyNicknameChanging -> {
+ userRepository.updateNickname(afterProfile.nickname, onError = onError)
+ }
+
+ ProfileChangingStatus.Same -> flow { onError(ClientError.ProfileNotChanged) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/VerifyNicknameUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/VerifyNicknameUseCase.kt
new file mode 100644
index 00000000..2ae4ab53
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/VerifyNicknameUseCase.kt
@@ -0,0 +1,23 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.profile.Nickname
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+class VerifyNicknameUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ operator fun invoke(
+ nickname: String,
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow {
+ if (!Nickname.verifyFormat(nickname)) {
+ return flow { onError(ClientError.NicknameError.FormatInvalid) }
+ }
+ return userRepository.verifyNicknameDuplicated(Nickname(nickname), onError = onError)
+ }
+}
diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/WithdrawUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/WithdrawUseCase.kt
new file mode 100644
index 00000000..9dc30c63
--- /dev/null
+++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/WithdrawUseCase.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class WithdrawUseCase @Inject constructor(
+ private val userRepository: UserRepository,
+) {
+ operator fun invoke(
+ onError: suspend (CheonghaError) -> Unit,
+ ): Flow =
+ userRepository.withdraw(
+ onError = onError,
+ )
+}
\ No newline at end of file
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCaseTest.kt
new file mode 100644
index 00000000..03733625
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAlbumImagesUseCaseTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+
+class GetAlbumImagesUseCaseTest {
+
+ private lateinit var getAlbumImagesUseCase: GetAlbumImagesUseCase
+ private val imageRepository = mockk(relaxed = true)
+
+ @Test
+ fun `이미지 페이징 정보를 요청한다`() = runTest() {
+ // given
+ getAlbumImagesUseCase = GetAlbumImagesUseCase(imageRepository)
+ // when
+ getAlbumImagesUseCase("test",20)
+ // then
+ verify { imageRepository.getImages("test",20) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCaseTest.kt
new file mode 100644
index 00000000..40fdbef8
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetAllFoldersUseCaseTest.kt
@@ -0,0 +1,32 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.repository.ImageRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class GetAllFoldersUseCaseTest {
+ private lateinit var getAllFoldersUseCase: GetAllFoldersUseCase
+ private val imageRepository: ImageRepository = mockk()
+
+ @Test
+ fun `모든 폴더를 가져올 수 있다`() = runTest {
+ // given
+ val testFolders = List(10) {
+ ImageFolder(
+ folderName = "Landon Bradley",
+ representativeImageUri = "cum",
+ imageCount = 4322,
+ )
+ }
+ coEvery { imageRepository.getFolders() } returns testFolders
+ getAllFoldersUseCase = GetAllFoldersUseCase(imageRepository)
+ // when
+ val actual = getAllFoldersUseCase()
+ // then
+ assertThat(actual).isEqualTo(testFolders)
+ }
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCaseTest.kt
new file mode 100644
index 00000000..a761f738
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GetPostsUseCaseTest.kt
@@ -0,0 +1,31 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class GetPostsUseCaseTest {
+ private lateinit var getPostsUseCase: GetPostsUseCase
+ private val postRepository = mockk(relaxed = true)
+
+ @Test
+ fun `게시글 페이징 정보를 요청한다`() = runTest() {
+ // given
+ val mockError = mockk Unit>()
+ getPostsUseCase = GetPostsUseCase(postRepository)
+ // when
+ getPostsUseCase(postTopic = PostTopic.INFORMATION, pageSize = 0, onError = mockError)
+ // then
+ verify {
+ postRepository.getPosts(
+ postTopic = PostTopic.INFORMATION,
+ pageSize = 0,
+ onError = mockError,
+ )
+ }
+ }
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCaseTest.kt
new file mode 100644
index 00000000..b1d454ff
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/GoogleLoginUseCaseTest.kt
@@ -0,0 +1,65 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class GoogleLoginUseCaseTest {
+
+ private lateinit var googleLoginUseCase: GoogleLoginUseCase
+ private val tokenRepository: TokenRepository = mockk(relaxed = true)
+
+ private fun initialize() = GoogleLoginUseCase(tokenRepository)
+
+ @Test
+ fun `로그인에 실패하면, 실패 로직이 실행된다`() = runTest {
+ // given
+ val onFailure = mockk<(CheonghaError) -> Unit>(relaxed = true)
+ coEvery {
+ tokenRepository.getTokenByGoogle(
+ "test",
+ onError = onFailure,
+ )
+ } returns flow {
+ onFailure.invoke(ResponseError.FAILURE_LOGIN)
+ }
+ googleLoginUseCase = initialize()
+ // when
+ googleLoginUseCase(
+ idToken = "test",
+ onError = onFailure,
+ ).collect()
+ // then
+ coVerify { onFailure(ResponseError.FAILURE_LOGIN) }
+ }
+
+ @Test
+ fun `로그인에 성공하면, 성공응답을 반환한다`() = runTest {
+ // given
+ val onSuccess = mockk<() -> Unit>(relaxed = true)
+ coEvery {
+ tokenRepository.getTokenByGoogle(
+ idToken = "test",
+ onError = any(),
+ )
+ } returns flow {
+ onSuccess.invoke()
+ }
+ googleLoginUseCase = initialize()
+ // when
+ googleLoginUseCase(
+ idToken = "test",
+ onError = {},
+ ).collect()
+ // then
+ coVerify { onSuccess() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt
new file mode 100644
index 00000000..3b8d1f72
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/IsLoginUseCaseTest.kt
@@ -0,0 +1,33 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.repository.TokenRepository
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class IsLoginUseCaseTest {
+ private lateinit var isLoginUseCase: IsLoginUseCase
+ private val tokenRepository: TokenRepository = mockk(relaxed = true)
+
+ private fun initialize() = IsLoginUseCase(tokenRepository)
+
+ @Test
+ fun `로그인 상태이다`() = runTest {
+ // given
+ coEvery { tokenRepository.isLogin() } returns true
+ isLoginUseCase = initialize()
+ // when & then
+ assertThat(isLoginUseCase()).isTrue()
+ }
+
+ @Test
+ fun `비로그인 상태이다`() = runTest {
+ // given
+ coEvery { tokenRepository.isLogin() } returns false
+ isLoginUseCase = initialize()
+ // when & then
+ assertThat(isLoginUseCase()).isFalse()
+ }
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCaseTest.kt
new file mode 100644
index 00000000..7fe784eb
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/RegisterPostUseCaseTest.kt
@@ -0,0 +1,82 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.repository.PostRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+class RegisterPostUseCaseTest {
+ private lateinit var registerPostUseCase: RegisterPostUseCase
+ private val postRepository = mockk()
+
+ @Before
+ fun setup() {
+ registerPostUseCase = RegisterPostUseCase(postRepository)
+ }
+
+ @Test
+ fun `게시글을 등록을 성공하면, 게시글 아이디를 반환한다`() = runTest {
+ // given
+ val testRegisterPost = RegisterPost(
+ id = null,
+ title = "title",
+ content = "content",
+ topic = PostTopic.ECONOMY,
+ images = LimitedImages(
+ urls = listOf(),
+ maxCount = 9536,
+ alreadyExistCount = 6212,
+ ),
+ )
+ coEvery {
+ postRepository.registerPost(
+ testRegisterPost,
+ onError = any(),
+ )
+ } returns flow { emit(1L) }
+ // when
+ val actual = registerPostUseCase(testRegisterPost, {}).first()
+ assertThat(actual).isEqualTo(1L)
+ }
+
+ @Test
+ fun `게시글을 등록을 실패하면, 실패 람다를 실행한다`() = runTest {
+ // given
+ val errorMock = mockk Unit>(relaxed = true)
+ val errorSlot = slot Unit>()
+ coEvery {
+ postRepository.registerPost(
+ testRegisterPost,
+ onError = capture(errorSlot),
+ )
+ } returns flow { errorSlot.captured.invoke(ResponseError.UNKNOWN_ERROR) }
+ // when
+ registerPostUseCase(testRegisterPost) { errorMock.invoke(it) }.toList()
+ coVerify { errorMock.invoke(ResponseError.UNKNOWN_ERROR) }
+ }
+
+ private val testRegisterPost = RegisterPost(
+ id = null,
+ title = "title",
+ content = "content",
+ topic = PostTopic.ECONOMY,
+ images = LimitedImages(
+ urls = listOf(),
+ maxCount = 9536,
+ alreadyExistCount = 6212,
+ ),
+ )
+}
diff --git a/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt
new file mode 100644
index 00000000..10627e5d
--- /dev/null
+++ b/core/domain/src/test/kotlin/com/withpeace/withpeace/core/domain/usecase/SignUpUseCaseTest.kt
@@ -0,0 +1,66 @@
+package com.withpeace.withpeace.core.domain.usecase
+
+import com.withpeace.withpeace.core.domain.model.SignUpInfo
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.repository.UserRepository
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SignUpUseCaseTest {
+ private lateinit var signUpUseCase: SignUpUseCase
+ private val userRepository: UserRepository = mockk(relaxed = true)
+
+ private fun initialize() = SignUpUseCase(userRepository)
+
+ @Test
+ fun `회원가입에 성공하면, 성공응답을 반환한다`() = runTest {
+ // given
+ val onSuccess = mockk<() -> Unit>(relaxed = true)
+ coEvery {
+ userRepository.signUp(
+ SignUpInfo(
+ "Email",
+ "nickname",
+ ),
+ onError = any(),
+ )
+ } returns flow { onSuccess.invoke() }
+ signUpUseCase = initialize()
+ // when
+ signUpUseCase(SignUpInfo("Email", "nickname"), {}).collect()
+ // then
+ coVerify { onSuccess.invoke() }
+ }
+
+ @Test
+ fun `회원가입에 실패하면, 메세지가 담긴 실패응답을 반환한다`() = runTest {
+ // given
+ val errorMock = mockk<(CheonghaError) -> Unit>(relaxed = true)
+ coEvery {
+ userRepository.signUp(
+ SignUpInfo(
+ "Email",
+ "nickname",
+ ),
+ onError = errorMock,
+ )
+ } returns flow { errorMock(ClientError.AuthExpired) }
+ signUpUseCase = initialize()
+ // when
+ signUpUseCase(
+ SignUpInfo(
+ "Email",
+ "nickname",
+ ),
+ onError = errorMock,
+ ).collect()
+ // then
+ coVerify { errorMock(ClientError.AuthExpired) }
+ }
+}
diff --git a/core/imagestorage/.gitignore b/core/imagestorage/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/imagestorage/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/imagestorage/build.gradle.kts b/core/imagestorage/build.gradle.kts
new file mode 100644
index 00000000..8d8f8276
--- /dev/null
+++ b/core/imagestorage/build.gradle.kts
@@ -0,0 +1,14 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.hilt")
+ id("convention.coroutine")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.imagestorage"
+}
+
+dependencies {
+
+}
diff --git a/core/imagestorage/consumer-rules.pro b/core/imagestorage/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/imagestorage/proguard-rules.pro b/core/imagestorage/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/imagestorage/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/imagestorage/src/main/AndroidManifest.xml b/core/imagestorage/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/core/imagestorage/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/DefaultImageDataSource.kt b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/DefaultImageDataSource.kt
new file mode 100644
index 00000000..9befb7e4
--- /dev/null
+++ b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/DefaultImageDataSource.kt
@@ -0,0 +1,134 @@
+package com.withpeace.withpeace.core.imagestorage
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.provider.MediaStore.Images
+import androidx.core.os.bundleOf
+
+class DefaultImageDataSource(
+ private val context: Context,
+) : ImageDataSource {
+
+ private val uriExternal: Uri by lazy { // 버젼에 따라 외부 이미지 파일에 접근하는 uri가 다름.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ } else {
+ Images.Media.EXTERNAL_CONTENT_URI
+ }
+ }
+
+ private val sortedOrder = Images.ImageColumns.DATE_TAKEN
+
+ override suspend fun getImages(
+ page: Int,
+ loadSize: Int,
+ folder: String?,
+ ): List {
+ val imageInfo = mutableListOf()
+ val pagingImagesQuery =
+ context.getPagingImagesQuery((page - 1) * loadSize, loadSize, folder)
+ pagingImagesQuery.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(Images.ImageColumns.MIME_TYPE)
+ val sizeIndex = cursor.getColumnIndex(Images.ImageColumns.SIZE)
+ val idColumn = cursor.getColumnIndexOrThrow(Images.Media._ID)
+ while (cursor.moveToNext()) {
+ val uri = ContentUris.withAppendedId(uriExternal, cursor.getLong(idColumn))
+ val mimeType = cursor.getString(nameIndex)
+ val size = cursor.getLong(sizeIndex)
+
+ imageInfo.add(ImageInfoEntity(uri, mimeType, size))
+ }
+ cursor.close()
+ }
+ return imageInfo
+ }
+
+ override suspend fun getFolders(): List {
+ val folderList = mutableListOf()
+ val folderQuery = context.getFolderQuery()
+ folderQuery.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(Images.Media._ID)
+ val bucketNameColumn = cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME)
+ while (cursor.moveToNext()) {
+ val uri = ContentUris.withAppendedId(uriExternal, cursor.getLong(idColumn))
+ val folderName = cursor.getString(bucketNameColumn) ?: continue
+ val matchedFolder = folderList.find { it.folderName == folderName }
+ if (matchedFolder == null) { //처음 등장하는 폴더 이름이면 리스트에 추가
+ folderList.add(ImageFolderEntity(folderName, uri, 1))
+ } else { // 아니라면 Count값을 증가
+ val index = folderList.indexOf(matchedFolder)
+ folderList[index] = matchedFolder.copy(count = matchedFolder.count + 1)
+ }
+ }
+ cursor.close()
+ }
+ return folderList
+ }
+
+ private fun Context.getPagingImagesQuery(
+ offset: Int?,
+ limit: Int?,
+ folder: String?,
+ ): Cursor {
+ val projection = arrayOf(
+ Images.ImageColumns.DATA,
+ Images.Media._ID,
+ Images.ImageColumns.MIME_TYPE,
+ Images.ImageColumns.SIZE,
+ )
+ val selection = folder?.let { "${Images.Media.DATA} LIKE ?" }
+ val selectionArgs = folder?.let { arrayOf("%$folder%") }
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
+ val bundle = bundleOf(
+ ContentResolver.QUERY_ARG_OFFSET to offset,
+ ContentResolver.QUERY_ARG_LIMIT to limit,
+ ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf(MediaStore.Files.FileColumns.DATE_MODIFIED),
+ ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
+ ContentResolver.QUERY_ARG_SQL_SELECTION to selection,
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
+ )
+ return contentResolver.query(uriExternal, projection, bundle, null)
+ ?: throw IllegalStateException("이미지 커서를 가져올 수 없어요")
+ } else {
+ return contentResolver.query(
+ uriExternal,
+ projection,
+ selection,
+ selectionArgs,
+ "$sortedOrder DESC LIMIT $limit OFFSET $offset",
+ ) ?: throw IllegalStateException("이미지 커서를 가져올 수 없어요")
+ }
+ }
+
+ private fun Context.getFolderQuery(): Cursor {
+ val projection = arrayOf(
+ Images.Media._ID,
+ Images.Media.BUCKET_DISPLAY_NAME,
+ )
+ val selection = null
+ val selectionArgs = null
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
+ val bundle = bundleOf(
+ ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf(MediaStore.Files.FileColumns.DATE_MODIFIED),
+ ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
+ ContentResolver.QUERY_ARG_SQL_SELECTION to selection,
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
+ )
+ return contentResolver.query(uriExternal, projection, bundle, null)
+ ?: throw IllegalStateException("이미지 커서를 가져올 수 없어요")
+ } else {
+ return contentResolver.query(
+ uriExternal,
+ projection,
+ selection,
+ selectionArgs,
+ "${Images.Media.DATE_ADDED} DESC",
+ ) ?: throw IllegalStateException("이미지를 가져올 수 없어요.")
+ }
+ }
+}
diff --git a/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSource.kt b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSource.kt
new file mode 100644
index 00000000..8c097bcb
--- /dev/null
+++ b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSource.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.imagestorage
+
+import android.net.Uri
+
+interface ImageDataSource {
+ suspend fun getImages(
+ page: Int,
+ loadSize: Int,
+ folder: String?,
+ ):List
+
+ suspend fun getFolders(): List
+}
diff --git a/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSourceModule.kt b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSourceModule.kt
new file mode 100644
index 00000000..c4f7fb9e
--- /dev/null
+++ b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageDataSourceModule.kt
@@ -0,0 +1,19 @@
+package com.withpeace.withpeace.core.imagestorage
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ImageDataSourceModule {
+ @Provides
+ @Singleton
+ fun provideImageDataSource(
+ @ApplicationContext context: Context,
+ ): ImageDataSource = DefaultImageDataSource(context)
+}
diff --git a/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageFolderEntity.kt b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageFolderEntity.kt
new file mode 100644
index 00000000..d1d36886
--- /dev/null
+++ b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageFolderEntity.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.imagestorage
+
+import android.net.Uri
+
+data class ImageFolderEntity(
+ val folderName: String,
+ val representativeImageUri: Uri,
+ val count: Int,
+)
diff --git a/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageInfoEntity.kt b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageInfoEntity.kt
new file mode 100644
index 00000000..f001f16c
--- /dev/null
+++ b/core/imagestorage/src/main/java/com/withpeace/withpeace/core/imagestorage/ImageInfoEntity.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.imagestorage
+
+import android.net.Uri
+
+data class ImageInfoEntity(
+ val imageUri: Uri,
+ val mimeType: String,
+ val byteSize: Long,
+)
\ No newline at end of file
diff --git a/core/interceptor/.gitignore b/core/interceptor/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/interceptor/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/interceptor/build.gradle.kts b/core/interceptor/build.gradle.kts
new file mode 100644
index 00000000..93fb4880
--- /dev/null
+++ b/core/interceptor/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.hilt")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.interceptor"
+ defaultConfig {
+ consumerProguardFiles("consumer-rules.pro")
+ }
+}
+
+dependencies {
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.retrofit.core)
+ implementation(libs.okhttp.logging)
+ implementation(libs.skydoves.sandwich)
+ implementation(project(":core:datastore"))
+ implementation(project(":core:network"))
+}
diff --git a/core/interceptor/consumer-rules.pro b/core/interceptor/consumer-rules.pro
new file mode 100644
index 00000000..259cd7d7
--- /dev/null
+++ b/core/interceptor/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class com.skydoves.sandwich.** {*;}
diff --git a/core/interceptor/proguard-rules.pro b/core/interceptor/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/interceptor/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/interceptor/src/main/AndroidManifest.xml b/core/interceptor/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..44008a43
--- /dev/null
+++ b/core/interceptor/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt
new file mode 100644
index 00000000..0b2fdba1
--- /dev/null
+++ b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt
@@ -0,0 +1,78 @@
+package com.withpeace.withpeace.core.interceptor
+
+import com.skydoves.sandwich.suspendMapSuccess
+import com.skydoves.sandwich.suspendOnError
+import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource
+import com.withpeace.withpeace.core.network.di.response.TokenResponse
+import com.withpeace.withpeace.core.network.di.service.AuthService
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+class AuthInterceptor @Inject constructor(
+ private val tokenPreferenceDataSource: TokenPreferenceDataSource,
+ private val authService: AuthService,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ // 일단 저장된 accessToken을 Header에 넣어준다
+ val accessToken = runBlocking { tokenPreferenceDataSource.accessToken.firstOrNull() }
+ var response = chain.getResponse(accessToken)
+ var count = 1
+
+ // Header에 넣어줬는데, 401이 뜬다면, RefreshToken 유무를 확인하고, accessToken을 재발급받아 다시 Header에 넣어준다.
+ if (response.code == 401) {
+ val refreshToken = runBlocking { tokenPreferenceDataSource.refreshToken.firstOrNull() }
+ if (refreshToken != null) {
+ while (count <= REQUEST_MAX_NUM) {
+ requestRefreshToken(
+ refreshToken,
+ onSuccess = { data ->
+ tokenPreferenceDataSource.updateAccessToken(data.accessToken)
+ tokenPreferenceDataSource.updateRefreshToken(data.refreshToken)
+ response = chain.getResponse(data.accessToken)
+ count = 4
+ },
+ onFail = { count++ },
+ )
+ }
+ }
+ }
+ return response
+ }
+
+ private fun requestRefreshToken(
+ refreshToken: String,
+ onSuccess: suspend (TokenResponse) -> Unit,
+ onFail: () -> Unit,
+ ) {
+ runBlocking {
+ authService.refreshAccessToken(TOKEN_FORMAT.format(refreshToken))
+ .suspendMapSuccess {
+ onSuccess(data)
+ }.suspendOnError {
+ onFail()
+ }
+ }
+ }
+
+ private fun Interceptor.Chain.getResponse(accessToken: String?): Response {
+ return this
+ .proceed(
+ request()
+ .newBuilder()
+ .addHeader(
+ ACCESS_TOKEN_HEADER,
+ TOKEN_FORMAT.format(accessToken),
+ ).build(),
+ )
+ }
+
+ companion object {
+ private const val ACCESS_TOKEN_HEADER = "Authorization"
+ private const val TOKEN_FORMAT = "Bearer %s"
+ private const val REQUEST_MAX_NUM = 3
+ }
+}
diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt
new file mode 100644
index 00000000..b75a642a
--- /dev/null
+++ b/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt
@@ -0,0 +1,23 @@
+package com.withpeace.withpeace.core.interceptor
+
+import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource
+import com.withpeace.withpeace.core.network.di.service.AuthService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.Interceptor
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object InterceptorModule {
+
+ @Provides
+ @Singleton
+ fun provideHeaderInterceptor(
+ tokenPreferenceDataSource: TokenPreferenceDataSource,
+ authService: AuthService,
+ ): Interceptor =
+ AuthInterceptor(tokenPreferenceDataSource, authService)
+}
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..09dc670f
--- /dev/null
+++ b/core/network/build.gradle.kts
@@ -0,0 +1,38 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
+fun getLocalPropertyString(propertyKey: String): String {
+ return gradleLocalProperties(rootDir).getProperty(propertyKey)
+}
+
+
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.hilt")
+ id("convention.coroutine")
+ id("kotlinx-serialization")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.network"
+ defaultConfig {
+ consumerProguardFiles("consumer-rules.pro")
+ buildConfigField(
+ "String",
+ "BASE_URL",
+ getLocalPropertyString("BASE_URL"),
+ )
+ }
+}
+
+dependencies {
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.retrofit.kotlin.serialization)
+ implementation(libs.retrofit.core)
+ implementation(libs.okhttp.logging)
+ implementation(libs.skydoves.sandwich)
+ kapt(libs.tikxml.processor)
+ implementation(libs.tikxml.core)
+ implementation(libs.retrofit.tikxml.converter)
+ implementation(libs.tikxml.annotation)
+}
diff --git a/core/network/consumer-rules.pro b/core/network/consumer-rules.pro
new file mode 100644
index 00000000..259cd7d7
--- /dev/null
+++ b/core/network/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class com.skydoves.sandwich.** {*;}
diff --git a/core/network/proguard-rules.pro b/core/network/proguard-rules.pro
new file mode 100644
index 00000000..92b6c05c
--- /dev/null
+++ b/core/network/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceF
diff --git a/core/network/src/androidTest/java/com/withpeace/withpeace/core/network/ExampleInstrumentedTest.kt b/core/network/src/androidTest/java/com/withpeace/withpeace/core/network/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..dd396ff4
--- /dev/null
+++ b/core/network/src/androidTest/java/com/withpeace/withpeace/core/network/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace.core.network
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.core.network.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/core/network/src/main/AndroidManifest.xml b/core/network/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..44008a43
--- /dev/null
+++ b/core/network/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/OkHttpUtil.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/OkHttpUtil.kt
new file mode 100644
index 00000000..a8c996c6
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/OkHttpUtil.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.network.di.common
+
+import okhttp3.ResponseBody
+import org.json.JSONObject
+
+fun ResponseBody.getErrorBody(): WithPeaceErrorBody {
+ val json = JSONObject(string())
+ val errorBody = JSONObject(json.getString("error") ?: "")
+ val errorMessage = errorBody.getString("code")
+ val errorCode = errorBody.getInt("code")
+ return WithPeaceErrorBody(errorCode, errorMessage)
+}
\ No newline at end of file
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/WithPeaceErrorBody.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/WithPeaceErrorBody.kt
new file mode 100644
index 00000000..792b89b1
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/WithPeaceErrorBody.kt
@@ -0,0 +1,6 @@
+package com.withpeace.withpeace.core.network.di.common
+
+data class WithPeaceErrorBody(
+ val code: Int?,
+ val message: String?,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt
new file mode 100644
index 00000000..8f29e2b5
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt
@@ -0,0 +1,127 @@
+package com.withpeace.withpeace.core.network.di.di
+
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory
+import com.tickaroo.tikxml.TikXml
+import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory
+import com.withpeace.withpeace.core.network.BuildConfig
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+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
+import java.util.concurrent.TimeUnit
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+ @Provides
+ @Singleton
+ fun provideConverterFactory(): Converter.Factory {
+ val json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ encodeDefaults = true
+ isLenient = true
+ }
+
+ val jsonMediaType = "application/json".toMediaType()
+ return json.asConverterFactory(jsonMediaType)
+ }
+
+ @Provides
+ @Singleton
+ fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
+ return HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ }
+
+ @Named("general_client")
+ @Singleton
+ @Provides
+ fun provideGeneralOkhttpClient(
+ authInterceptor: Interceptor,
+ httpLoggingInterceptor: HttpLoggingInterceptor,
+ ): OkHttpClient {
+ return OkHttpClient.Builder().apply {
+ addInterceptor(authInterceptor)
+ addInterceptor(httpLoggingInterceptor)
+ connectTimeout(30, TimeUnit.SECONDS)
+ readTimeout(30, TimeUnit.SECONDS)
+ writeTimeout(30, TimeUnit.SECONDS)
+ }.build()
+ }
+
+ @Named("logging_client")
+ @Singleton
+ @Provides
+ fun provideAuthOkhttpClient(
+ httpLoggingInterceptor: HttpLoggingInterceptor,
+ ): OkHttpClient {
+ return OkHttpClient.Builder().apply {
+ addInterceptor(httpLoggingInterceptor)
+ connectTimeout(30, TimeUnit.SECONDS)
+ readTimeout(30, TimeUnit.SECONDS)
+ writeTimeout(30, TimeUnit.SECONDS)
+ }.build()
+ }
+
+ @Named("general")
+ @Provides
+ @Singleton
+ fun provideTokenRetrofitClient(
+ @Named("general_client") okHttpClient: OkHttpClient,
+ converterFactory: Converter.Factory,
+ ): Retrofit {
+ return Retrofit.Builder()
+ .client(okHttpClient)
+ .baseUrl(BuildConfig.BASE_URL)
+ .addConverterFactory(converterFactory)
+ .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
+ .build()
+ }
+
+
+ /**
+ * todo: 네이밍 수정
+ */
+ @Named("auth")
+ @Provides
+ @Singleton
+ fun provideRetrofitClient(
+ converterFactory: Converter.Factory,
+ @Named("logging_client") okHttpClient: OkHttpClient,
+ ): Retrofit {
+ return Retrofit.Builder()
+ .client(okHttpClient)
+ .baseUrl(BuildConfig.BASE_URL)
+ .addConverterFactory(converterFactory)
+ .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
+ .build()
+ }
+
+ @Named("youth_policy")
+ @Provides
+ @Singleton
+ fun provideXmlRetrofitClient(
+ @Named("logging_client") okHttpClient: OkHttpClient,
+ ): Retrofit {
+ val parser = TikXml.Builder().exceptionOnUnreadXml(false).build()
+
+ return Retrofit.Builder()
+ .client(okHttpClient)
+ .baseUrl("https://www.youthcenter.go.kr/opi/")
+ .addConverterFactory(TikXmlConverterFactory.create(parser))
+ .addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
+ .build()
+ }
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/ServiceModule.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/ServiceModule.kt
new file mode 100644
index 00000000..c58175ff
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/ServiceModule.kt
@@ -0,0 +1,38 @@
+package com.withpeace.withpeace.core.network.di.di
+
+import com.withpeace.withpeace.core.network.di.service.UserService
+import com.withpeace.withpeace.core.network.di.service.AuthService
+import com.withpeace.withpeace.core.network.di.service.PostService
+import com.withpeace.withpeace.core.network.di.service.YouthPolicyService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ServiceModule {
+
+ @Provides
+ @Singleton
+ fun providesLoginService(@Named("auth") retrofit: Retrofit): AuthService =
+ retrofit.create(AuthService::class.java)
+
+ @Provides
+ @Singleton
+ fun providesPostService(@Named("general") retrofit: Retrofit): PostService =
+ retrofit.create(PostService::class.java)
+
+ @Provides
+ @Singleton
+ fun providesUserService(@Named("general") retrofit: Retrofit): UserService =
+ retrofit.create(UserService::class.java)
+
+ @Provides
+ @Singleton
+ fun providesYouthPolicyService(@Named("youth_policy") retrofit: Retrofit): YouthPolicyService =
+ retrofit.create(YouthPolicyService::class.java)
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/CommentRequest.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/CommentRequest.kt
new file mode 100644
index 00000000..6b07ccec
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/CommentRequest.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.network.di.request
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CommentRequest(
+ val content: String,
+)
+
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/NicknameRequest.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/NicknameRequest.kt
new file mode 100644
index 00000000..db523e55
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/NicknameRequest.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.core.network.di.request
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class NicknameRequest(
+ val nickname: String,
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/ReportTypeRequest.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/ReportTypeRequest.kt
new file mode 100644
index 00000000..af6b57a9
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/ReportTypeRequest.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.core.network.di.request
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ReportTypeRequest(
+ val reason: String,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt
new file mode 100644
index 00000000..ac9248fd
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/request/SignUpRequest.kt
@@ -0,0 +1,3 @@
+package com.withpeace.withpeace.core.network.di.request
+
+import kotlinx.serialization.Serializable
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/BaseResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/BaseResponse.kt
new file mode 100644
index 00000000..e7057676
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/BaseResponse.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BaseResponse(
+ val data: T,
+ val error: String?,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ChangedProfileResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ChangedProfileResponse.kt
new file mode 100644
index 00000000..1bb80cec
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ChangedProfileResponse.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ChangedProfileResponse(
+ val nickname: String,
+ val profileImageUrl: String,
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt
new file mode 100644
index 00000000..e4083e42
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/LoginResponse.kt
@@ -0,0 +1,12 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LoginResponse(
+ @SerialName("jwtTokenDto")
+ val tokenResponse: TokenResponse,
+ val role: String,
+ val userId: Long,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ProfileResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ProfileResponse.kt
new file mode 100644
index 00000000..d892b3c6
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/ProfileResponse.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ProfileResponse(
+ val email: String,
+ val profileImageUrl: String,
+ val nickname: String,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/TokenResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/TokenResponse.kt
new file mode 100644
index 00000000..7f0c414f
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/TokenResponse.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TokenResponse(
+ val accessToken: String,
+ val refreshToken: String,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/YouthPolicyListResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/YouthPolicyListResponse.kt
new file mode 100644
index 00000000..b57c1340
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/YouthPolicyListResponse.kt
@@ -0,0 +1,31 @@
+package com.withpeace.withpeace.core.network.di.response
+
+import com.tickaroo.tikxml.annotation.Element
+import com.tickaroo.tikxml.annotation.PropertyElement
+import com.tickaroo.tikxml.annotation.Xml
+
+@Xml(name = "youthPolicyList")
+data class YouthPolicyListResponse(
+ @PropertyElement
+ val pageIndex: Int,
+ @PropertyElement(name = "totalCount")
+ val totalDataCount: Int,
+ @Element
+ val youthPolicyEntity: List,
+)
+
+@Xml(name = "youthPolicy")
+data class YouthPolicyEntity(
+ @PropertyElement(name = "bizId", writeAsCData = true)
+ val id: String,
+ @PropertyElement(name = "polyBizSjnm", writeAsCData = true) // XML에서는 String 형식을 CData라고 정의함
+ val title: String?, // API 데이터를 넣는 상황에, 휴먼에러를 고려하여 nullable 설정
+ @PropertyElement(name = "polyItcnCn", writeAsCData = true)
+ val introduce: String?,
+ @PropertyElement(name = "polyRlmCd", writeAsCData = true)
+ val classification: String?,
+ @PropertyElement(name = "polyBizSecd")
+ val regionCode: String?,
+ @PropertyElement(name = "ageInfo", writeAsCData = true)
+ val ageInfo: String?,
+)
\ No newline at end of file
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/CommentResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/CommentResponse.kt
new file mode 100644
index 00000000..06b06e03
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/CommentResponse.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.network.di.response.post
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CommentResponse(
+ val commentId: Long,
+ val userId: Long,
+ val nickname: String,
+ val profileImageUrl: String,
+ val content: String,
+ val createDate: String,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostDetailResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostDetailResponse.kt
new file mode 100644
index 00000000..474050cc
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostDetailResponse.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.core.network.di.response.post
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PostDetailResponse(
+ val postId: Long,
+ val userId: Long,
+ val nickname: String,
+ val profileImageUrl: String,
+ val title: String,
+ val content: String,
+ val type: PostTopicResponse,
+ val createDate: String,
+ val postImageUrls: List,
+ val comments: List,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostIdResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostIdResponse.kt
new file mode 100644
index 00000000..90d675fc
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostIdResponse.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.core.network.di.response.post
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PostIdResponse(
+ val postId: Long,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostResponse.kt
new file mode 100644
index 00000000..1729bae9
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostResponse.kt
@@ -0,0 +1,13 @@
+package com.withpeace.withpeace.core.network.di.response.post
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PostResponse(
+ val postId: Long,
+ val title: String,
+ val content: String,
+ val type: PostTopicResponse,
+ val postImageUrl: String? = null,
+ val createDate: String,
+)
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostTopicResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostTopicResponse.kt
new file mode 100644
index 00000000..7cda67a4
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/PostTopicResponse.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.core.network.di.response.post;
+
+enum class PostTopicResponse {
+ FREEDOM,
+ INFORMATION,
+ QUESTION,
+ LIVING,
+ HOBBY,
+ ECONOMY
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt
new file mode 100644
index 00000000..c44cd99a
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/AuthService.kt
@@ -0,0 +1,29 @@
+package com.withpeace.withpeace.core.network.di.service
+
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.network.di.response.BaseResponse
+import com.withpeace.withpeace.core.network.di.response.LoginResponse
+import com.withpeace.withpeace.core.network.di.response.TokenResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.http.Header
+import retrofit2.http.Multipart
+import retrofit2.http.POST
+import retrofit2.http.Part
+
+interface AuthService {
+
+ @POST("/api/v1/auth/google")
+ suspend fun googleLogin(
+ @Header("Authorization")
+ idToken: String,
+ ): ApiResponse>
+
+ @POST("/api/v1/auth/refresh")
+ suspend fun refreshAccessToken(
+ @Header("Authorization") refreshToken: String,
+ ): ApiResponse>
+
+ @POST("/api/v1/auth/logout")
+ suspend fun logout(): ApiResponse>
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt
new file mode 100644
index 00000000..b910ee02
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt
@@ -0,0 +1,74 @@
+package com.withpeace.withpeace.core.network.di.service
+
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.network.di.request.CommentRequest
+import com.withpeace.withpeace.core.network.di.request.ReportTypeRequest
+import com.withpeace.withpeace.core.network.di.response.BaseResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostDetailResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostIdResponse
+import com.withpeace.withpeace.core.network.di.response.post.PostResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Multipart
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Part
+import retrofit2.http.PartMap
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface PostService {
+
+ @Multipart
+ @POST("/api/v1/posts/register")
+ suspend fun registerPost(
+ @PartMap postRequest: HashMap,
+ @Part images: List,
+ ): ApiResponse>
+
+ @Multipart
+ @PUT("/api/v1/posts/{postId}")
+ suspend fun editPost(
+ @Path("postId") postId: Long,
+ @PartMap postRequest: HashMap,
+ @Part images: List,
+ ): ApiResponse>
+
+ @GET("/api/v1/posts/{postId}")
+ suspend fun getPost(
+ @Path("postId") postId: Long,
+ ): ApiResponse>
+
+ @GET("/api/v1/posts")
+ suspend fun getPosts(
+ @Query("type") postTopic: String,
+ @Query("pageIndex") pageIndex: Int,
+ @Query("pageSize") pageSize: Int,
+ ): ApiResponse>>
+
+ @DELETE("/api/v1/posts/{postId}")
+ suspend fun deletePost(
+ @Path("postId") postId: Long,
+ ): ApiResponse>
+
+ @POST("/api/v1/posts/{postId}/comments/register")
+ suspend fun registerComment(
+ @Path("postId") postId: Long,
+ @Body commentRequest: CommentRequest,
+ ): ApiResponse>
+
+ @POST("/api/v1/posts/{postId}/reportPost")
+ suspend fun reportPost(
+ @Path("postId") postId: Long,
+ @Body reportTypeRequest: ReportTypeRequest,
+ ): ApiResponse>
+
+ @POST("/api/v1/posts/{commentId}/reportComment")
+ suspend fun reportComment(
+ @Path("commentId") commentId: Long,
+ @Body reportTypeRequest: ReportTypeRequest,
+ ): ApiResponse>
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt
new file mode 100644
index 00000000..580d1429
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt
@@ -0,0 +1,60 @@
+package com.withpeace.withpeace.core.network.di.service
+
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.network.di.request.NicknameRequest
+import com.withpeace.withpeace.core.network.di.response.BaseResponse
+import com.withpeace.withpeace.core.network.di.response.ChangedProfileResponse
+import com.withpeace.withpeace.core.network.di.response.ProfileResponse
+import com.withpeace.withpeace.core.network.di.response.TokenResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.Multipart
+import retrofit2.http.PATCH
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Part
+import retrofit2.http.Query
+
+interface UserService {
+ @GET("/api/v1/users/profile")
+ suspend fun getProfile(): ApiResponse>
+
+ @PATCH("/api/v1/users/profile/nickname")
+ suspend fun updateNickname(@Body nicknameRequest: NicknameRequest): ApiResponse>
+
+ @Multipart
+ @PATCH("/api/v1/users/profile/image")
+ suspend fun updateImage(@Part imageFile: MultipartBody.Part): ApiResponse>
+
+ @Multipart
+ @PUT("/api/v1/users/profile")
+ suspend fun updateProfile(
+ @Part("nickname") nickname: RequestBody,
+ @Part imageFile: MultipartBody.Part,
+ ): ApiResponse>
+
+ @GET("/api/v1/users/profile/nickname/check")
+ suspend fun isNicknameDuplicate(@Query("nickname") nickname: String): ApiResponse>
+
+ @POST("/api/v1/auth/logout")
+ suspend fun logout(): ApiResponse>
+
+ @Multipart
+ @POST("/api/v1/auth/register")
+ suspend fun signUp(
+ @Part("nickname") nickname: RequestBody,
+ @Part imageFile: MultipartBody.Part,
+ ): ApiResponse>
+
+ @Multipart
+ @POST("/api/v1/auth/register")
+ suspend fun signUp(
+ @Part("nickname") nickname: RequestBody,
+ ): ApiResponse>
+
+ @DELETE("/api/v1/users")
+ suspend fun withdraw(): ApiResponse>
+}
diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt
new file mode 100644
index 00000000..cae65855
--- /dev/null
+++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.core.network.di.service
+
+import com.skydoves.sandwich.ApiResponse
+import com.withpeace.withpeace.core.network.di.response.YouthPolicyListResponse
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface YouthPolicyService {
+ @GET("youthPlcyList.do")
+ suspend fun getPolicies(
+ @Query("openApiVlak") apiKey: String,
+ @Query("display") pageSize: Int,
+ @Query("pageIndex") pageIndex: Int,
+ @Query("bizTycdSel") classification: String?,
+ @Query("srchPolyBizSecd") region: String?,
+ ): ApiResponse
+}
\ No newline at end of file
diff --git a/core/network/src/test/java/com/withpeace/withpeace/core/network/ExampleUnitTest.kt b/core/network/src/test/java/com/withpeace/withpeace/core/network/ExampleUnitTest.kt
new file mode 100644
index 00000000..3a01bec8
--- /dev/null
+++ b/core/network/src/test/java/com/withpeace/withpeace/core/network/ExampleUnitTest.kt
@@ -0,0 +1,16 @@
+package com.withpeace.withpeace.core.network
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/core/permission/.gitignore b/core/permission/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/permission/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/permission/build.gradle.kts b/core/permission/build.gradle.kts
new file mode 100644
index 00000000..1a29f189
--- /dev/null
+++ b/core/permission/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.compose")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.permission"
+}
+
+dependencies{
+ implementation(project(":core:designsystem"))
+}
diff --git a/core/permission/consumer-rules.pro b/core/permission/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/permission/proguard-rules.pro b/core/permission/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/permission/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/permission/src/main/AndroidManifest.xml b/core/permission/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/core/permission/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/permission/src/main/java/com/withpeace/withpeace/core/permission/ImagePermission.kt b/core/permission/src/main/java/com/withpeace/withpeace/core/permission/ImagePermission.kt
new file mode 100644
index 00000000..181fdfe7
--- /dev/null
+++ b/core/permission/src/main/java/com/withpeace/withpeace/core/permission/ImagePermission.kt
@@ -0,0 +1,145 @@
+package com.withpeace.withpeace.core.permission
+
+import android.Manifest.permission.READ_EXTERNAL_STORAGE
+import android.Manifest.permission.READ_MEDIA_IMAGES
+import android.Manifest.permission.READ_MEDIA_VIDEO
+import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+class ImagePermissionHelper(
+ private val context: Context,
+) {
+ fun onCheckSelfImagePermission(
+ onPermissionGranted: () -> Unit,
+ onPermissionDenied: () -> Unit,
+ ) {
+ when {
+ isOverThirteenImageGranted() -> onPermissionGranted()
+ isOverFourteenImagePartialGranted() -> onPermissionGranted()
+ isLessThanTwelveImageGranted() -> onPermissionGranted()
+ else -> onPermissionDenied()
+ }
+ }
+
+ @Composable
+ fun getImageLauncher(
+ onPermissionGranted: () -> Unit,
+ onPermissionDenied: () -> Unit,
+ ): ManagedActivityResultLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
+ if (isGranted) onPermissionGranted()
+ else onPermissionDenied()
+ }
+
+ fun requestPermissionDialog(
+ launcher: ManagedActivityResultLauncher,
+ ) {
+ when {
+ isLessThanTwelve -> launcher.launch(READ_EXTERNAL_STORAGE)
+ isOverFourteen -> launcher.launch(READ_MEDIA_IMAGES)
+ isOverThirteen -> launcher.launch(READ_MEDIA_IMAGES)
+ else -> {}
+ }
+ }
+
+ private val isLessThanTwelve = Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2
+ private val isOverThirteen = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ private val isOverFourteen = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+
+ private fun isLessThanTwelveImageGranted() =
+ isLessThanTwelve && ContextCompat.checkSelfPermission(
+ context,
+ READ_EXTERNAL_STORAGE,
+ ) == PERMISSION_GRANTED
+
+ private fun isOverThirteenImageGranted() =
+ isOverThirteen && (
+ ContextCompat.checkSelfPermission(
+ context,
+ READ_MEDIA_IMAGES,
+ ) == PERMISSION_GRANTED ||
+ ContextCompat.checkSelfPermission(
+ context,
+ READ_MEDIA_VIDEO,
+ ) == PERMISSION_GRANTED
+ )
+
+ private fun isOverFourteenImagePartialGranted() =
+ isOverFourteen &&
+ ContextCompat.checkSelfPermission(
+ context,
+ READ_MEDIA_VISUAL_USER_SELECTED,
+ ) == PERMISSION_GRANTED
+
+ @Composable
+ fun ImagePermissionDialog(
+ onDismissRequest: () -> Unit,
+ ) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier = Modifier
+ .background(
+ WithpeaceTheme.colors.SystemWhite,
+ RoundedCornerShape(10.dp),
+ )
+ .fillMaxWidth(),
+ ) {
+ Text(
+ modifier = Modifier.padding(24.dp),
+ text = stringResource(R.string.image_permission_dialog_text),
+ style = WithpeaceTheme.typography.caption,
+ )
+ Row(modifier = Modifier.align(Alignment.End)) {
+ Text(
+ modifier = Modifier
+ .clickable {
+ onDismissRequest()
+ }
+ .padding(end = 16.dp, bottom = 16.dp),
+ text = stringResource(R.string.cancel),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SubBlue2,
+ )
+ Text(
+ modifier = Modifier
+ .clickable {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.parse("package:${context.packageName}")
+ context.startActivity(intent)
+ onDismissRequest()
+ }
+ .padding(start = 16.dp, end = 30.dp, bottom = 16.dp),
+ text = stringResource(R.string.go_to_setting),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SubBlue2,
+ )
+ }
+ }
+ }
+ }
+}
+
diff --git a/core/permission/src/main/res/values/strings.xml b/core/permission/src/main/res/values/strings.xml
new file mode 100644
index 00000000..18702e65
--- /dev/null
+++ b/core/permission/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 저장공간 권한이 꺼져있습니다.\n사진 업로드를 위해서는 [권한] 설정에서\n저장공간 권한을 허용해야합니다.
+ 설정으로 가기
+ 취소
+
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..a0e7bbd5
--- /dev/null
+++ b/core/testing/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.coroutine")
+ id("convention.test.library")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.testing"
+}
+
+dependencies {
+ api(libs.coroutines.test)
+ api(libs.junit4)
+ api(libs.mockk)
+}
diff --git a/core/testing/consumer-rules.pro b/core/testing/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/testing/proguard-rules.pro b/core/testing/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/testing/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/testing/src/main/AndroidManifest.xml b/core/testing/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/core/testing/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/testing/src/main/java/com/withpeace/withpeace/core/testing/MainDispatcherRule.kt b/core/testing/src/main/java/com/withpeace/withpeace/core/testing/MainDispatcherRule.kt
new file mode 100644
index 00000000..8b02020b
--- /dev/null
+++ b/core/testing/src/main/java/com/withpeace/withpeace/core/testing/MainDispatcherRule.kt
@@ -0,0 +1,23 @@
+package com.withpeace.withpeace.core.testing
+
+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.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MainDispatcherRule(
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+) : TestWatcher() {
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
+}
diff --git a/core/ui/.gitignore b/core/ui/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/core/ui/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
new file mode 100644
index 00000000..bc0a74c2
--- /dev/null
+++ b/core/ui/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+ id("com.android.library")
+ id("convention.android.base")
+ id("convention.android.compose")
+ alias(libs.plugins.kotlin.serialization)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.core.ui"
+}
+
+dependencies {
+ implementation(project(":core:permission"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:designsystem"))
+ implementation(libs.skydoves.landscapist.glide)
+ implementation(libs.skydoves.landscapist.bom)
+ implementation(libs.kotlinx.serialization.json)
+}
diff --git a/core/ui/consumer-rules.pro b/core/ui/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/core/ui/proguard-rules.pro b/core/ui/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/core/ui/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/core/ui/src/androidTest/java/com/withpeace/withpeace/core/ui/ExampleInstrumentedTest.kt b/core/ui/src/androidTest/java/com/withpeace/withpeace/core/ui/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..63423f41
--- /dev/null
+++ b/core/ui/src/androidTest/java/com/withpeace/withpeace/core/ui/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.core.ui
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.core.ui.test", appContext.packageName)
+ }
+}
diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/core/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/DateUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/DateUiModel.kt
new file mode 100644
index 00000000..adae91bc
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/DateUiModel.kt
@@ -0,0 +1,92 @@
+package com.withpeace.withpeace.core.ui
+
+import android.content.Context
+import com.withpeace.withpeace.core.domain.model.date.Date
+import com.withpeace.withpeace.core.domain.model.date.DurationFromNow
+import java.time.Duration
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+data class DateUiModel(
+ val date: LocalDateTime,
+) {
+ val duration: Duration
+ get() = Duration.between(
+ date,
+ LocalDateTime.now(ZoneId.of("Asia/Seoul")),
+ )
+
+ val durationFromNow: DurationFromNowUiModel
+ get() = date.toDurationFromNowUiModel()
+}
+
+sealed class DurationFromNowUiModel {
+ data object LessThanOneMinute : DurationFromNowUiModel()
+ data object OneMinuteToOneHour : DurationFromNowUiModel()
+ data object OneHourToOneDay : DurationFromNowUiModel()
+ data object OneDayToSevenDay : DurationFromNowUiModel()
+ data object SevenDayToOneYear : DurationFromNowUiModel()
+ data object OverOneYear : DurationFromNowUiModel()
+}
+
+fun Date.toUiModel(): DateUiModel = DateUiModel(
+ date = date,
+)
+
+fun LocalDateTime.toDurationFromNowUiModel(): DurationFromNowUiModel {
+ val date = Date(this)
+ return when (date.durationFromNow) {
+ is DurationFromNow.LessThanOneMinute -> DurationFromNowUiModel.LessThanOneMinute
+
+ is DurationFromNow.OneDayToSevenDay -> DurationFromNowUiModel.OneDayToSevenDay
+
+ is DurationFromNow.OneHourToOneDay -> DurationFromNowUiModel.OneHourToOneDay
+
+ is DurationFromNow.OneMinuteToOneHour -> DurationFromNowUiModel.OneMinuteToOneHour
+
+ is DurationFromNow.OverOneYear -> DurationFromNowUiModel.OverOneYear
+
+ is DurationFromNow.SevenDayToOneYear -> DurationFromNowUiModel.SevenDayToOneYear
+ }
+}
+
+fun DateUiModel.toRelativeString(context: Context): String {
+ return when (durationFromNow) {
+ is DurationFromNowUiModel.LessThanOneMinute -> {
+ val seconds =
+ if (duration.seconds > MIN_DURATION_SECONDS) duration.seconds else MIN_DURATION_SECONDS
+ context.getString(R.string.second_format, seconds)
+ }
+
+ is DurationFromNowUiModel.OneMinuteToOneHour -> context.getString(
+ R.string.minute_format,
+ duration.toMinutes(),
+ )
+
+ is DurationFromNowUiModel.OneHourToOneDay -> context.getString(
+ R.string.hour_format,
+ duration.toHours(),
+ )
+
+ is DurationFromNowUiModel.OneDayToSevenDay -> context.getString(
+ R.string.day_format,
+ duration.toDays(),
+ )
+
+ is DurationFromNowUiModel.SevenDayToOneYear -> date.format(
+ DateTimeFormatter.ofPattern(
+ DATE_FORMAT,
+ ),
+ )
+
+ is DurationFromNowUiModel.OverOneYear -> context.getString(
+ R.string.years_format,
+ duration.toDays() / DAYS_FOR_YEAR,
+ )
+ }
+}
+
+private const val MIN_DURATION_SECONDS = 1L
+private const val DATE_FORMAT = "MM월 dd일"
+private const val DAYS_FOR_YEAR = 365
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/CommentUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/CommentUiModel.kt
new file mode 100644
index 00000000..42663154
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/CommentUiModel.kt
@@ -0,0 +1,34 @@
+package com.withpeace.withpeace.core.ui.post
+
+import com.withpeace.withpeace.core.domain.model.post.Comment
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.toUiModel
+import java.time.LocalDateTime
+
+data class CommentUiModel(
+ val id: Long,
+ val content: String,
+ val createDate: DateUiModel = DateUiModel(
+ LocalDateTime.now(),
+ ),
+ val commentUser: CommentUserUiModel,
+ val isMyComment: Boolean,
+)
+
+data class CommentUserUiModel(
+ val id: Long,
+ val nickname: String,
+ val profileImageUrl: String,
+)
+
+fun Comment.toUiModel(currentUserId: Long) = CommentUiModel(
+ id = commentId,
+ content = content,
+ createDate = createDate.toUiModel(),
+ commentUser = CommentUserUiModel(
+ id = commentUser.id,
+ nickname = commentUser.nickname,
+ profileImageUrl = commentUser.profileImageUrl,
+ ),
+ isMyComment = currentUserId == commentUser.id,
+)
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostDetailUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostDetailUiModel.kt
new file mode 100644
index 00000000..4737ce0a
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostDetailUiModel.kt
@@ -0,0 +1,39 @@
+package com.withpeace.withpeace.core.ui.post
+
+import com.withpeace.withpeace.core.domain.model.post.PostDetail
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.toUiModel
+
+data class PostDetailUiModel(
+ val postUser: PostUserUiModel,
+ val id: Long,
+ val title: String,
+ val content: String,
+ val postTopic: PostTopicUiModel,
+ val imageUrls: List,
+ val createDate: DateUiModel,
+ val comments: List,
+ val isMyPost: Boolean,
+)
+
+data class PostUserUiModel(
+ val id: Long,
+ val name: String,
+ val profileImageUrl: String,
+)
+
+fun PostDetail.toUiModel(currentUserId: Long): PostDetailUiModel = PostDetailUiModel(
+ postUser = PostUserUiModel(
+ id = user.id,
+ name = user.name,
+ profileImageUrl = user.profileImageUrl,
+ ),
+ id = id,
+ title = title.value,
+ content = content.value,
+ postTopic = postTopic.toUi(),
+ imageUrls = imageUrls,
+ createDate = createDate.toUiModel(),
+ comments = comments.map { it.toUiModel(currentUserId) },
+ isMyPost = user.id == currentUserId,
+)
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostTopicUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostTopicUiModel.kt
new file mode 100644
index 00000000..4078c369
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostTopicUiModel.kt
@@ -0,0 +1,50 @@
+package com.withpeace.withpeace.core.ui.post
+
+import android.os.Parcelable
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.ui.R
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.ECONOMY
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.FREEDOM
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.HOBBY
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.INFORMATION
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.LIVING
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel.QUESTION
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+enum class PostTopicUiModel(
+ @StringRes val textResId: Int,
+ @DrawableRes val iconResId: Int,
+ val index: Int,
+) : Parcelable {
+ FREEDOM(R.string.free, R.drawable.ic_freedom, 0),
+ INFORMATION(R.string.information, R.drawable.ic_information, 1),
+ QUESTION(R.string.question, R.drawable.ic_question, 2),
+ LIVING(R.string.life, R.drawable.ic_life, 3),
+ HOBBY(R.string.hobby, R.drawable.ic_hobby, 4),
+ ECONOMY(R.string.economy, R.drawable.ic_economy, 5)
+}
+
+fun PostTopicUiModel.toDomain(): PostTopic {
+ return when (this) {
+ FREEDOM -> PostTopic.FREEDOM
+ INFORMATION -> PostTopic.INFORMATION
+ QUESTION -> PostTopic.QUESTION
+ LIVING -> PostTopic.LIVING
+ HOBBY -> PostTopic.HOBBY
+ ECONOMY -> PostTopic.ECONOMY
+ }
+}
+
+fun PostTopic.toUi(): PostTopicUiModel {
+ return when (this) {
+ PostTopic.FREEDOM -> FREEDOM
+ PostTopic.INFORMATION -> INFORMATION
+ PostTopic.QUESTION -> QUESTION
+ PostTopic.LIVING -> LIVING
+ PostTopic.HOBBY -> HOBBY
+ PostTopic.ECONOMY -> ECONOMY
+ }
+}
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostUiModel.kt
new file mode 100644
index 00000000..61a78a37
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/PostUiModel.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.core.ui.post
+
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.toUiModel
+
+data class PostUiModel(
+ val postId: Long,
+ val title: String,
+ val content: String,
+ val postTopic: PostTopicUiModel,
+ val createDate: DateUiModel,
+ val postImageUrl: String?,
+)
+
+fun Post.toPostUiModel() =
+ PostUiModel(
+ postId = postId,
+ title = title,
+ content = content,
+ postTopic = postTopic.toUi(),
+ createDate = createDate.toUiModel(),
+ postImageUrl = postImageUrl,
+ )
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/RegisterPostUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/RegisterPostUiModel.kt
new file mode 100644
index 00000000..d277947f
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/RegisterPostUiModel.kt
@@ -0,0 +1,32 @@
+package com.withpeace.withpeace.core.ui.post
+
+import android.os.Parcelable
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class RegisterPostUiModel(
+ val id: Long? = null,
+ val title: String = "",
+ val content: String = "",
+ val topic: PostTopicUiModel? = null,
+ val imageUrls: List = listOf(),
+) : Parcelable
+
+
+fun RegisterPostUiModel.toDomain() = RegisterPost(
+ id = id,
+ title = title,
+ content = content,
+ topic = topic?.toDomain(),
+ images = LimitedImages(imageUrls),
+)
+
+fun RegisterPost.toUi() = RegisterPostUiModel(
+ id = id,
+ title = title,
+ content = content,
+ topic = topic?.toUi(),
+ imageUrls = images.urls,
+)
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/ReportTypeUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/ReportTypeUiModel.kt
new file mode 100644
index 00000000..eceafdd9
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/post/ReportTypeUiModel.kt
@@ -0,0 +1,36 @@
+package com.withpeace.withpeace.core.ui.post
+
+import com.withpeace.withpeace.core.domain.model.post.ReportType
+
+enum class ReportTypeUiModel(val postTitle: String, val commentTitle: String) {
+ DUPLICATE(
+ "중복 / 도배성 게시글이에요",
+ "중복 / 도배성 댓글이에요",
+ ),
+ ADVERTISEMENT(
+ "광고 / 홍보 글이에요",
+ "광고 / 홍보 댓글이에요",
+ ),
+ INAPPROPRIATE(
+ "게시판 성격에 부적절한 글이에요",
+ "게시판 성격에 부적절한 댓글이에요",
+ ),
+ PROFANITY(
+ "욕설 / 혐오 표현이 사용된 글이에요",
+ "욕설 / 혐오 표현이 사용된 댓글이에요",
+ ),
+ OBSCENITY(
+ "음란성 / 선정적인 글이에요",
+ "음란성 / 선정적인 댓글이에요",
+ )
+}
+
+fun ReportTypeUiModel.toDomain(): ReportType {
+ return when (this) {
+ ReportTypeUiModel.DUPLICATE -> ReportType.DUPLICATE
+ ReportTypeUiModel.ADVERTISEMENT -> ReportType.ADVERTISEMENT
+ ReportTypeUiModel.INAPPROPRIATE -> ReportType.INAPPROPRIATE
+ ReportTypeUiModel.PROFANITY -> ReportType.PROFANITY
+ ReportTypeUiModel.OBSCENITY -> ReportType.OBSCENITY
+ }
+}
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt
new file mode 100644
index 00000000..3e16c2ce
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/NicknameEditor.kt
@@ -0,0 +1,110 @@
+package com.withpeace.withpeace.core.ui.profile
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.text.BasicTextField
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+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.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.ui.R
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.seconds
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NickNameEditor(
+ modifier: Modifier = Modifier,
+ nickname: String,
+ isChanged: Boolean,
+ nicknameValidStatus: ProfileNicknameValidUiState,
+ onNickNameChanged: (String) -> Unit,
+ onKeyBoardTimerEnd: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ LaunchedEffect(nickname) {
+ delay(1.seconds)
+ onKeyBoardTimerEnd()
+ }
+
+ Column(
+ modifier = modifier
+ .width(140.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ BasicTextField(
+ value = nickname,
+ onValueChange = {
+ onNickNameChanged(it)
+ },
+ modifier = modifier.fillMaxWidth(),
+ enabled = true,
+ textStyle = WithpeaceTheme.typography.body.copy(textAlign = TextAlign.Center),
+ singleLine = true,
+ maxLines = 1,
+ ) {
+ TextFieldDefaults.DecorationBox(
+ value = nickname,
+ innerTextField = it,
+ enabled = true,
+ singleLine = false,
+ visualTransformation = VisualTransformation.None,
+ placeholder = {
+ Text(
+ modifier = modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ text = stringResource(R.string.enter_nickname),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemGray2,
+ )
+ },
+ interactionSource = interactionSource,
+ contentPadding = PaddingValues(0.dp),
+ colors = TextFieldDefaults.colors(
+ disabledTextColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedContainerColor = Color.Transparent,
+ ),
+ )
+ }
+ // isChanged: 닉네임이 변경되지 않은 경우, ProfileNicknameValidUiState.Valid: 닉네임이 검증 된 경우
+ HorizontalDivider(
+ modifier = modifier
+ .width(140.dp)
+ .height(1.dp),
+ color = if (nicknameValidStatus is ProfileNicknameValidUiState.UnVerified
+ || nicknameValidStatus is ProfileNicknameValidUiState.Valid || isChanged.not()
+ ) WithpeaceTheme.colors.SystemBlack
+ else WithpeaceTheme.colors.SystemError,
+ )
+ }
+ if (!(nicknameValidStatus is ProfileNicknameValidUiState.UnVerified || nicknameValidStatus is ProfileNicknameValidUiState.Valid || isChanged.not())) {
+ Text(
+ text = if (nicknameValidStatus is ProfileNicknameValidUiState.InValidDuplicated) stringResource(
+ R.string.nickname_duplicated,
+ ) else stringResource(id = R.string.nickname_policy),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemError,
+ modifier = modifier.padding(top = 4.dp),
+ )
+ }
+}
\ No newline at end of file
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt
new file mode 100644
index 00000000..b65306ad
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileEditor.kt
@@ -0,0 +1,82 @@
+package com.withpeace.withpeace.core.ui.profile
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+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.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.permission.ImagePermissionHelper
+import com.withpeace.withpeace.core.ui.R
+
+@Composable
+fun ProfileImageEditor(
+ profileImage: String?,
+ modifier: Modifier,
+ onNavigateToGallery: () -> Unit,
+ contentDescription: String
+) {
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val context = LocalContext.current
+ val imagePermissionHelper = remember { ImagePermissionHelper(context) }
+ val launcher = imagePermissionHelper.getImageLauncher(
+ onPermissionGranted = onNavigateToGallery,
+ onPermissionDenied = { showDialog = true },
+ )
+ if (showDialog) {
+ imagePermissionHelper.ImagePermissionDialog { showDialog = false }
+ }
+ val imageModifier = modifier
+ .size(120.dp)
+ .clip(CircleShape)
+ Row(
+ modifier = modifier.wrapContentSize(Alignment.Center),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Box(
+ modifier.clickable {
+ imagePermissionHelper.onCheckSelfImagePermission(
+ onPermissionGranted = onNavigateToGallery,
+ onPermissionDenied = {
+ imagePermissionHelper.requestPermissionDialog(launcher)
+ },
+ )
+ },
+ ) {
+ GlideImage(
+ modifier = imageModifier,
+ imageModel = { profileImage },
+ failure = {
+ Image(
+ painterResource(id = R.drawable.ic_default_profile),
+ modifier = imageModifier,
+ contentDescription = contentDescription,
+ )
+ },
+ )
+ Image(
+ modifier = modifier
+ .align(Alignment.BottomEnd)
+ .padding(bottom = 6.dp, end = 6.dp),
+ painter = painterResource(id = R.drawable.ic_editor_pencil),
+ contentDescription = contentDescription,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt
new file mode 100644
index 00000000..9f85de13
--- /dev/null
+++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/profile/ProfileNicknameValidUiState.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.core.ui.profile
+
+sealed interface ProfileNicknameValidUiState {
+ data object Valid : ProfileNicknameValidUiState
+ data object InValidFormat : ProfileNicknameValidUiState
+ data object InValidDuplicated : ProfileNicknameValidUiState
+ data object UnVerified : ProfileNicknameValidUiState
+}
\ No newline at end of file
diff --git a/core/ui/src/main/res/drawable/ic_default_profile.xml b/core/ui/src/main/res/drawable/ic_default_profile.xml
new file mode 100644
index 00000000..da35818c
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_default_profile.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_economy.xml b/core/ui/src/main/res/drawable/ic_economy.xml
new file mode 100644
index 00000000..4e3a30dc
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_economy.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_editor_pencil.xml b/core/ui/src/main/res/drawable/ic_editor_pencil.xml
new file mode 100644
index 00000000..8fa03dc4
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_editor_pencil.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_freedom.xml b/core/ui/src/main/res/drawable/ic_freedom.xml
new file mode 100644
index 00000000..ccffa34e
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_freedom.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_hobby.xml b/core/ui/src/main/res/drawable/ic_hobby.xml
new file mode 100644
index 00000000..b3204fb8
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_hobby.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_information.xml b/core/ui/src/main/res/drawable/ic_information.xml
new file mode 100644
index 00000000..117facc8
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_information.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_life.xml b/core/ui/src/main/res/drawable/ic_life.xml
new file mode 100644
index 00000000..43676908
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_life.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_question.xml b/core/ui/src/main/res/drawable/ic_question.xml
new file mode 100644
index 00000000..73e400b2
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_question.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
new file mode 100644
index 00000000..3c1c486f
--- /dev/null
+++ b/core/ui/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ 자유
+ 정보
+ "질문"
+ "생활"
+ "취미"
+ "경제"
+
+
+ %1$d초 전
+ %1$d분 전
+ %1$d시간 전
+ %1$d일 전
+ %1$d년 전
+
+
+ 닉네임은 2~10자의 한글, 영문만 가능합니다.
+ 닉네임을 입력하세요
+ 중복된 닉네임입니다.
+
diff --git a/feature/gallery/.gitignore b/feature/gallery/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/gallery/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/gallery/build.gradle.kts b/feature/gallery/build.gradle.kts
new file mode 100644
index 00000000..7109fc9f
--- /dev/null
+++ b/feature/gallery/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins{
+ id("convention.feature")
+}
+
+android{
+ namespace = "com.withpeace.withpeace.feature.gallery"
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ //https://developer.android.com/reference/tools/gradle-api/4.1/com/android/build/api/dsl/UnitTestOptions#isreturndefaultvalues
+ }
+}
+
+dependencies{
+ implementation(libs.skydoves.landscapist.bom)
+ implementation(libs.skydoves.landscapist.glide)
+ implementation(libs.androidx.paging.common)
+ implementation(libs.androidx.pagingCompose)
+ testImplementation(libs.androidx.paging.testing)
+ testImplementation(libs.androidx.core.testing)
+}
diff --git a/feature/gallery/consumer-rules.pro b/feature/gallery/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/gallery/proguard-rules.pro b/feature/gallery/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/gallery/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/gallery/src/main/AndroidManifest.xml b/feature/gallery/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/gallery/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryScreen.kt b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryScreen.kt
new file mode 100644
index 00000000..4832b591
--- /dev/null
+++ b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryScreen.kt
@@ -0,0 +1,305 @@
+package com.withpeace.withpeace.feature.gallery
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+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 androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.LoadState
+import androidx.paging.LoadStates
+import androidx.paging.PagingData
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemKey
+import com.skydoves.landscapist.ImageOptions
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.R
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceBackButtonTopAppBar
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceCompleteButton
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.feature.gallery.R.drawable
+import com.withpeace.withpeace.feature.gallery.R.string
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.flowOf
+import java.text.NumberFormat
+import java.util.Locale
+
+@Composable
+fun GalleryRoute(
+ viewModel: GalleryViewModel = hiltViewModel(),
+ onShowSnackBar: (String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onCompleteRegisterImages: (List) -> Unit,
+) {
+ val noMoreImageMessage = stringResource(id = string.no_more_image_select)
+ val allFolders = viewModel.allFolders.collectAsStateWithLifecycle().value
+ val pagingImages = viewModel.images.collectAsLazyPagingItems()
+ val selectedImageList = viewModel.selectedImages.collectAsStateWithLifecycle().value
+ val selectedFolder = viewModel.selectedFolder.collectAsStateWithLifecycle().value
+
+ BackHandler { // 스마트폰 뒤로가기 제어
+ if (selectedFolder == null) {
+ onClickBackButton()
+ } else {
+ viewModel.onSelectFolder(null)
+ }
+ }
+ GalleryScreen(
+ allFolders = allFolders,
+ onSelectImage = viewModel::onSelectImage,
+ onClickBackButton = onClickBackButton,
+ onCompleteRegisterImages = onCompleteRegisterImages,
+ onSelectFolder = viewModel::onSelectFolder,
+ pagingImages = pagingImages,
+ selectedImageList = selectedImageList,
+ selectedFolder = selectedFolder,
+ )
+
+ LaunchedEffect(key1 = null) {
+ viewModel.sideEffect.collectLatest {
+ when (it) {
+ GallerySideEffect.SelectImageNoMore -> onShowSnackBar(noMoreImageMessage)
+ GallerySideEffect.SelectImageNoApplyType -> onShowSnackBar("지원하지 않는 파일 형식입니다.")
+ GallerySideEffect.SelectImageOverSize -> onShowSnackBar("10MB 이하의 이미지만 업로드 가능합니다.")
+ }
+ }
+ }
+}
+
+@Composable
+fun GalleryScreen(
+ onClickBackButton: () -> Unit = {},
+ onCompleteRegisterImages: (List) -> Unit = {},
+ allFolders: List,
+ onSelectFolder: (ImageFolder?) -> Unit = {},
+ onSelectImage: (ImageInfo) -> Unit = {},
+ pagingImages: LazyPagingItems,
+ selectedImageList: LimitedImages,
+ selectedFolder: ImageFolder?,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WithpeaceTheme.colors.SystemWhite),
+ ) {
+ WithPeaceBackButtonTopAppBar(
+ onClickBackButton = {
+ if (selectedFolder == null) {
+ onClickBackButton()
+ } else {
+ onSelectFolder(null)
+ }
+ },
+ title = {
+ if (selectedFolder == null) {
+ Text(
+ text = stringResource(string.gallery_folder_topbar_title),
+ style = WithpeaceTheme.typography.title1,
+ )
+ } else {
+ Text(
+ text = stringResource(
+ string.selected_images_count,
+ selectedImageList.currentCount,
+ selectedImageList.maxCount,
+ ),
+ style = WithpeaceTheme.typography.title1,
+ )
+ }
+ },
+ actions = {
+ if (selectedFolder != null) {
+ WithPeaceCompleteButton(
+ modifier = Modifier.padding(end = 23.dp),
+ onClick = { onCompleteRegisterImages(selectedImageList.urls) },
+ enabled = selectedImageList.urls.isNotEmpty(),
+ )
+ }
+ },
+ )
+ if (selectedFolder == null) {
+ FolderList(
+ allFolders = allFolders,
+ onSelectFolder = onSelectFolder,
+ )
+ } else {
+ ImageList(
+ pagingImages = pagingImages,
+ selectedImageList = selectedImageList,
+ onSelectImage = onSelectImage,
+ )
+ }
+ }
+}
+
+@Composable
+fun FolderList(
+ modifier: Modifier = Modifier,
+ allFolders: List,
+ onSelectFolder: (ImageFolder) -> Unit,
+) {
+ LazyVerticalGrid(
+ modifier = modifier,
+ columns = GridCells.Fixed(2),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(
+ items = allFolders,
+ ) { folder ->
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clickable {
+ onSelectFolder(folder)
+ },
+ ) {
+ GlideImage(
+ imageModel = { Uri.parse(folder.representativeImageUri) },
+ imageOptions = ImageOptions(),
+ previewPlaceholder = R.drawable.ic_backarrow_right,
+ )
+ Column(
+ modifier = Modifier
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ WithpeaceTheme.colors.SystemBlack.copy(alpha = 0.5f),
+ ),
+ ),
+ )
+ .padding(bottom = 8.dp, start = 8.dp)
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .aspectRatio(2.2f),
+ verticalArrangement = Arrangement.Bottom,
+ ) {
+ Text(
+ text = folder.folderName,
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ Text(
+ text = NumberFormat.getNumberInstance(Locale.US).format(folder.imageCount),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ImageList(
+ modifier: Modifier = Modifier,
+ pagingImages: LazyPagingItems,
+ selectedImageList: LimitedImages,
+ onSelectImage: (ImageInfo) -> Unit,
+) {
+ LazyVerticalGrid(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ horizontalArrangement = Arrangement.spacedBy(1.dp),
+ columns = GridCells.Fixed(3),
+ ) {
+ items(
+ count = pagingImages.itemCount,
+ key = pagingImages.itemKey { it.uri },
+ ) { index ->
+ val imageInfo = pagingImages[index] ?: throw IllegalStateException("uri가 존재하지 않음")
+
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clickable {
+ onSelectImage(imageInfo)
+ },
+ ) {
+ GlideImage(
+ modifier = Modifier.align(Alignment.Center),
+ imageModel = { Uri.parse(imageInfo.uri) },
+ imageOptions = ImageOptions(contentScale = ContentScale.Crop),
+ previewPlaceholder = R.drawable.ic_backarrow_right,
+ )
+ if (selectedImageList.contains(imageInfo.uri)) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WithpeaceTheme.colors.SystemGray3.copy(alpha = 0.5f)),
+ ) {
+ Image(
+ modifier = Modifier.align(Alignment.Center),
+ painter = painterResource(id = drawable.ic_picture_select),
+ contentDescription = null,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GalleryScreenPreview() {
+ WithpeaceTheme {
+ GalleryScreen(
+ onClickBackButton = {},
+ onCompleteRegisterImages = {},
+ allFolders = List(10) {
+ ImageFolder(
+ folderName = "Phillip McClain",
+ representativeImageUri = "per",
+ imageCount = 7380,
+ )
+ },
+ pagingImages = flowOf(
+ PagingData.from(
+ List(10) { ImageInfo("", "", 1L) },
+ sourceLoadStates =
+ LoadStates(
+ refresh = LoadState.NotLoading(false),
+ append = LoadState.NotLoading(false),
+ prepend = LoadState.NotLoading(false),
+ ),
+ ),
+ ).collectAsLazyPagingItems(),
+ selectedImageList = LimitedImages(listOf("", "")),
+ selectedFolder = ImageFolder(
+ folderName = "Phillip McClain",
+ representativeImageUri = "per",
+ imageCount = 7380,
+ ),
+ )
+ }
+}
+
diff --git a/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GallerySideEffect.kt b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GallerySideEffect.kt
new file mode 100644
index 00000000..ed0ed903
--- /dev/null
+++ b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GallerySideEffect.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.feature.gallery
+
+sealed interface GallerySideEffect {
+ data object SelectImageNoMore : GallerySideEffect
+ data object SelectImageNoApplyType : GallerySideEffect
+ data object SelectImageOverSize : GallerySideEffect
+}
diff --git a/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryViewModel.kt b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryViewModel.kt
new file mode 100644
index 00000000..db92a5df
--- /dev/null
+++ b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/GalleryViewModel.kt
@@ -0,0 +1,101 @@
+package com.withpeace.withpeace.feature.gallery
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.usecase.GetAlbumImagesUseCase
+import com.withpeace.withpeace.core.domain.usecase.GetAllFoldersUseCase
+import com.withpeace.withpeace.feature.gallery.navigation.GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT
+import com.withpeace.withpeace.feature.gallery.navigation.GALLERY_IMAGE_LIMIT_ARGUMENT
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class GalleryViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val getAllFoldersUseCase: GetAllFoldersUseCase,
+ private val getAlbumImagesUseCase: GetAlbumImagesUseCase,
+) : ViewModel() {
+
+ private val _selectedImages =
+ MutableStateFlow(
+ LimitedImages(
+ urls = emptyList(),
+ maxCount = savedStateHandle.get(GALLERY_IMAGE_LIMIT_ARGUMENT)
+ ?: DEFAULT_MAX_SELECTABLE_COUNT,
+ alreadyExistCount = savedStateHandle.get(GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT)
+ ?: DEFAULT__CURRENT_IMAGE_COUNT,
+ ),
+ )
+ val selectedImages = _selectedImages.asStateFlow()
+
+ private val _allFolders = MutableStateFlow>(emptyList())
+ val allFolders = _allFolders.asStateFlow()
+
+ private val _selectedFolder = MutableStateFlow(null)
+ val selectedFolder = _selectedFolder.asStateFlow()
+
+ val images = selectedFolder.map { imageFolder ->
+ getImagePagingData(imageFolder?.folderName ?: "")
+ }
+
+ private val _sideEffect = Channel()
+ val sideEffect = _sideEffect.receiveAsFlow()
+
+ init {
+ viewModelScope.launch {
+ _allFolders.update { getAllFoldersUseCase() }
+ }
+ }
+
+ private suspend fun getImagePagingData(folderName:String):PagingData{
+ return getAlbumImagesUseCase(folderName,PAGE_SIZE)
+ .cachedIn(viewModelScope) // Paging 정보를 화면 회전에도 날라가지 않게 하기 위함
+ .firstOrNull() ?: PagingData.empty()
+ }
+
+ fun onSelectFolder(imageFolder: ImageFolder?) {
+ _selectedFolder.value = imageFolder
+ }
+
+ fun onSelectImage(imageInfo: ImageInfo) {
+ when {
+ selectedImages.value.contains(imageInfo.uri) -> _selectedImages.update {
+ it.deleteImage(imageInfo.uri)
+ }
+
+ selectedImages.value.canAddImage() -> {
+ if (!imageInfo.isUploadType()) {
+ viewModelScope.launch { _sideEffect.send(GallerySideEffect.SelectImageNoApplyType) }
+ return
+ }
+ if (imageInfo.isSizeOver()) {
+ viewModelScope.launch { _sideEffect.send(GallerySideEffect.SelectImageOverSize) }
+ return
+ }
+ _selectedImages.update { it.addImage(imageInfo.uri) }
+ }
+
+ else -> viewModelScope.launch { _sideEffect.send(GallerySideEffect.SelectImageNoMore) }
+ }
+ }
+
+ companion object {
+ private const val DEFAULT_MAX_SELECTABLE_COUNT = 5
+ private const val DEFAULT__CURRENT_IMAGE_COUNT = 0
+ private const val PAGE_SIZE = 30
+ }
+}
diff --git a/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/navigation/GalleryNavigation.kt b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/navigation/GalleryNavigation.kt
new file mode 100644
index 00000000..c6cf9476
--- /dev/null
+++ b/feature/gallery/src/main/java/com/withpeace/withpeace/feature/gallery/navigation/GalleryNavigation.kt
@@ -0,0 +1,42 @@
+package com.withpeace.withpeace.feature.gallery.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.withpeace.withpeace.feature.gallery.GalleryRoute
+
+const val GALLERY_ROUTE = "gallery_route"
+const val GALLERY_IMAGE_LIMIT_ARGUMENT = "image_limit"
+const val GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT = "image_count"
+const val GALLERY_ROUTE_WITH_ARGUMENT =
+ "$GALLERY_ROUTE/{$GALLERY_IMAGE_LIMIT_ARGUMENT}/{$GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT}"
+
+fun NavController.navigateToGallery(
+ navOptions: NavOptions? = null,
+ imageLimit: Int = 5,
+ currentImageCount: Int = 0,
+) =
+ navigate("$GALLERY_ROUTE/$imageLimit/$currentImageCount", navOptions)
+
+fun NavGraphBuilder.galleryNavGraph(
+ onClickBackButton: () -> Unit,
+ onCompleteRegisterImages: (List) -> Unit,
+ onShowSnackBar: (String) -> Unit,
+) {
+ composable(
+ route = GALLERY_ROUTE_WITH_ARGUMENT,
+ arguments = listOf(
+ navArgument(GALLERY_IMAGE_LIMIT_ARGUMENT) { type = NavType.IntType },
+ navArgument(GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT) { type = NavType.IntType },
+ ),
+ ) {
+ GalleryRoute(
+ onClickBackButton = onClickBackButton,
+ onCompleteRegisterImages = onCompleteRegisterImages,
+ onShowSnackBar = onShowSnackBar
+ )
+ }
+}
diff --git a/feature/gallery/src/main/res/drawable/ic_check.xml b/feature/gallery/src/main/res/drawable/ic_check.xml
new file mode 100644
index 00000000..52ce1321
--- /dev/null
+++ b/feature/gallery/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/feature/gallery/src/main/res/drawable/ic_picture_select.xml b/feature/gallery/src/main/res/drawable/ic_picture_select.xml
new file mode 100644
index 00000000..114008e1
--- /dev/null
+++ b/feature/gallery/src/main/res/drawable/ic_picture_select.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/feature/gallery/src/main/res/values/strings.xml b/feature/gallery/src/main/res/values/strings.xml
new file mode 100644
index 00000000..557776b9
--- /dev/null
+++ b/feature/gallery/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ 사진첩
+ %1$s/%2$s 선택됨
+ 더이상 이미지를 선택할 수 없습니다.
+
diff --git a/feature/gallery/src/test/java/com/withpeace/withpeace/feature/gallery/GalleryViewModelTest.kt b/feature/gallery/src/test/java/com/withpeace/withpeace/feature/gallery/GalleryViewModelTest.kt
new file mode 100644
index 00000000..4acb21f3
--- /dev/null
+++ b/feature/gallery/src/test/java/com/withpeace/withpeace/feature/gallery/GalleryViewModelTest.kt
@@ -0,0 +1,188 @@
+package com.withpeace.withpeace.feature.gallery
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.testing.asPagingSourceFactory
+import androidx.paging.testing.asSnapshot
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.image.ImageFolder
+import com.withpeace.withpeace.core.domain.model.image.ImageInfo
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.usecase.GetAlbumImagesUseCase
+import com.withpeace.withpeace.core.domain.usecase.GetAllFoldersUseCase
+import com.withpeace.withpeace.core.testing.MainDispatcherRule
+import com.withpeace.withpeace.feature.gallery.navigation.GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT
+import com.withpeace.withpeace.feature.gallery.navigation.GALLERY_IMAGE_LIMIT_ARGUMENT
+import io.mockk.coEvery
+import io.mockk.mockk
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class GalleryViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var viewModel: GalleryViewModel
+ private lateinit var savedStateHandle: SavedStateHandle
+ private val getAllFoldersUseCase = mockk(relaxed = true)
+ private val getAlbumImagesUseCase = mockk(relaxed = true)
+
+ private fun savedStateHandle(
+ alreadyImageCount: Int,
+ limitImageCount: Int,
+ ): SavedStateHandle = SavedStateHandle(
+ mapOf(
+ GALLERY_ALREADY_IMAGE_COUNT_ARGUMENT to alreadyImageCount,
+ GALLERY_IMAGE_LIMIT_ARGUMENT to limitImageCount,
+ ),
+ )
+
+ private fun viewModel() =
+ GalleryViewModel(savedStateHandle, getAllFoldersUseCase, getAlbumImagesUseCase)
+
+ @Test
+ fun `이미 선택된 이미지 개수와 최대 이미지 개수 설정이 가능하다`() {
+ // given
+ savedStateHandle = savedStateHandle(1, 5)
+ // when
+ viewModel = viewModel()
+ // then
+ val actual = viewModel.selectedImages.value
+ assertThat(actual).isEqualTo(LimitedImages(emptyList(), 5, 1))
+ }
+
+ @Test
+ fun `모든 이미지 폴더를 가져올 수 있다`() {
+ // given
+ savedStateHandle = SavedStateHandle()
+ val testFolders = List(10) {
+ ImageFolder(
+ "test",
+ representativeImageUri = "test",
+ imageCount = 10,
+ )
+ }
+ coEvery { getAllFoldersUseCase() } returns testFolders
+ // when
+ viewModel = viewModel()
+ // then
+ val actual = viewModel.allFolders.value
+ assertThat(actual).isEqualTo(testFolders)
+ }
+
+ @Test
+ fun `폴더를 선택할 수 있다`() {
+ // given
+ savedStateHandle = SavedStateHandle()
+ viewModel = viewModel()
+ val testFolder = ImageFolder(
+ folderName = "test",
+ representativeImageUri = "test",
+ imageCount = 10,
+ )
+ // when
+ viewModel.onSelectFolder(testFolder)
+ // then
+ val actual = viewModel.selectedFolder.value
+ assertThat(actual).isEqualTo(testFolder)
+ }
+
+ @Test
+ fun `폴더를 선택하지 않으면, 이미지 상태는 비어있다`() = runTest {
+ // given
+ savedStateHandle = SavedStateHandle()
+ viewModel = viewModel()
+ coEvery {
+ getAlbumImagesUseCase("",30)
+ } returns Pager(
+ config = PagingConfig(30),
+ pagingSourceFactory = emptyList().asPagingSourceFactory(),
+ ).flow
+ // when & then
+ val actual = viewModel.images.getFullScrollItems()
+ assertThat(actual).isEqualTo(emptyList())
+ }
+
+ @Test
+ fun `폴더를 변경하면 이미지 상태를 가져올 수 있다`() = runTest {
+ // given
+ savedStateHandle = SavedStateHandle()
+ viewModel = viewModel()
+ val testFolder = ImageFolder(
+ folderName = "test",
+ representativeImageUri = "test",
+ imageCount = 10,
+ )
+ val testImages = List(100) { ImageInfo(
+ "uri","type",0L
+ )}
+ coEvery {
+ getAlbumImagesUseCase(testFolder.folderName,30)
+ } returns Pager(
+ config = PagingConfig(30),
+ pagingSourceFactory = testImages.asPagingSourceFactory(),
+ ).flow
+ // when
+ viewModel.onSelectFolder(testFolder)
+ val actual = viewModel.images.getFullScrollItems()
+ assertThat(actual).isEqualTo(testImages)
+ }
+
+ private suspend fun Flow>.getFullScrollItems() =
+ asSnapshot { appendScrollWhile { true } }
+
+ @Test
+ fun `이미지를 선택할 수 있다`() {
+ // given
+ savedStateHandle = SavedStateHandle()
+ viewModel = viewModel()
+ val testImage = ImageInfo(
+ "uri","images/png",10
+ )
+ // when
+ viewModel.onSelectImage(testImage)
+ // then
+ val actual = viewModel.selectedImages.value.contains(testImage.uri)
+ assertThat(actual).isTrue()
+ }
+
+ @Test
+ fun `이미 선택한 이미지를 선택하면, 해제할 수 있다`() {
+ // given
+ savedStateHandle = SavedStateHandle()
+ viewModel = viewModel()
+ val testImage = ImageInfo(
+ "uri","type",0L
+ )
+ // when
+ viewModel.onSelectImage(testImage)
+ viewModel.onSelectImage(testImage)
+ // then
+ val actual = viewModel.selectedImages.value.contains(testImage.uri)
+ assertThat(actual).isFalse()
+ }
+
+ @Test
+ fun `최대 이미지 개수룰 넘어버렸을 때 이미지를 선택하면, 선택 실패 이팩트가 발생한다`() = runTest {
+ // given
+ savedStateHandle = SavedStateHandle(
+ mapOf(GALLERY_IMAGE_LIMIT_ARGUMENT to 0), //최대 이미지 개수 0
+ )
+ viewModel = viewModel()
+ val testImage = ImageInfo(
+ "uri","type",0L
+ )
+ // when && then
+ viewModel.sideEffect.test {
+ viewModel.onSelectImage(testImage)
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(GallerySideEffect.SelectImageNoMore)
+ }
+ }
+}
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..939e9427
--- /dev/null
+++ b/feature/home/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.home"
+}
+
+dependencies {
+ implementation(libs.androidx.paging.common)
+ implementation(libs.androidx.pagingCompose)
+}
\ No newline at end of file
diff --git a/feature/home/consumer-rules.pro b/feature/home/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
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/androidTest/java/com/withpeace/withpeace/feature/home/ExampleInstrumentedTest.kt b/feature/home/src/androidTest/java/com/withpeace/withpeace/feature/home/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..cd121d9a
--- /dev/null
+++ b/feature/home/src/androidTest/java/com/withpeace/withpeace/feature/home/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.feature.home
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.feature.home.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/feature/home/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt
new file mode 100644
index 00000000..123a8324
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt
@@ -0,0 +1,376 @@
+package com.withpeace.withpeace.feature.home
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.LoadState
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemKey
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.util.dropShadow
+import com.withpeace.withpeace.feature.home.filtersetting.FilterBottomSheet
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.ClassificationUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.RegionUiModel
+import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel
+import com.withpeace.withpeace.feature.home.uistate.YouthPolicyUiModel
+import kotlinx.coroutines.launch
+
+@Composable
+fun HomeRoute(
+ onShowSnackBar: (message: String) -> Unit = {},
+ viewModel: HomeViewModel = hiltViewModel(),
+) {
+ val youthPolicyPagingData = viewModel.youthPolicyPagingFlow.collectAsLazyPagingItems()
+ val selectedFilterUiState = viewModel.selectingFilters.collectAsStateWithLifecycle()
+ HomeScreen(
+ youthPolicies = youthPolicyPagingData,
+ selectedFilterUiState = selectedFilterUiState.value,
+ onDismissRequest = viewModel::onCancelFilter,
+ onClassificationCheckChanged = viewModel::onCheckClassification,
+ onRegionCheckChanged = viewModel::onCheckRegion,
+ onFilterAllOff = viewModel::onFilterAllOff,
+ onSearchWithFilter = viewModel::onCompleteFilter,
+ onCloseFilter = viewModel::onCancelFilter,
+ )
+}
+
+@Composable
+fun HomeScreen(
+ modifier: Modifier = Modifier,
+ youthPolicies: LazyPagingItems,
+ selectedFilterUiState: PolicyFiltersUiModel,
+ onDismissRequest: () -> Unit,
+ onClassificationCheckChanged: (ClassificationUiModel) -> Unit,
+ onRegionCheckChanged: (RegionUiModel) -> Unit,
+ onFilterAllOff: () -> Unit,
+ onSearchWithFilter: () -> Unit,
+ onCloseFilter: () -> Unit,
+) {
+ Column(modifier = modifier.fillMaxSize()) {
+ HomeHeader(
+ modifier = modifier,
+ onDismissRequest = onDismissRequest,
+ selectedFilterUiState = selectedFilterUiState,
+ onClassificationCheckChanged = onClassificationCheckChanged,
+ onRegionCheckChanged = onRegionCheckChanged,
+ onFilterAllOff = onFilterAllOff,
+ onSearchWithFilter = onSearchWithFilter,
+ onCloseFilter = onCloseFilter,
+ )
+ HorizontalDivider(
+ modifier = modifier.height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ when(youthPolicies.loadState.refresh) {
+ is LoadState.Loading -> {
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+
+ is LoadState.Error -> {
+ Box(Modifier.fillMaxSize()) {
+ Text(
+ text = "네트워크 상태를 확인해주세요.",
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+
+ is LoadState.NotLoading -> {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color(0xFFF8F9FB))
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(modifier = modifier.height(8.dp))
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 16.dp)
+ ) {
+ items(
+ count = youthPolicies.itemCount,
+ key = youthPolicies.itemKey { it.id },
+ ) {
+ val youthPolicy = youthPolicies[it] ?: throw IllegalStateException()
+ Spacer(modifier = modifier.height(8.dp))
+ YouthPolicyCard(
+ modifier = modifier,
+ youthPolicy = youthPolicy,
+ )
+ }
+ item {
+ if (youthPolicies.loadState.append is LoadState.Loading) {
+ Column(
+ modifier = modifier.padding(top = 8.dp)
+ .fillMaxWidth()
+ .background(
+ Color.Transparent,
+ ),
+ ) {
+ CircularProgressIndicator(
+ modifier.align(Alignment.CenterHorizontally),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+ }
+ }
+
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun HomeHeader(
+ modifier: Modifier,
+ selectedFilterUiState: PolicyFiltersUiModel,
+ onDismissRequest: () -> Unit,
+ onClassificationCheckChanged: (ClassificationUiModel) -> Unit,
+ onRegionCheckChanged: (RegionUiModel) -> Unit,
+ onFilterAllOff: () -> Unit,
+ onSearchWithFilter: () -> Unit,
+ onCloseFilter: () -> Unit,
+) {
+ var showBottomSheet by remember { mutableStateOf(false) }
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp, horizontal = 24.dp),
+ ) {
+ Image(
+ modifier = modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .align(Alignment.Center),
+ painter = painterResource(id = R.drawable.home_logo),
+ contentDescription = stringResource(R.string.cheongha_logo),
+ )
+ Image(
+ modifier = modifier
+ .size(24.dp)
+ .align(Alignment.CenterEnd)
+ .clickable {
+ showBottomSheet = true
+ },
+ painter = painterResource(id = R.drawable.ic_filter),
+ contentDescription = stringResource(R.string.filter),
+ )
+ }
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ dragHandle = null,
+ onDismissRequest = {
+ onDismissRequest()
+ showBottomSheet = false
+ },
+ sheetState = sheetState,
+ windowInsets = BottomSheetDefaults.windowInsets.only(WindowInsetsSides.Bottom), // 바텀시트시 상태바의 색깔도 ScopeOut 색으로 바꾸기 위함
+ ) {
+ FilterBottomSheet(
+ modifier = modifier,
+ selectedFilterUiState = selectedFilterUiState,
+ onClassificationCheckChanged = onClassificationCheckChanged,
+ onRegionCheckChanged = onRegionCheckChanged,
+ onFilterAllOff = onFilterAllOff,
+ onSearchWithFilter = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ showBottomSheet = false
+ onSearchWithFilter()
+ }
+ },
+ onCloseFilter = {
+ scope.launch { sheetState.hide() }.invokeOnCompletion {
+ showBottomSheet = false
+ onCloseFilter()
+ }
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun YouthPolicyCard(
+ modifier: Modifier,
+ youthPolicy: YouthPolicyUiModel,
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = WithpeaceTheme.colors.SystemWhite,
+ shape = RoundedCornerShape(size = 10.dp),
+ )
+ .dropShadow(
+ color = Color(0x1A000000),
+ blurRadius = 4.dp,
+ spreadRadius = 2.dp,
+ borderRadius = 10.dp
+ ),
+ ) {
+ ConstraintLayout(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(WithpeaceTheme.colors.SystemWhite)
+ .padding(16.dp),
+ ) {
+ val (
+ title, content,
+ region, ageRange, thumbnail,
+ ) = createRefs()
+
+ Text(
+ text = youthPolicy.title,
+ modifier.constrainAs(
+ title,
+ constrainBlock = {
+ top.linkTo(parent.top)
+ start.linkTo(parent.start)
+ end.linkTo(thumbnail.start, margin = 8.dp)
+ width = Dimension.fillToConstraints
+ },
+ ),
+ color = WithpeaceTheme.colors.SystemBlack,
+ style = WithpeaceTheme.typography.homePolicyTitle,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ Text(
+ text = youthPolicy.content,
+ modifier = modifier
+ .constrainAs(
+ content,
+ constrainBlock = {
+ top.linkTo(title.bottom, margin = 8.dp)
+ start.linkTo(parent.start)
+ end.linkTo(thumbnail.start, margin = 8.dp)
+ width = Dimension.fillToConstraints
+ },
+ ),
+ color = WithpeaceTheme.colors.SystemBlack,
+ style = WithpeaceTheme.typography.homePolicyContent,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 2,
+ )
+
+ Text(
+ text = youthPolicy.region.name,
+ color = WithpeaceTheme.colors.MainPurple,
+ modifier = modifier
+ .constrainAs(
+ region,
+ constrainBlock = {
+ top.linkTo(content.bottom, margin = 8.dp)
+ start.linkTo(parent.start)
+ bottom.linkTo(parent.bottom)
+ },
+ )
+ .background(
+ color = WithpeaceTheme.colors.SubPurple,
+ shape = RoundedCornerShape(size = 5.dp),
+ )
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ style = WithpeaceTheme.typography.homePolicyTag,
+ )
+
+ Text(
+ text = youthPolicy.ageInfo,
+ color = WithpeaceTheme.colors.SystemGray1,
+ modifier = modifier
+ .constrainAs(
+ ageRange,
+ constrainBlock = {
+ top.linkTo(region.top)
+ start.linkTo(region.end, margin = 8.dp)
+ bottom.linkTo(region.bottom)
+ },
+ )
+ .background(
+ color = WithpeaceTheme.colors.SystemGray3,
+ shape = RoundedCornerShape(size = 5.dp),
+ )
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ style = WithpeaceTheme.typography.homePolicyTag,
+ )
+
+
+ Image(
+ modifier = modifier
+ .size(57.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .constrainAs(
+ ref = thumbnail,
+ constrainBlock = {
+ start.linkTo(title.end)
+ end.linkTo(parent.end)
+ top.linkTo(parent.top)
+ },
+ ),
+ painter = painterResource(id = R.drawable.ic_home_thumbnail_example),
+ contentDescription = "임시",
+ ) //TODO("이미지 변경")
+ }
+ }
+}
+
+@Composable
+@Preview
+fun HomePreview() {
+ WithpeaceTheme {
+ // HomeScreen()
+ }
+}
+// TODO(바텀 시트 스크롤 고려)
+//
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt
new file mode 100644
index 00000000..087d05c8
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt
@@ -0,0 +1,94 @@
+package com.withpeace.withpeace.feature.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.map
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.core.domain.usecase.GetYouthPoliciesUseCase
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.ClassificationUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.RegionUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.toDomain
+import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel
+import com.withpeace.withpeace.feature.home.uistate.YouthPolicyUiModel
+import com.withpeace.withpeace.feature.home.uistate.toDomain
+import com.withpeace.withpeace.feature.home.uistate.toUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val youthPoliciesUseCase: GetYouthPoliciesUseCase,
+) : ViewModel() {
+ private val _youthPolicyPagingFlow = MutableStateFlow(PagingData.empty())
+ val youthPolicyPagingFlow = _youthPolicyPagingFlow.asStateFlow()
+
+ private val _selectingFilters = MutableStateFlow(PolicyFilters())
+ val selectingFilters: StateFlow =
+ _selectingFilters.map { it.toUiModel() }.stateIn(
+ scope = viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ PolicyFiltersUiModel(),
+ )
+
+ private var completedFilters = PolicyFilters()
+
+ init {
+ fetchData()
+ }
+
+ private fun fetchData() {
+ viewModelScope.launch {
+ _youthPolicyPagingFlow.update {
+ youthPoliciesUseCase(
+ filterInfo = completedFilters,
+ onError = {
+ },
+ ).map {
+ it.map { youthPolicy ->
+ youthPolicy.toUiModel()
+ }
+ }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty()
+ }
+ }
+ }
+
+ fun onCheckClassification(classification: ClassificationUiModel) {
+ _selectingFilters.update {
+ it.updateClassification(classification.toDomain())
+ }
+ }
+
+ fun onCheckRegion(region: RegionUiModel) {
+ _selectingFilters.update {
+ it.updateRegion(region.toDomain())
+ }
+ }
+
+ fun onCompleteFilter() {
+ completedFilters = selectingFilters.value.toDomain()
+ fetchData()
+ }
+
+ fun onCancelFilter() {
+ _selectingFilters.update { completedFilters }
+ }
+
+ fun onFilterAllOff() {
+ _selectingFilters.update {
+ it.removeAll()
+ }
+ }
+}
+
+//TODO("검색이 끝나지 않았을 때 필터걸면?")
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt
new file mode 100644
index 00000000..9c80d78d
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt
@@ -0,0 +1,327 @@
+package com.withpeace.withpeace.feature.home.filtersetting
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.feature.home.R
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.ClassificationUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.FilterListUiState
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.RegionUiModel
+import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel
+
+@Composable
+fun FilterBottomSheet(
+ modifier: Modifier,
+ selectedFilterUiState: PolicyFiltersUiModel,
+ onClassificationCheckChanged: (ClassificationUiModel) -> Unit,
+ onRegionCheckChanged: (RegionUiModel) -> Unit,
+ onFilterAllOff: () -> Unit,
+ onSearchWithFilter: () -> Unit,
+ onCloseFilter: () -> Unit,
+) {
+ val filterListUiState= remember { mutableStateOf(FilterListUiState().getStateByFilterState(selectedFilterUiState)) }
+ val scrollState = rememberScrollState()
+ val configuration = LocalConfiguration.current
+ val screenHeight = configuration.screenHeightDp.dp
+ var footerHeight by remember { mutableStateOf(0.dp) }
+ val localDensity = LocalDensity.current
+ Box(modifier = modifier.heightIn(0.dp, screenHeight)) {
+ FilterFooter(
+ modifier = modifier
+ .align(Alignment.BottomCenter)
+ .onSizeChanged {
+ footerHeight = with(localDensity) { it.height.toDp() }
+ },
+ onFilterAllOff = onFilterAllOff,
+ onSearchWithFilter = onSearchWithFilter,
+ )
+ Column(
+ modifier = modifier
+ .align(Alignment.TopCenter)
+ .padding(bottom = footerHeight),
+ ) {
+ FilterHeader(
+ modifier = modifier,
+ onCloseFilter = onCloseFilter,
+ )
+ ScrollableFilterSection(
+ modifier = modifier,
+ filterListUiState = filterListUiState.value,
+ selectedFilterUiState = selectedFilterUiState,
+ onClassificationCheckChanged = onClassificationCheckChanged,
+ onRegionCheckChanged = onRegionCheckChanged,
+ onClassificationMoreViewClick = {
+ filterListUiState.value =
+ filterListUiState.value.copy(isClassificationExpanded = !filterListUiState.value.isClassificationExpanded)
+ },
+ onRegionMoreViewClick = {
+ filterListUiState.value =
+ filterListUiState.value.copy(isRegionExpanded = !filterListUiState.value.isRegionExpanded)
+ },
+ scrollState = scrollState,
+ )
+ }
+ }
+}
+
+@Composable
+private fun FilterHeader(modifier: Modifier, onCloseFilter: () -> Unit) {
+ Spacer(modifier = modifier.height(24.dp))
+ Row(modifier = modifier.padding(horizontal = 16.dp)) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_filter_close),
+ modifier = modifier.clickable {
+ onCloseFilter()
+ },
+ contentDescription = stringResource(
+ R.string.filter_close,
+ ),
+ )
+ Text(
+ text = stringResource(id = R.string.filter),
+ modifier = modifier.padding(start = 8.dp),
+ style = WithpeaceTheme.typography.title1,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(WithpeaceTheme.colors.SystemGray3)
+ .height(1.dp),
+ )
+}
+
+@Composable
+private fun ScrollableFilterSection(
+ modifier: Modifier,
+ filterListUiState: FilterListUiState,
+ selectedFilterUiState: PolicyFiltersUiModel,
+ onClassificationCheckChanged: (ClassificationUiModel) -> Unit,
+ onRegionCheckChanged: (RegionUiModel) -> Unit,
+ onClassificationMoreViewClick: () -> Unit,
+ onRegionMoreViewClick: () -> Unit,
+ scrollState: ScrollState,
+) {
+ Column(
+ modifier = modifier
+ .verticalScroll(scrollState)
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(modifier = modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.policy_classfication),
+ style = WithpeaceTheme.typography.title2,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ Column(
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+ ),
+ ),
+ ) {
+ filterListUiState.getClassifications().forEach {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement =
+ Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(id = it.resId),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ Checkbox(
+ colors = CheckboxDefaults.colors(
+ checkedColor = WithpeaceTheme.colors.MainPurple,
+ uncheckedColor = WithpeaceTheme.colors.SystemGray2,
+ checkmarkColor = WithpeaceTheme.colors.SystemWhite,
+ ),
+ checked = selectedFilterUiState.classifications.contains(it),
+ onCheckedChange = { _ -> onClassificationCheckChanged(it) },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable {
+ onClassificationMoreViewClick()
+ },
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(id = if (filterListUiState.isClassificationExpanded) R.string.filter_fold else R.string.filter_expanded),
+ color = WithpeaceTheme.colors.SystemGray1,
+ style = WithpeaceTheme.typography.caption,
+ modifier = modifier.padding(end = 4.dp),
+ )
+ Image(
+ painterResource(id = if (filterListUiState.isClassificationExpanded) R.drawable.ic_filter_fold else R.drawable.ic_filter_expanded),
+ contentDescription = stringResource(id = R.string.filter_expanded),
+ )
+ }
+ Spacer(modifier = modifier.height(16.dp))
+ HorizontalDivider(
+ modifier = modifier.height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ Text(
+ text = stringResource(id = R.string.region),
+ style = WithpeaceTheme.typography.title2,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+
+ Spacer(modifier = modifier.height(16.dp))
+ Column(
+ modifier = modifier.animateContentSize(
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMedium,
+ ),
+ ),
+ ) {
+ filterListUiState.getRegions().forEach { filterItem ->
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement =
+ Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = filterItem.name,
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ Checkbox(
+ colors = CheckboxDefaults.colors(
+ checkedColor = WithpeaceTheme.colors.MainPurple,
+ uncheckedColor = WithpeaceTheme.colors.SystemGray2,
+ checkmarkColor = WithpeaceTheme.colors.SystemWhite,
+ ),
+ checked = selectedFilterUiState.regions.contains(filterItem),
+ onCheckedChange = { onRegionCheckChanged(filterItem) },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable {
+ onRegionMoreViewClick()
+ },
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(
+ if (filterListUiState.isRegionExpanded) R.string.filter_fold else R.string.filter_expanded,
+ ),
+ color = WithpeaceTheme.colors.SystemGray1,
+ style = WithpeaceTheme.typography.caption,
+ modifier = modifier.padding(end = 4.dp),
+ )
+ Image(
+ painterResource(id = if (filterListUiState.isRegionExpanded) R.drawable.ic_filter_fold else R.drawable.ic_filter_expanded),
+ contentDescription = stringResource(id = R.string.filter_expanded),
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+}
+
+@Composable
+private fun FilterFooter(
+ modifier: Modifier,
+ onFilterAllOff: () -> Unit,
+ onSearchWithFilter: () -> Unit,
+) {
+ Column(modifier = modifier.wrapContentHeight()) {
+ Spacer(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(WithpeaceTheme.colors.SystemGray3),
+ )
+ Row(
+ modifier = modifier
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TextButton(
+ modifier = modifier,
+ onClick = { onFilterAllOff() },
+ ) {
+ Text(
+ text = stringResource(R.string.filter_all_off),
+ color = WithpeaceTheme.colors.SystemGray1,
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ TextButton(
+ contentPadding = PaddingValues(vertical = 12.dp, horizontal = 32.dp),
+ colors = ButtonColors(
+ containerColor = WithpeaceTheme.colors.MainPurple,
+ contentColor = WithpeaceTheme.colors.SystemWhite,
+ disabledContainerColor = WithpeaceTheme.colors.MainPurple,
+ disabledContentColor = WithpeaceTheme.colors.SystemWhite,
+ ),
+ shape = RoundedCornerShape(5.dp),
+ onClick = { onSearchWithFilter() },
+ ) {
+ Text(text = stringResource(R.string.search))
+ }
+ }
+ }
+}
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/ClassificationUiModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/ClassificationUiModel.kt
new file mode 100644
index 00000000..758cd444
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/ClassificationUiModel.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace.feature.home.filtersetting.uistate
+
+import androidx.annotation.StringRes
+import com.withpeace.withpeace.core.domain.model.policy.PolicyClassification
+import com.withpeace.withpeace.feature.home.R
+
+enum class ClassificationUiModel(@StringRes val resId: Int) {
+ JOB(R.string.classification_job),
+ RESIDENT(R.string.classification_resident),
+ EDUCATION(R.string.classification_education),
+ WELFARE_AND_CULTURE(R.string.classification_wefare_and_culture),
+ PARTICIPATION_AND_RIGHT(R.string.classification_partification_and_right),
+ ETC(R.string.classification_etc)
+}
+
+fun PolicyClassification.toUiModel(): ClassificationUiModel {
+ return ClassificationUiModel.entries.find { it.name == this.name } ?: ClassificationUiModel.ETC
+}
+
+fun ClassificationUiModel.toDomain(): PolicyClassification {
+ return PolicyClassification.entries.find { it.name == this.name } ?: PolicyClassification.ETC
+}
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt
new file mode 100644
index 00000000..5f2c0d49
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt
@@ -0,0 +1,34 @@
+package com.withpeace.withpeace.feature.home.filtersetting.uistate
+
+import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel
+
+data class FilterListUiState(
+ val isClassificationExpanded: Boolean = false,
+ val isRegionExpanded: Boolean = false,
+) {
+ private val allClassifications: List = ClassificationUiModel.entries
+ private val allRegions: List = RegionUiModel.entries
+
+ fun getStateByFilterState(filtersUiModel: PolicyFiltersUiModel): FilterListUiState {
+ return this.copy(
+ isClassificationExpanded = allClassifications.indexOf(filtersUiModel.classifications.lastOrNull()) >= FOLDED_CLASSIFICATION_ITEM_COUNT,
+ isRegionExpanded = allRegions.indexOf(filtersUiModel.regions.lastOrNull()) >= FOLDED_REGION_ITEM_COUNT,
+ )
+ }
+
+ fun getClassifications(): List {
+ return if (isClassificationExpanded) allClassifications.dropLast(ETC_COUNT)
+ else allClassifications.subList(0, FOLDED_CLASSIFICATION_ITEM_COUNT)
+ }
+
+ fun getRegions(): List {
+ return if (isRegionExpanded) allRegions.dropLast(ETC_COUNT)
+ else allRegions.subList(0, FOLDED_REGION_ITEM_COUNT)
+ }
+
+ companion object {
+ private const val FOLDED_CLASSIFICATION_ITEM_COUNT = 3
+ private const val FOLDED_REGION_ITEM_COUNT = 7
+ private const val ETC_COUNT = 1
+ }
+}
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/RegionUiModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/RegionUiModel.kt
new file mode 100644
index 00000000..7672f600
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/RegionUiModel.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.feature.home.filtersetting.uistate
+
+import com.withpeace.withpeace.core.domain.model.policy.PolicyRegion
+
+enum class RegionUiModel {
+ 중앙부처, 서울, 부산, 대구, 인천, 광주, 대전, 울산, 경기, 강원, 충북, 충남, 전북, 전남, 경북, 경남, 제주, 세종, 기타
+}
+
+fun PolicyRegion.toUiModel(): RegionUiModel {
+ return RegionUiModel.entries.find { this.name == it.name } ?: RegionUiModel.기타
+}
+
+fun RegionUiModel.toDomain(): PolicyRegion {
+ return PolicyRegion.entries.find { this.name == it.name } ?: PolicyRegion.기타
+}
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt
new file mode 100644
index 00000000..b7a05f18
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt
@@ -0,0 +1,22 @@
+package com.withpeace.withpeace.feature.home.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.feature.home.HomeRoute
+
+const val HOME_ROUTE = "homeRoute"
+
+fun NavController.navigateHome(navOptions: NavOptions? = null) {
+ navigate(HOME_ROUTE, navOptions)
+}
+
+fun NavGraphBuilder.homeNavGraph(
+ onShowSnackBar: (message: String) -> Unit,
+) {
+ composable(route = HOME_ROUTE) {
+ HomeRoute(
+ onShowSnackBar = onShowSnackBar)
+ }
+}
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt
new file mode 100644
index 00000000..9fa0c479
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt
@@ -0,0 +1,28 @@
+package com.withpeace.withpeace.feature.home.uistate
+
+import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.ClassificationUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.RegionUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.toDomain
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.toUiModel
+
+data class PolicyFiltersUiModel(
+ val classifications: List = listOf(),
+ val regions: List = listOf(),
+)
+
+fun PolicyFilters.toUiModel(): PolicyFiltersUiModel {
+ return PolicyFiltersUiModel(
+ regions = regions.map { it.toUiModel() },
+ classifications = classifications.map {
+ it.toUiModel()
+ },
+ )
+}
+
+fun PolicyFiltersUiModel.toDomain(): PolicyFilters {
+ return PolicyFilters(
+ regions = regions.map { it.toDomain() },
+ classifications = classifications.map { it.toDomain() },
+ )
+}
\ No newline at end of file
diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/YouthPolicyUiModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/YouthPolicyUiModel.kt
new file mode 100644
index 00000000..884e84c6
--- /dev/null
+++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/YouthPolicyUiModel.kt
@@ -0,0 +1,36 @@
+package com.withpeace.withpeace.feature.home.uistate
+
+import com.withpeace.withpeace.core.domain.model.policy.PolicyClassification
+import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.RegionUiModel
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.toDomain
+import com.withpeace.withpeace.feature.home.filtersetting.uistate.toUiModel
+
+data class YouthPolicyUiModel(
+ val id: String,
+ val title: String,
+ val content: String,
+ val region: RegionUiModel,
+ val ageInfo: String,
+)
+
+fun YouthPolicy.toUiModel(): YouthPolicyUiModel {
+ return YouthPolicyUiModel(
+ id = id,
+ title = title,
+ content = introduce,
+ region = region.toUiModel(),
+ ageInfo = ageInfo,
+ )
+}
+
+fun YouthPolicyUiModel.toDomain(): YouthPolicy {
+ return YouthPolicy(
+ id = id,
+ title = title,
+ introduce = content,
+ region = region.toDomain(),
+ policyClassification = PolicyClassification.JOB,
+ ageInfo = ageInfo,
+ )
+}
\ No newline at end of file
diff --git a/feature/home/src/main/res/drawable/home_logo.xml b/feature/home/src/main/res/drawable/home_logo.xml
new file mode 100644
index 00000000..df743866
--- /dev/null
+++ b/feature/home/src/main/res/drawable/home_logo.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/home/src/main/res/drawable/ic_filter.xml b/feature/home/src/main/res/drawable/ic_filter.xml
new file mode 100644
index 00000000..e8ad2a36
--- /dev/null
+++ b/feature/home/src/main/res/drawable/ic_filter.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/feature/home/src/main/res/drawable/ic_filter_close.xml b/feature/home/src/main/res/drawable/ic_filter_close.xml
new file mode 100644
index 00000000..3b9176a8
--- /dev/null
+++ b/feature/home/src/main/res/drawable/ic_filter_close.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/home/src/main/res/drawable/ic_filter_expanded.xml b/feature/home/src/main/res/drawable/ic_filter_expanded.xml
new file mode 100644
index 00000000..00b563cf
--- /dev/null
+++ b/feature/home/src/main/res/drawable/ic_filter_expanded.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/feature/home/src/main/res/drawable/ic_filter_fold.xml b/feature/home/src/main/res/drawable/ic_filter_fold.xml
new file mode 100644
index 00000000..fd1a600d
--- /dev/null
+++ b/feature/home/src/main/res/drawable/ic_filter_fold.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/feature/home/src/main/res/drawable/ic_home_thumbnail_example.png b/feature/home/src/main/res/drawable/ic_home_thumbnail_example.png
new file mode 100644
index 00000000..bac4677c
Binary files /dev/null and b/feature/home/src/main/res/drawable/ic_home_thumbnail_example.png differ
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..4b3832b0
--- /dev/null
+++ b/feature/home/src/main/res/values/strings.xml
@@ -0,0 +1,18 @@
+
+
+ 청하 로고
+ 필터
+ 일자리
+ 주거
+ 교육
+ 복지,문화
+ 참여,권리
+ 기타
+ 필터 닫기
+ 더보기
+ 정책분야
+ 지역
+ 전체 해제
+ 검색하기
+ 접기
+
\ No newline at end of file
diff --git a/feature/home/src/test/java/com/withpeace/withpeace/feature/home/ExampleUnitTest.kt b/feature/home/src/test/java/com/withpeace/withpeace/feature/home/ExampleUnitTest.kt
new file mode 100644
index 00000000..70b49715
--- /dev/null
+++ b/feature/home/src/test/java/com/withpeace/withpeace/feature/home/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.feature.home
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/feature/login/.gitignore b/feature/login/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/login/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/login/build.gradle.kts b/feature/login/build.gradle.kts
new file mode 100644
index 00000000..0167a052
--- /dev/null
+++ b/feature/login/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.login"
+}
+
+dependencies {
+ implementation(project(":google-login"))
+}
diff --git a/feature/login/consumer-rules.pro b/feature/login/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/login/proguard-rules.pro b/feature/login/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/login/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/login/src/main/AndroidManifest.xml b/feature/login/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..7179a81c
--- /dev/null
+++ b/feature/login/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt
new file mode 100644
index 00000000..771b43e8
--- /dev/null
+++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginScreen.kt
@@ -0,0 +1,172 @@
+package com.withpeace.withpeace.feature.login
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+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.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.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.PlatformTextStyle
+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.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.googlelogin.GoogleLoginManager
+import kotlinx.coroutines.launch
+
+@Composable
+fun LoginRoute(
+ viewModel: LoginViewModel = hiltViewModel(),
+ onShowSnackBar: (message: String) -> Unit,
+ onSignUpNeeded: () -> Unit,
+ onLoginSuccess: () -> Unit,
+) {
+ LoginScreen(
+ onGoogleLogin = viewModel::googleLogin,
+ onShowSnackBar = onShowSnackBar
+ )
+ LaunchedEffect(key1 = null) {
+ viewModel.loginUiEvent.collect { uiEvent ->
+ when (uiEvent) {
+ is LoginUiEvent.SignUpSuccess -> {
+ onShowSnackBar("회원가입에 성공하였습니다!")
+ }
+
+ is LoginUiEvent.SignUpFail -> {
+ onShowSnackBar("회원가입에 실패하였습니다!")
+ }
+
+ is LoginUiEvent.SignUpNeeded -> {
+ onSignUpNeeded()
+ }
+
+ is LoginUiEvent.LoginFail -> {
+ onShowSnackBar("서버와의 로그인 인증에 실패하였습니다!")
+ }
+
+ is LoginUiEvent.LoginSuccess -> {
+ onLoginSuccess()
+ }
+ is LoginUiEvent.WithdrawUser -> {
+ onShowSnackBar("삭제된 계정입니다.")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun LoginScreen(
+ onGoogleLogin: (idToken: String) -> Unit = {},
+ onShowSnackBar: (message: String) -> Unit = {},
+) {
+ val googleLoginManager = GoogleLoginManager(context = LocalContext.current)
+ val coroutineScope = rememberCoroutineScope()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(152.dp))
+ Image(
+ modifier = Modifier.size(150.dp),
+ painter = painterResource(id = R.drawable.app_logo),
+ contentDescription = stringResource(R.string.app_logo_content_description),
+ )
+ Spacer(modifier = Modifier.height(40.dp))
+ Text(
+ style = WithpeaceTheme.typography.title1,
+ text = stringResource(R.string.welcome_to_withpeace),
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ lineHeight = 19.sp,
+ style = WithpeaceTheme.typography.body,
+ text = stringResource(R.string.welcome_introduction),
+ textAlign = TextAlign.Center,
+ )
+ }
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ googleLoginManager.startLogin(
+ onSuccessLogin = onGoogleLogin,
+ onFailLogin = {
+ onShowSnackBar("로그인에 실패하였습니다")
+ },
+ )
+ }
+ },
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier
+ .padding(
+ bottom = 40.dp,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ )
+ .fillMaxWidth(),
+ border = BorderStroke(width = 1.dp, color = WithpeaceTheme.colors.SystemBlack),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.SystemWhite),
+ shape = RoundedCornerShape(9.dp),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Image(
+ modifier = Modifier
+ .padding(start = 16.dp)
+ .align(Alignment.CenterStart)
+ .size(24.dp),
+ painter = painterResource(id = R.drawable.img_google_logo),
+ contentDescription = stringResource(R.string.image_google_logo),
+ )
+ Text(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(vertical = 18.dp),
+ color = WithpeaceTheme.colors.SystemBlack,
+ style = WithpeaceTheme.typography.notoSans.merge(
+ TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)),
+ ),
+ text = stringResource(R.string.login_to_google),
+ )
+ }
+ }
+ }
+}
+
+@Preview(widthDp = 400, heightDp = 900, showBackground = true)
+@Composable
+fun LoginScreenPreview() {
+ LoginScreen()
+}
diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt
new file mode 100644
index 00000000..0b9b4486
--- /dev/null
+++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginUiEvent.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.feature.login
+
+sealed interface LoginUiEvent {
+ data object LoginSuccess : LoginUiEvent
+
+ data object LoginFail : LoginUiEvent
+
+ data object SignUpNeeded : LoginUiEvent
+
+ data object SignUpSuccess : LoginUiEvent
+
+ data object WithdrawUser : LoginUiEvent
+
+ data class SignUpFail(val message: String?) : LoginUiEvent
+}
diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt
new file mode 100644
index 00000000..82217dfe
--- /dev/null
+++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/LoginViewModel.kt
@@ -0,0 +1,50 @@
+package com.withpeace.withpeace.feature.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.role.Role
+import com.withpeace.withpeace.core.domain.usecase.GoogleLoginUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel @Inject constructor(
+ private val googleLoginUseCase: GoogleLoginUseCase,
+) : ViewModel() {
+
+ private val _loginUiEvent: Channel = Channel()
+ val loginUiEvent = _loginUiEvent.receiveAsFlow()
+
+ fun googleLogin(idToken: String) {
+ viewModelScope.launch {
+ googleLoginUseCase(
+ idToken = idToken,
+ onError = {
+ when (it) {
+ ResponseError.DUPLICATE_RESOURCE -> {
+ _loginUiEvent.send(LoginUiEvent.WithdrawUser)
+ }
+
+ else -> {
+ _loginUiEvent.send(LoginUiEvent.LoginFail)
+ }
+ }
+
+ },
+ ).collect {
+ launch {
+ when (it) {
+ Role.USER -> _loginUiEvent.send(LoginUiEvent.LoginSuccess)
+ Role.GUEST -> _loginUiEvent.send(LoginUiEvent.SignUpNeeded)
+ Role.UNKNOWN -> _loginUiEvent.send(LoginUiEvent.LoginFail)
+ // UNKNOWN은 서버에서 내려주는 역할이 string이므로 휴먼에러 방지를 위함
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt
new file mode 100644
index 00000000..db308176
--- /dev/null
+++ b/feature/login/src/main/java/com/withpeace/withpeace/feature/login/navigation/LoginNavigation.kt
@@ -0,0 +1,27 @@
+package com.withpeace.withpeace.feature.login.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.feature.login.LoginRoute
+
+const val LOGIN_ROUTE = "loginRoute"
+
+fun NavController.navigateLogin(navOptions: NavOptions? = null) {
+ navigate(LOGIN_ROUTE, navOptions)
+}
+
+fun NavGraphBuilder.loginNavGraph(
+ onShowSnackBar: (message: String) -> Unit,
+ onSignUpNeeded: () -> Unit,
+ onLoginSuccess: () -> Unit,
+) {
+ composable(route = LOGIN_ROUTE) {
+ LoginRoute(
+ onShowSnackBar = onShowSnackBar,
+ onSignUpNeeded = onSignUpNeeded,
+ onLoginSuccess = onLoginSuccess,
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/login/src/main/res/drawable/app_logo.xml b/feature/login/src/main/res/drawable/app_logo.xml
new file mode 100644
index 00000000..ba0c5023
--- /dev/null
+++ b/feature/login/src/main/res/drawable/app_logo.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/feature/login/src/main/res/drawable/img_google_logo.png b/feature/login/src/main/res/drawable/img_google_logo.png
new file mode 100644
index 00000000..5bae1042
Binary files /dev/null and b/feature/login/src/main/res/drawable/img_google_logo.png differ
diff --git a/feature/login/src/main/res/values/strings.xml b/feature/login/src/main/res/values/strings.xml
new file mode 100644
index 00000000..df149f9b
--- /dev/null
+++ b/feature/login/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+
+ app logo
+ 청하에 오신 것을 환영합니다
+ image_google_logo
+ Google로 로그인하기
+ 1인 가구의 모든 것\n 유용한 정보를 함께 공유해보세요!
+
\ No newline at end of file
diff --git a/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt b/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt
new file mode 100644
index 00000000..6348c6e3
--- /dev/null
+++ b/feature/login/src/test/java/com/withpeace/withpeace/feature/login/LoginViewModelTest.kt
@@ -0,0 +1,112 @@
+package com.withpeace.withpeace.feature.login
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.role.Role
+import com.withpeace.withpeace.core.domain.usecase.GoogleLoginUseCase
+import com.withpeace.withpeace.core.testing.MainDispatcherRule
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LoginViewModelTest {
+
+ @get:Rule
+ val dispatcherRule = MainDispatcherRule()
+
+ private lateinit var viewModel: LoginViewModel
+ private val googleLoginUseCase: GoogleLoginUseCase = mockk()
+
+ private fun initialize(): LoginViewModel {
+ return LoginViewModel(googleLoginUseCase)
+ }
+
+ @Test
+ fun `구글 로그인에 성공하고 권한이 유저이면 로그인 성공 이벤트를 발생한다`() = runTest {
+ // given
+ coEvery {
+ googleLoginUseCase(
+ "test",
+ onError = any(),
+ )
+ } returns flow {
+ emit(Role.USER)
+ }
+ viewModel = initialize()
+
+ // when & then
+ viewModel.loginUiEvent.test {
+ viewModel.googleLogin("test")
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(LoginUiEvent.LoginSuccess)
+ }
+ }
+
+ @Test
+ fun `구글 로그인에 성공하고 권한이 게스트이면 회원가입 필요 이벤트를 발생한다`() = runTest {
+ // given
+ coEvery {
+ googleLoginUseCase(
+ "test",
+ onError = any(),
+ )
+ } returns flow {
+ emit(Role.GUEST)
+ }
+ viewModel = initialize()
+
+ // when & then
+ viewModel.loginUiEvent.test {
+ viewModel.googleLogin("test")
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(LoginUiEvent.SignUpNeeded)
+ }
+ }
+
+ @Test
+ fun `구글 로그인에 성공하고 권한이 알 수 없으면 로그인 실패 이벤트를 발생한다`() = runTest {
+ // given
+ coEvery {
+ googleLoginUseCase(
+ "test",
+ onError = any(),
+ )
+ } returns flow {
+ emit(Role.UNKNOWN)
+ }
+ viewModel = initialize()
+
+ // when & then
+ viewModel.loginUiEvent.test {
+ viewModel.googleLogin("test")
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(LoginUiEvent.LoginFail)
+ }
+ }
+
+ @Test
+ fun `구글 로그인에 실패하면 로그인 실패 이벤트를 발생한다`() = runTest {
+ // given
+ val onFailSlot = slot Unit>()
+ coEvery {
+ googleLoginUseCase(
+ "test",
+ onError = capture(onFailSlot),
+ )
+ } returns flow { onFailSlot.captured(ResponseError.UNKNOWN_ERROR) }
+
+ viewModel = initialize()
+ // when & then
+ viewModel.loginUiEvent.test {
+ viewModel.googleLogin("test")
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(LoginUiEvent.LoginFail)
+ }
+ }
+}
diff --git a/feature/mypage/.gitignore b/feature/mypage/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/mypage/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/mypage/build.gradle.kts b/feature/mypage/build.gradle.kts
new file mode 100644
index 00000000..61f160ca
--- /dev/null
+++ b/feature/mypage/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.mypage"
+}
+
+dependencies {
+ implementation(libs.skydoves.landscapist.glide)
+ implementation(libs.skydoves.landscapist.bom)
+}
\ No newline at end of file
diff --git a/feature/mypage/consumer-rules.pro b/feature/mypage/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/mypage/proguard-rules.pro b/feature/mypage/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/mypage/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/mypage/src/androidTest/java/com/withpeace/withpeace/feature/mypage/ExampleInstrumentedTest.kt b/feature/mypage/src/androidTest/java/com/withpeace/withpeace/feature/mypage/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..ac587119
--- /dev/null
+++ b/feature/mypage/src/androidTest/java/com/withpeace/withpeace/feature/mypage/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.feature.mypage
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.feature.mypage.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/AndroidManifest.xml b/feature/mypage/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/feature/mypage/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageScreen.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageScreen.kt
new file mode 100644
index 00000000..624601b9
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageScreen.kt
@@ -0,0 +1,315 @@
+package com.withpeace.withpeace.feature.mypage
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+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.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.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.graphics.Color
+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 androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.NoTitleDialog
+import com.withpeace.withpeace.core.designsystem.ui.TitleBar
+import com.withpeace.withpeace.feature.mypage.uistate.MyPageUiEvent
+import com.withpeace.withpeace.feature.mypage.uistate.ProfileInfoUiModel
+import com.withpeace.withpeace.feature.mypage.uistate.ProfileUiState
+
+@Composable
+fun MyPageRoute(
+ viewModel: MyPageViewModel = hiltViewModel(),
+ onShowSnackBar: (message: String) -> Unit = {},
+ onEditProfile: (nickname: String, profileImageUrl: String) -> Unit,
+ onLogoutSuccess: () -> Unit,
+ onWithdrawSuccess: () -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ val profileInfo by viewModel.profileUiState.collectAsStateWithLifecycle()
+ LaunchedEffect(viewModel.myPageUiEvent) {
+ viewModel.myPageUiEvent.collect {
+ when (it) {
+ MyPageUiEvent.UnAuthorizedError -> {
+ onAuthExpired()
+ }
+
+ MyPageUiEvent.ResponseError -> {
+ onShowSnackBar("서버 통신 중 오류가 발생했습니다. 다시 시도해주세요.")
+ }
+
+ MyPageUiEvent.Logout -> onLogoutSuccess()
+ MyPageUiEvent.WithdrawSuccess -> {
+ onShowSnackBar("계정삭제 되었습니다. 14일이내 복구 가능합니다.")
+ onWithdrawSuccess()
+ }
+ }
+ }
+ }
+ MyPageScreen(
+ onEditProfile = {
+ onEditProfile(it.nickname, it.profileImage)
+ },
+ onLogoutClick = {
+ viewModel.logout()
+ },
+ onWithdrawClick = viewModel::withdraw,
+ profileInfo = profileInfo,
+ )
+}
+
+@Composable
+fun MyPageScreen(
+ modifier: Modifier = Modifier,
+ onEditProfile: (ProfileInfoUiModel) -> Unit,
+ onLogoutClick: () -> Unit,
+ onWithdrawClick: () -> Unit,
+ profileInfo: ProfileUiState,
+) {
+ Column {
+ TitleBar(title = stringResource(R.string.my_page))
+ when (profileInfo) {
+ is ProfileUiState.Success -> {
+ MyPageContent(
+ modifier,
+ profileInfo.profileInfoUiModel,
+ onEditProfile,
+ onLogoutClick,
+ onWithdrawClick,
+ )
+ }
+
+ is ProfileUiState.Loading -> {
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+
+ is ProfileUiState.Failure -> {
+ Box(Modifier.fillMaxSize()) {
+ Text(
+ text = "네트워크 상태를 확인해주세요.",
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+ }
+
+ }
+}
+
+@Composable
+private fun MyPageContent(
+ modifier: Modifier,
+ profileInfo: ProfileInfoUiModel,
+ onEditProfile: (ProfileInfoUiModel) -> Unit,
+ onLogoutClick: () -> Unit,
+ onWithdrawClick: () -> Unit,
+) {
+ val scrollState = rememberScrollState()
+ Column(modifier = modifier.verticalScroll(scrollState)) {
+ Column(modifier = modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding)) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val imageModifier = modifier
+ .size(54.dp)
+ .border(
+ BorderStroke(0.dp, Color.Transparent),
+ shape = CircleShape,
+ )
+ GlideImage(
+ modifier = imageModifier.clip(CircleShape),
+ imageModel = { profileInfo.profileImage },
+ failure = {
+ Image(
+ painterResource(id = R.drawable.ic_default_profile),
+ modifier = imageModifier,
+ contentDescription = "",
+ )
+ },
+ )
+ Text(
+ style = WithpeaceTheme.typography.body,
+ text = profileInfo.nickname,
+ modifier = modifier.padding(start = 8.dp),
+ )
+ }
+ TextButton(
+ onClick = { onEditProfile(profileInfo) },
+ ) {
+ Text(
+ color = WithpeaceTheme.colors.MainPurple,
+ text = stringResource(R.string.edit_profile),
+ style = WithpeaceTheme.typography.caption,
+ )
+ }
+ }
+ }
+ Spacer(modifier = modifier.height(16.dp))
+ Spacer(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(WithpeaceTheme.colors.SystemGray3),
+ )
+ MyPageSections(
+ modifier = modifier,
+ onLogoutClick = onLogoutClick,
+ onWithdrawClick = onWithdrawClick,
+ email = profileInfo.email,
+ )
+ }
+}
+
+@Composable
+fun MyPageSections(
+ modifier: Modifier,
+ onLogoutClick: () -> Unit,
+ onWithdrawClick: () -> Unit,
+ email: String,
+) {
+ Column(modifier = modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding)) {
+ AccountSection(modifier, email = email)
+ HorizontalDivider(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ EtcSection(modifier, onLogoutClick = onLogoutClick, onWithdrawClick = onWithdrawClick)
+ }
+}
+
+@Composable
+private fun AccountSection(modifier: Modifier, email: String) {
+ Section(title = stringResource(R.string.account)) {
+ Spacer(modifier = modifier.height(16.dp))
+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ text = stringResource(R.string.connected_account),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ Text(
+ text = email,
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemGray2,
+ )
+ }
+ }
+}
+
+@Composable
+private fun EtcSection(
+ modifier: Modifier,
+ onLogoutClick: () -> Unit,
+ onWithdrawClick: () -> Unit,
+) {
+ var showDialog by remember { mutableStateOf(false) }
+ Section(title = stringResource(R.string.etc)) {
+ Spacer(modifier = modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.logout),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemBlack,
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable {
+ onLogoutClick()
+ }
+ .padding(vertical = 8.dp),
+ )
+ Text(
+ text = stringResource(R.string.withdraw),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemBlack,
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable {
+ showDialog = true
+ }
+ .padding(vertical = 8.dp),
+ )
+ }
+ if (showDialog) {
+ Dialog(onDismissRequest = { showDialog = false }) {
+ NoTitleDialog(
+ onClickPositive = { showDialog = false },
+ onClickNegative = {
+ onWithdrawClick()
+ showDialog = false
+ },
+ contentText = stringResource(R.string.withdraw_info_content),
+ positiveText = stringResource(R.string.withdraw_undo),
+ negativeText = stringResource(id = R.string.withdraw),
+ )
+ }
+ }
+}
+
+@Composable
+private fun Section(
+ title: String,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit = {},
+) {
+ Column {
+ Spacer(modifier = modifier.height(16.dp))
+ Text(text = title, style = WithpeaceTheme.typography.caption, color = Color(0xFF858585))
+ content()
+ Spacer(modifier = modifier.height(16.dp))
+ }
+
+}
+
+@Preview(widthDp = 400, heightDp = 900)
+@Composable
+fun MyPagePreview() {
+ WithpeaceTheme {
+ MyPageScreen(
+ onEditProfile = {
+ },
+ modifier = Modifier,
+ onLogoutClick = {},
+ onWithdrawClick = {},
+ profileInfo = ProfileUiState.Success(ProfileInfoUiModel("닉네임닉네임", "", "abc@gmail.com")),
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageViewModel.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageViewModel.kt
new file mode 100644
index 00000000..34b1e72c
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/MyPageViewModel.kt
@@ -0,0 +1,106 @@
+package com.withpeace.withpeace.feature.mypage
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.usecase.GetProfileInfoUseCase
+import com.withpeace.withpeace.core.domain.usecase.LogoutUseCase
+import com.withpeace.withpeace.core.domain.usecase.WithdrawUseCase
+import com.withpeace.withpeace.feature.mypage.uistate.MyPageUiEvent
+import com.withpeace.withpeace.feature.mypage.uistate.ProfileInfoUiModel
+import com.withpeace.withpeace.feature.mypage.uistate.ProfileUiState
+import com.withpeace.withpeace.feature.mypage.uistate.toUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MyPageViewModel @Inject constructor(
+ private val getUserInfoUseCase: GetProfileInfoUseCase,
+ private val logoutUseCase: LogoutUseCase,
+ private val withdrawUseCase: WithdrawUseCase,
+) : ViewModel() {
+ private val _myPageUiEvent = Channel()
+ val myPageUiEvent = _myPageUiEvent.receiveAsFlow()
+
+ private val _profileUiState = MutableStateFlow(
+ ProfileUiState.Loading,
+ )
+ val profileUiState = _profileUiState.asStateFlow()
+
+ init {
+ getProfile()
+ }
+
+ private fun getProfile() {
+ viewModelScope.launch {
+ getUserInfoUseCase { error ->
+ when (error) {
+ ResponseError.EXPIRED_TOKEN_ERROR -> {
+ _myPageUiEvent.send(MyPageUiEvent.UnAuthorizedError)
+ }
+ else -> {
+ _myPageUiEvent.send(MyPageUiEvent.ResponseError)
+ }
+ }
+ _profileUiState.update { ProfileUiState.Failure }
+ }.collect { profileInfo ->
+ _profileUiState.update {
+ ProfileUiState.Success(profileInfo.toUiModel())
+ }
+ }
+ }
+ }
+
+ fun updateProfile(nickname: String?, profileUrl: String?) {
+ if (nickname == null && profileUrl == null) {
+ return
+ }
+ _profileUiState.update {
+ val profileInfo = (it as ProfileUiState.Success).profileInfoUiModel
+ ProfileUiState.Success(
+ ProfileInfoUiModel(
+ nickname = nickname ?: profileInfo.nickname,
+ profileImage = profileUrl ?: profileInfo.profileImage,
+ email = profileInfo.email,
+ ),
+ )
+ }
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ logoutUseCase {
+ _myPageUiEvent.send(
+ MyPageUiEvent.ResponseError,
+ )
+ }.collect {
+ _myPageUiEvent.send(MyPageUiEvent.Logout)
+ }
+ }
+ }
+
+ fun withdraw() {
+ viewModelScope.launch {
+ withdrawUseCase {
+ when (it) {
+ ResponseError.EXPIRED_TOKEN_ERROR -> {
+ _myPageUiEvent.send(MyPageUiEvent.UnAuthorizedError)
+ }
+
+ else -> {
+ _myPageUiEvent.send(MyPageUiEvent.ResponseError)
+ }
+ }
+ }.collect {
+ _myPageUiEvent.send(MyPageUiEvent.WithdrawSuccess)
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/navigation/MyPageNavigation.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/navigation/MyPageNavigation.kt
new file mode 100644
index 00000000..e68aee7e
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/navigation/MyPageNavigation.kt
@@ -0,0 +1,40 @@
+package com.withpeace.withpeace.feature.mypage.navigation
+
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.feature.mypage.MyPageRoute
+import com.withpeace.withpeace.feature.mypage.MyPageViewModel
+
+const val MY_PAGE_ROUTE = "myPageRoute"
+const val MY_PAGE_CHANGED_NICKNAME_ARGUMENT = "myPageChangedNicknameArgument"
+const val MY_PAGE_CHANGED_IMAGE_ARGUMENT = "myPageChangedImageArgument"
+
+fun NavController.navigateMyPage(navOptions: NavOptions? = null) {
+ navigate(MY_PAGE_ROUTE, navOptions)
+}
+
+fun NavGraphBuilder.myPageNavGraph(
+ onShowSnackBar: (message: String) -> Unit,
+ onEditProfile: (nickname: String, profileImageUrl: String) -> Unit,
+ onLogoutSuccess: () -> Unit,
+ onWithdrawSuccess: () -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ composable(route = MY_PAGE_ROUTE) {
+ val nickname = it.savedStateHandle.get(MY_PAGE_CHANGED_NICKNAME_ARGUMENT)
+ val profile = it.savedStateHandle.get(MY_PAGE_CHANGED_IMAGE_ARGUMENT)
+ val viewModel: MyPageViewModel = hiltViewModel()
+ viewModel.updateProfile(nickname, profile)
+ MyPageRoute(
+ onShowSnackBar = onShowSnackBar,
+ onEditProfile = onEditProfile,
+ onLogoutSuccess = onLogoutSuccess,
+ onWithdrawSuccess = onWithdrawSuccess,
+ viewModel = viewModel,
+ onAuthExpired = onAuthExpired
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/MyPageUiEvent.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/MyPageUiEvent.kt
new file mode 100644
index 00000000..c5f1cf31
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/MyPageUiEvent.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.feature.mypage.uistate
+
+sealed interface MyPageUiEvent {
+ data object UnAuthorizedError: MyPageUiEvent
+ data object ResponseError : MyPageUiEvent
+ data object Logout : MyPageUiEvent
+ data object WithdrawSuccess: MyPageUiEvent
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoMapper.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoMapper.kt
new file mode 100644
index 00000000..5fbcb3ec
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoMapper.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.feature.mypage.uistate
+
+import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo
+
+internal fun ProfileInfo.toUiModel(): ProfileInfoUiModel {
+ return ProfileInfoUiModel(
+ nickname.value, profileImageUrl, email,
+ )
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoUiModel.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoUiModel.kt
new file mode 100644
index 00000000..ebefae5f
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileInfoUiModel.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.feature.mypage.uistate
+
+data class ProfileInfoUiModel(
+ val nickname: String,
+ val profileImage: String,
+ val email: String,
+)
\ No newline at end of file
diff --git a/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileUiState.kt b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileUiState.kt
new file mode 100644
index 00000000..9b3939fb
--- /dev/null
+++ b/feature/mypage/src/main/java/com/withpeace/withpeace/feature/mypage/uistate/ProfileUiState.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.feature.mypage.uistate
+
+sealed interface ProfileUiState {
+ data class Success(val profileInfoUiModel: ProfileInfoUiModel) : ProfileUiState
+ data object Loading : ProfileUiState
+ data object Failure : ProfileUiState
+}
\ No newline at end of file
diff --git a/feature/mypage/src/main/res/drawable/ic_default_profile.xml b/feature/mypage/src/main/res/drawable/ic_default_profile.xml
new file mode 100644
index 00000000..da35818c
--- /dev/null
+++ b/feature/mypage/src/main/res/drawable/ic_default_profile.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/feature/mypage/src/main/res/values/strings.xml b/feature/mypage/src/main/res/values/strings.xml
new file mode 100644
index 00000000..8ff6c4d3
--- /dev/null
+++ b/feature/mypage/src/main/res/values/strings.xml
@@ -0,0 +1,13 @@
+
+
+ 마이페이지
+ 프로필 수정
+ 계정
+ 연결된 계정
+ 기타
+ 로그아웃
+ 탈퇴하기
+ 서버와의 오류가 발생했습니다. 다시 시도해주세요.
+ 탈퇴하시면 같은 계정으로\n 14일 동안 계정 생성을 할 수 없습니다.\n그래도 탈퇴하시겠습니까?
+ 나가기
+
\ No newline at end of file
diff --git a/feature/mypage/src/test/java/com/withpeace/withpeace/feature/mypage/ExampleUnitTest.kt b/feature/mypage/src/test/java/com/withpeace/withpeace/feature/mypage/ExampleUnitTest.kt
new file mode 100644
index 00000000..7ebd9062
--- /dev/null
+++ b/feature/mypage/src/test/java/com/withpeace/withpeace/feature/mypage/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.feature.mypage
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/feature/postdetail/.gitignore b/feature/postdetail/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/feature/postdetail/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/feature/postdetail/build.gradle.kts b/feature/postdetail/build.gradle.kts
new file mode 100644
index 00000000..cc1387fa
--- /dev/null
+++ b/feature/postdetail/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.postdetail"
+}
+
+dependencies {
+ implementation(libs.skydoves.landscapist.bom)
+ implementation(libs.skydoves.landscapist.glide)
+}
diff --git a/feature/postdetail/consumer-rules.pro b/feature/postdetail/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/postdetail/proguard-rules.pro b/feature/postdetail/proguard-rules.pro
new file mode 100644
index 00000000..f1b42451
--- /dev/null
+++ b/feature/postdetail/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
diff --git a/feature/postdetail/src/androidTest/java/com/withpeace/withpeace/feature/postdetail/ExampleInstrumentedTest.kt b/feature/postdetail/src/androidTest/java/com/withpeace/withpeace/feature/postdetail/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..e03b7696
--- /dev/null
+++ b/feature/postdetail/src/androidTest/java/com/withpeace/withpeace/feature/postdetail/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.feature.postdetail.test", appContext.packageName)
+ }
+}
diff --git a/feature/postdetail/src/main/AndroidManifest.xml b/feature/postdetail/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/postdetail/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/CommentSection.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/CommentSection.kt
new file mode 100644
index 00000000..06dda6fc
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/CommentSection.kt
@@ -0,0 +1,262 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+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.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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+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.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.theme.PretendardFont
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.R.drawable
+import com.withpeace.withpeace.core.ui.post.CommentUiModel
+import com.withpeace.withpeace.core.ui.post.CommentUserUiModel
+import com.withpeace.withpeace.core.ui.post.ReportTypeUiModel
+import com.withpeace.withpeace.core.ui.toRelativeString
+import java.time.LocalDateTime
+
+fun LazyListScope.CommentSection(
+ comments: List,
+ onReportComment: (id: Long, ReportTypeUiModel) -> Unit,
+) {
+ itemsIndexed(
+ items = comments,
+ key = { index, item -> item.id },
+ ) { index, comment ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ ) {
+ if (index != 0) {
+ HorizontalDivider(color = WithpeaceTheme.colors.SystemGray3)
+ } else {
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ CommentItem(comment = comment, onReportComment = onReportComment)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+}
+
+@Composable
+fun CommentItem(
+ comment: CommentUiModel,
+ onReportComment: (id: Long, ReportTypeUiModel) -> Unit,
+) {
+ var showCommentBottomSheet by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val context = LocalContext.current
+ val imageModifier = Modifier
+ .clip(CircleShape)
+ .size(40.dp)
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(modifier = Modifier.weight(1f)) {
+ GlideImage(
+ modifier = imageModifier,
+ imageModel = { comment.commentUser.profileImageUrl },
+ previewPlaceholder = R.drawable.ic_chat,
+ failure = {
+ Image(
+ painterResource(id = drawable.ic_default_profile),
+ modifier = imageModifier,
+ contentDescription = stringResource(R.string.basic_user_profile),
+ )
+ },
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Column {
+ Text(
+ text = comment.commentUser.nickname,
+ style = WithpeaceTheme.typography.body,
+ )
+ Spacer(modifier = Modifier.height(7.dp))
+ Text(
+ text = comment.createDate.toRelativeString(context),
+ fontSize = 12.sp,
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ color = WithpeaceTheme.colors.SystemGray4,
+ )
+ }
+ }
+ if (!comment.isMyComment) {
+ Icon(
+ modifier = Modifier.clickable {
+ showCommentBottomSheet = true
+ },
+ painter = painterResource(id = R.drawable.ic_more),
+ contentDescription = stringResource(
+ R.string.comment_menu_icon_description,
+ ),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = comment.content,
+ style = WithpeaceTheme.typography.caption,
+ )
+ if (showCommentBottomSheet) {
+ CommentBottomSheet(
+ isMyComment = comment.isMyComment,
+ commentId = comment.id,
+ onDismissRequest = { showCommentBottomSheet = false },
+ onClickDeleteButton = { },
+ onReportComment = onReportComment,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CommentBottomSheet(
+ isMyComment: Boolean,
+ commentId: Long,
+ onDismissRequest: () -> Unit,
+ onClickDeleteButton: () -> Unit,
+ onReportComment: (id: Long, ReportTypeUiModel) -> Unit,
+) {
+ var showReportBottomSheet by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ dragHandle = {},
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ onDismissRequest = onDismissRequest,
+ sheetState = bottomSheetState,
+ shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp),
+ ) {
+ if (isMyComment) {
+ Column(
+ modifier = Modifier.padding(
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ top = 24.dp,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable {
+ onClickDeleteButton()
+ onDismissRequest()
+ }
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_delete),
+ contentDescription = stringResource(R.string.delete_icon_content_description),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.delete_post),
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.padding(
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ top = 24.dp,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable {
+ showReportBottomSheet = true
+ }
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_complain),
+ contentDescription = stringResource(R.string.complain_icon_content_description),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.complain_post),
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ HorizontalDivider()
+ }
+ }
+ if (showReportBottomSheet) {
+ PostDetailReportBottomSheet(
+ isPostReport = false,
+ id = commentId,
+ onDismissRequest = {
+ showReportBottomSheet = false
+ onDismissRequest()
+ },
+ onClickReportType = onReportComment,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun CommentSectionPreview() {
+ WithpeaceTheme {
+ LazyColumn {
+ CommentSection(
+ comments = List(10) {
+ CommentUiModel(
+ it.toLong(),
+ "안녕하세요",
+ DateUiModel(LocalDateTime.now()),
+ CommentUserUiModel(it.toLong(), "우석", ""),
+ false,
+ )
+ },
+ onReportComment = { _, _ -> },
+ )
+ }
+ }
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailScreen.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailScreen.kt
new file mode 100644
index 00000000..16c6671c
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailScreen.kt
@@ -0,0 +1,627 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.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.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.MoreVert
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.KeyboardAware
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceBackButtonTopAppBar
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.post.CommentUiModel
+import com.withpeace.withpeace.core.ui.post.CommentUserUiModel
+import com.withpeace.withpeace.core.ui.post.PostDetailUiModel
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.PostUserUiModel
+import com.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import com.withpeace.withpeace.core.ui.post.ReportTypeUiModel
+import com.withpeace.withpeace.feature.postdetail.R.drawable
+import com.withpeace.withpeace.feature.postdetail.R.string
+import java.time.LocalDateTime
+
+@Composable
+fun PostDetailRoute(
+ viewModel: PostDetailViewModel = hiltViewModel(),
+ onShowSnackBar: (String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onClickEditButton: (RegisterPostUiModel) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ val postUiState by viewModel.postUiState.collectAsStateWithLifecycle()
+ val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
+ val commentText by viewModel.commentText.collectAsStateWithLifecycle()
+ val lazyListState = rememberLazyListState()
+
+ KeyboardAware {
+ PostDetailScreen(
+ postUiState = postUiState,
+ commentText = commentText,
+ onClickRegisterCommentButton = viewModel::registerComment,
+ onCommentTextChanged = viewModel::onCommentTextChanged,
+ onClickBackButton = onClickBackButton,
+ onClickDeleteButton = viewModel::deletePost,
+ onClickEditButton = onClickEditButton,
+ isLoading = isLoading,
+ lazyListState = lazyListState,
+ onReportComment = viewModel::reportComment,
+ onReportPost = viewModel::reportPost,
+ )
+ }
+
+ LaunchedEffect(null) {
+ viewModel.postUiEvent.collect {
+ when (it) {
+ PostDetailUiEvent.DeleteFailByNetworkError -> onShowSnackBar("삭제에 실패하였습니다. 네트워크를 확인해주세요")
+
+ PostDetailUiEvent.UnAuthorized -> onAuthExpired()
+
+ PostDetailUiEvent.DeleteSuccess -> {
+ onShowSnackBar("게시글이 삭제되었습니다.")
+ onClickBackButton()
+ }
+
+ PostDetailUiEvent.RegisterCommentFailByNetwork -> onShowSnackBar("댓글 등록에 실패했습니다. 네트워크를 확인해주세요")
+ PostDetailUiEvent.RegisterCommentSuccess -> {
+ lazyListState.fullAnimatedScroll()
+ }
+
+ PostDetailUiEvent.ReportCommentFail -> onShowSnackBar("신고에 실패하였습니다")
+ PostDetailUiEvent.ReportCommentSuccess -> onShowSnackBar("신고 되었습니다")
+ PostDetailUiEvent.ReportPostFail -> onShowSnackBar("신고에 실패하였습니다")
+ PostDetailUiEvent.ReportPostSuccess -> onShowSnackBar("신고 되었습니다")
+ PostDetailUiEvent.ReportCommentDuplicated -> onShowSnackBar("이미 신고한 댓글입니다")
+ PostDetailUiEvent.ReportPostDuplicated -> onShowSnackBar("이미 신고한 게시글입니다")
+ }
+ }
+ }
+}
+
+private suspend fun LazyListState.fullAnimatedScroll() {
+ val maxIndex = Integer.MAX_VALUE
+ val maxOffset = Integer.MAX_VALUE
+ animateScrollToItem(maxIndex, maxOffset)
+}
+
+@Composable
+fun PostDetailScreen(
+ onClickBackButton: () -> Unit = {},
+ onClickDeleteButton: () -> Unit = {},
+ postUiState: PostDetailUiState,
+ isLoading: Boolean = false,
+ onClickEditButton: (RegisterPostUiModel) -> Unit = {},
+ commentText: String = "",
+ onClickRegisterCommentButton: () -> Unit = {},
+ onCommentTextChanged: (String) -> Unit = {},
+ lazyListState: LazyListState,
+ onReportPost: (id: Long, ReportTypeUiModel) -> Unit = { _, _ -> },
+ onReportComment: (id: Long, ReportTypeUiModel) -> Unit = { _, _ -> },
+) {
+ var showPostBottomSheet by rememberSaveable {
+ mutableStateOf(false)
+ }
+
+ var showDeleteDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
+
+ Scaffold(
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ topBar = {
+ WithPeaceBackButtonTopAppBar(
+ onClickBackButton = onClickBackButton,
+ title = {},
+ actions = {
+ Icon(
+ modifier = Modifier
+ .clickable {
+ showPostBottomSheet = true
+ }
+ .padding(21.dp)
+ .size(24.dp),
+ imageVector = Icons.Rounded.MoreVert,
+ contentDescription = "option",
+ )
+ },
+ )
+ },
+ bottomBar = {
+ RegisterCommentSection(
+ onClickRegisterButton = onClickRegisterCommentButton,
+ onTextChanged = onCommentTextChanged,
+ text = commentText,
+ )
+ },
+ ) { innerPadding ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ when (postUiState) {
+ PostDetailUiState.Init -> Unit
+ PostDetailUiState.FailByNetwork -> {
+ Text(
+ text = stringResource(string.netwokr_error_message_text),
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ PostDetailUiState.NotFound -> {
+ Text(
+ text = stringResource(string.not_found_post),
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ is PostDetailUiState.Success -> {
+
+ LazyColumn(state = lazyListState) {
+ PostSection(
+ postTopic = postUiState.postDetail.postTopic,
+ postUser = postUiState.postDetail.postUser,
+ createDate = postUiState.postDetail.createDate,
+ title = postUiState.postDetail.title,
+ content = postUiState.postDetail.content,
+ imageUrls = postUiState.postDetail.imageUrls,
+ commentSize = postUiState.postDetail.comments.size,
+ )
+ CommentSection(
+ comments = postUiState.postDetail.comments,
+ onReportComment = onReportComment,
+ )
+ }
+
+ if (showPostBottomSheet) {
+ PostDetailPostBottomSheet(
+ isMyPost = postUiState.postDetail.isMyPost,
+ postId = postUiState.postDetail.id,
+ onDismissRequest = { showPostBottomSheet = false },
+ onClickDeleteButton = { showDeleteDialog = true },
+ onClickEditButton = {
+ onClickEditButton(
+ RegisterPostUiModel(
+ id = postUiState.postDetail.id,
+ title = postUiState.postDetail.title,
+ content = postUiState.postDetail.content,
+ topic = postUiState.postDetail.postTopic,
+ imageUrls = postUiState.postDetail.imageUrls,
+ ),
+ )
+ },
+ onReportPost = onReportPost,
+ )
+ }
+
+ if (showDeleteDialog) {
+ DeletePostDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ onClickConfirmButton = onClickDeleteButton,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DeletePostDialog(
+ onDismissRequest: () -> Unit,
+ onClickConfirmButton: () -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier = Modifier
+ .background(
+ WithpeaceTheme.colors.SystemWhite,
+ RoundedCornerShape(10.dp),
+ )
+ .wrapContentSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(top = 24.dp, bottom = 16.dp),
+ text = stringResource(string.delete_post_dialog_title),
+ style = WithpeaceTheme.typography.title2,
+ )
+ Text(
+ modifier = Modifier.padding(bottom = 16.dp),
+ text = stringResource(string.delete_post_dialog_description),
+ style = WithpeaceTheme.typography.body,
+ textAlign = TextAlign.Center,
+ )
+ Row {
+ Button(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 4.dp)
+ .weight(1f),
+ onClick = { onDismissRequest() },
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(width = 1.dp, color = WithpeaceTheme.colors.MainPurple),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.SystemWhite),
+ ) {
+ Text(
+ text = stringResource(string.delete_cancel),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ Button(
+ modifier = Modifier
+ .padding(start = 4.dp, end = 16.dp)
+ .weight(1f),
+ onClick = { onClickConfirmButton() },
+ shape = RoundedCornerShape(10.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.MainPurple),
+ ) {
+ Text(
+ text = stringResource(id = string.delete_post),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PostDetailPostBottomSheet(
+ isMyPost: Boolean,
+ postId: Long,
+ onDismissRequest: () -> Unit,
+ onClickDeleteButton: () -> Unit,
+ onClickEditButton: () -> Unit,
+ onReportPost: (id: Long, ReportTypeUiModel) -> Unit,
+) {
+ var showReportBottomSheet by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ dragHandle = {},
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ onDismissRequest = onDismissRequest,
+ sheetState = bottomSheetState,
+ shape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp),
+ ) {
+ if(isMyPost){
+ Column(
+ modifier = Modifier.padding(
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ top = 24.dp,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable {
+ onClickEditButton()
+ onDismissRequest()
+ }
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = drawable.ic_edit),
+ contentDescription = stringResource(string.edit_icon_content_description),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(string.edit_post),
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ HorizontalDivider(color = WithpeaceTheme.colors.SystemGray3)
+ Row(
+ modifier = Modifier
+ .clickable {
+ onClickDeleteButton()
+ onDismissRequest()
+ }
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = drawable.ic_delete),
+ contentDescription = stringResource(string.delete_icon_content_description),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(string.delete_post),
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = Modifier.padding(
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ top = 24.dp,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .clickable {
+ showReportBottomSheet = true
+ }
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = drawable.ic_complain),
+ contentDescription = stringResource(string.complain_icon_content_description),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(string.complain_post),
+ style = WithpeaceTheme.typography.body,
+ )
+ }
+ // TODO("사용자의 글 다시보지 않기")
+ // HorizontalDivider()
+ // Row(
+ // modifier = Modifier.padding(vertical = 16.dp),
+ // verticalAlignment = Alignment.CenterVertically,
+ // ) {
+ // Icon(
+ // painter = painterResource(id = drawable.ic_hide),
+ // contentDescription = stringResource(string.hide_user_posts_icon_content_description),
+ // )
+ // Spacer(modifier = Modifier.width(8.dp))
+ // Text(
+ // text = stringResource(string.hide_user_posts),
+ // style = WithpeaceTheme.typography.body,
+ // )
+ // }
+ }
+ }
+ if (showReportBottomSheet) {
+ PostDetailReportBottomSheet(
+ isPostReport = true,
+ id = postId,
+ onDismissRequest = {
+ showReportBottomSheet = false
+ onDismissRequest()
+ },
+ onClickReportType = onReportPost,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PostDetailReportBottomSheet(
+ isPostReport: Boolean,
+ id: Long,
+ onDismissRequest: () -> Unit,
+ onClickReportType: (id: Long, ReportTypeUiModel) -> Unit,
+) {
+ val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ModalBottomSheet(
+ dragHandle = {},
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ onDismissRequest = onDismissRequest,
+ sheetState = bottomSheetState,
+ shape = RectangleShape,
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ WithPeaceBackButtonTopAppBar(
+ onClickBackButton = onDismissRequest,
+ title = {
+ Text(text = "신고하는 이유를 선택해주세요", style = WithpeaceTheme.typography.title1)
+ },
+ )
+ HorizontalDivider(Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ ) {
+ ReportTypeUiModel.entries.forEach { reportTypeUiModel ->
+ ReportTypeItem(
+ isPostReport = isPostReport,
+ id = id,
+ reportTypeUiModel = reportTypeUiModel,
+ onClickReportType = onClickReportType,
+ onDismissRequest = onDismissRequest,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ReportTypeItem(
+ modifier: Modifier = Modifier,
+ isPostReport: Boolean,
+ id: Long,
+ reportTypeUiModel: ReportTypeUiModel,
+ onClickReportType: (id: Long, ReportTypeUiModel) -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ var showReportDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val title = if (isPostReport) reportTypeUiModel.postTitle else reportTypeUiModel.commentTitle
+ Column(
+ modifier = modifier.clickable {
+ showReportDialog = true
+ },
+ ) {
+ Text(
+ text = title,
+ style = WithpeaceTheme.typography.body,
+ modifier = Modifier.padding(start = 4.dp, top = 16.dp, bottom = 16.dp),
+ )
+ HorizontalDivider()
+ }
+ if (showReportDialog) {
+ ReportDialog(
+ title = title,
+ onClickReportButton = {
+ onClickReportType(id, reportTypeUiModel)
+ onDismissRequest()
+ },
+ onDismissRequest = { showReportDialog = false },
+ )
+ }
+}
+
+@Composable
+fun ReportDialog(
+ title: String,
+ onClickReportButton: () -> Unit,
+ onDismissRequest: () -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier = Modifier
+ .background(
+ WithpeaceTheme.colors.SystemWhite,
+ RoundedCornerShape(10.dp),
+ )
+ .wrapContentSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ modifier = Modifier.padding(top = 24.dp, bottom = 16.dp),
+ text = title,
+ style = WithpeaceTheme.typography.title2,
+ )
+ Row {
+ Button(
+ modifier = Modifier
+ .padding(start = 16.dp, end = 4.dp)
+ .weight(1f),
+ onClick = { onDismissRequest() },
+ shape = RoundedCornerShape(10.dp),
+ border = BorderStroke(width = 1.dp, color = WithpeaceTheme.colors.MainPurple),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.SystemWhite),
+ ) {
+ Text(
+ text = stringResource(string.delete_cancel),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ Button(
+ modifier = Modifier
+ .padding(start = 4.dp, end = 16.dp)
+ .weight(1f),
+ onClick = {
+ onClickReportButton()
+ onDismissRequest()
+ },
+ shape = RoundedCornerShape(10.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.MainPurple),
+ ) {
+ Text(
+ text = "신고하기",
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Preview(showBackground = true, widthDp = 400, heightDp = 900, backgroundColor = 0xFFffffff)
+@Composable
+private fun PostDetailScreenPreview() {
+ WithpeaceTheme {
+ PostDetailScreen(
+ postUiState = PostDetailUiState.Success(
+ PostDetailUiModel(
+ postUser = PostUserUiModel(
+ id = 4323,
+ name = "Angeline Shaw",
+ profileImageUrl = "https://www.google.com/#q=ignota",
+ ),
+ id = 1529,
+ title = "일찌기 나는 아무것도 아니었다.",
+ content = "돌아가는 팽이를 보고 싶어서, 그 팽이가 온전히 내 팽이이고 싶어서, 내 속도를 그대로 빼닮은 팽이의 회전을 여유롭게 관찰하고 싶어서, 그러니까 문방구에서 막상 팽이를 사오긴 했는데 요즘 누가 팽이 돌리나 눈치 보다 땅에다가는 못 풀고 눈으로 푸는 마음, 그 눈에서 돌아가는 팽이의 마음, 그거 같다",
+ postTopic = PostTopicUiModel.FREEDOM,
+ imageUrls = listOf(),
+ createDate = DateUiModel(
+ LocalDateTime.now(),
+ ),
+ isMyPost = false,
+ comments = List(10) {
+ CommentUiModel(
+ id = it.toLong(),
+ content = "natum",
+ createDate = DateUiModel(date = LocalDateTime.now()),
+ commentUser = CommentUserUiModel(
+ id = 9807,
+ nickname = "Becky Lowery",
+ profileImageUrl = "https://www.google.com/#q=repudiare",
+ ),
+ false
+ )
+ },
+ ),
+ ),
+ lazyListState = rememberLazyListState(),
+ )
+ }
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiEvent.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiEvent.kt
new file mode 100644
index 00000000..615bba8e
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiEvent.kt
@@ -0,0 +1,15 @@
+package com.withpeace.withpeace.feature.postdetail
+
+sealed interface PostDetailUiEvent {
+ data object DeleteFailByNetworkError : PostDetailUiEvent
+ data object UnAuthorized : PostDetailUiEvent
+ data object DeleteSuccess : PostDetailUiEvent
+ data object RegisterCommentFailByNetwork : PostDetailUiEvent
+ data object RegisterCommentSuccess : PostDetailUiEvent
+ data object ReportPostSuccess : PostDetailUiEvent
+ data object ReportPostFail : PostDetailUiEvent
+ data object ReportCommentSuccess : PostDetailUiEvent
+ data object ReportCommentFail : PostDetailUiEvent
+ data object ReportPostDuplicated : PostDetailUiEvent
+ data object ReportCommentDuplicated : PostDetailUiEvent
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiState.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiState.kt
new file mode 100644
index 00000000..7d65c052
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailUiState.kt
@@ -0,0 +1,10 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import com.withpeace.withpeace.core.ui.post.PostDetailUiModel
+
+sealed interface PostDetailUiState {
+ data object Init : PostDetailUiState
+ data class Success(val postDetail: PostDetailUiModel) : PostDetailUiState
+ data object FailByNetwork : PostDetailUiState
+ data object NotFound : PostDetailUiState
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailViewModel.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailViewModel.kt
new file mode 100644
index 00000000..ea60898e
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostDetailViewModel.kt
@@ -0,0 +1,168 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.usecase.DeletePostUseCase
+import com.withpeace.withpeace.core.domain.usecase.GetCurrentUserIdUseCase
+import com.withpeace.withpeace.core.domain.usecase.GetPostDetailUseCase
+import com.withpeace.withpeace.core.domain.usecase.RegisterCommentUseCase
+import com.withpeace.withpeace.core.domain.usecase.ReportCommentUseCase
+import com.withpeace.withpeace.core.domain.usecase.ReportPostUseCase
+import com.withpeace.withpeace.core.ui.post.ReportTypeUiModel
+import com.withpeace.withpeace.core.ui.post.toDomain
+import com.withpeace.withpeace.core.ui.post.toUiModel
+import com.withpeace.withpeace.feature.postdetail.navigation.POST_DETAIL_ID_ARGUMENT
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PostDetailViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val getPostDetailUseCase: GetPostDetailUseCase,
+ private val getCurrentUserIdUseCase: GetCurrentUserIdUseCase,
+ private val deletePostUseCase: DeletePostUseCase,
+ private val registerCommentUseCase: RegisterCommentUseCase,
+ private val reportPostUseCase: ReportPostUseCase,
+ private val reportCommentUseCase: ReportCommentUseCase,
+) : ViewModel() {
+
+ private val postId =
+ checkNotNull(savedStateHandle.get(POST_DETAIL_ID_ARGUMENT)) { "게시글 아이디가 유효하지 않아요" }
+
+ private val _postUiState = MutableStateFlow(PostDetailUiState.Init)
+ val postUiState = _postUiState.asStateFlow()
+
+ private val _commentText = MutableStateFlow("")
+ val commentText = _commentText.asStateFlow()
+
+ private val _postUiEvent = Channel()
+ val postUiEvent = _postUiEvent.receiveAsFlow()
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading = _isLoading.asStateFlow()
+
+ init {
+ fetchPostDetail()
+ }
+
+ private fun fetchPostDetail(
+ onSuccess: suspend () -> Unit = {},
+ ) {
+ getPostDetailUseCase(
+ postId,
+ onError = {
+ when(it) {
+ ResponseError.NOT_FOUND_RESOURCE -> _postUiState.update { PostDetailUiState.NotFound }
+ ClientError.AuthExpired -> _postUiEvent.send(PostDetailUiEvent.UnAuthorized)
+ else -> _postUiState.update { PostDetailUiState.FailByNetwork }
+ }
+ },
+ ).onEach { data ->
+ val currentUserId = getCurrentUserIdUseCase()
+ _postUiState.update { PostDetailUiState.Success(data.toUiModel(currentUserId)) }
+ onSuccess()
+ }.onStart {
+ _isLoading.update { true }
+ }.onCompletion {
+ _isLoading.update { false }
+ }.launchIn(viewModelScope)
+ }
+
+ fun deletePost() {
+ deletePostUseCase(
+ postId = postId,
+ onError = {
+ when (it) {
+ ClientError.AuthExpired -> _postUiEvent.send(PostDetailUiEvent.UnAuthorized)
+ else -> _postUiEvent.send(PostDetailUiEvent.DeleteFailByNetworkError)
+ }
+ },
+ ).onEach {
+ _postUiEvent.send(PostDetailUiEvent.DeleteSuccess)
+ }.launchIn(viewModelScope)
+ }
+
+ fun onCommentTextChanged(input: String) {
+ viewModelScope.launch { _commentText.update { input } }
+ }
+
+ fun registerComment() {
+ if (commentText.value == "") return
+ registerCommentUseCase(
+ postId = postId,
+ content = commentText.value,
+ onError = {
+ when (it) {
+ ClientError.AuthExpired -> _postUiEvent.send(PostDetailUiEvent.UnAuthorized)
+ else -> _postUiEvent.send(PostDetailUiEvent.RegisterCommentFailByNetwork)
+ }
+ },
+ ).onStart {
+ _isLoading.update { true }
+ }.onEach {
+ fetchPostDetail {
+ _commentText.update { "" }
+ _postUiEvent.send(PostDetailUiEvent.RegisterCommentSuccess)
+ }
+ }.onCompletion {
+ _isLoading.update { false }
+ }.launchIn(viewModelScope)
+ }
+
+ fun reportPost(
+ postId: Long,
+ reportTypeUiModel: ReportTypeUiModel,
+ ) {
+ reportPostUseCase(
+ postId,
+ reportTypeUiModel.toDomain(),
+ onError = {
+ when (it) {
+ ResponseError.POST_DUPLICATED_ERROR -> _postUiEvent.send(PostDetailUiEvent.ReportPostDuplicated)
+ else -> _postUiEvent.send(PostDetailUiEvent.ReportPostFail)
+ }
+ },
+ ).onStart {
+ _isLoading.update { true }
+ }.onEach {
+ _postUiEvent.send(PostDetailUiEvent.ReportPostSuccess)
+ }.onCompletion {
+ _isLoading.update { false }
+ }.launchIn(viewModelScope)
+ }
+
+ fun reportComment(
+ commentId: Long,
+ reportTypeUiModel: ReportTypeUiModel,
+ ) {
+ reportCommentUseCase(
+ commentId,
+ reportTypeUiModel.toDomain(),
+ onError = {
+ when (it) {
+ ResponseError.COMMENT_DUPLICATED_ERROR -> _postUiEvent.send(PostDetailUiEvent.ReportCommentDuplicated)
+ else -> _postUiEvent.send(PostDetailUiEvent.ReportCommentFail)
+ }
+ },
+ ).onStart {
+ _isLoading.update { true }
+ }.onEach {
+ _postUiEvent.send(PostDetailUiEvent.ReportCommentSuccess)
+ }.onCompletion {
+ _isLoading.update { false }
+ }.launchIn(viewModelScope)
+ }
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostSection.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostSection.kt
new file mode 100644
index 00000000..d573d089
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/PostSection.kt
@@ -0,0 +1,239 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+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.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.skydoves.landscapist.ImageOptions
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.R
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.PostUserUiModel
+import com.withpeace.withpeace.core.ui.toRelativeString
+import com.withpeace.withpeace.feature.postdetail.R.drawable
+import com.withpeace.withpeace.feature.postdetail.R.string
+
+fun LazyListScope.PostSection(
+ postTopic: PostTopicUiModel,
+ postUser: PostUserUiModel,
+ createDate: DateUiModel,
+ title: String,
+ content: String,
+ imageUrls: List,
+ commentSize: Int,
+) {
+
+ item {
+ HorizontalDivider(
+ modifier = Modifier
+ .height(1.dp)
+ .fillMaxWidth()
+ .padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ PostTopic(postTopic = postTopic)
+ Spacer(modifier = Modifier.height(16.dp))
+ PostUserProfile(
+ modifier = Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ user = postUser,
+ createDate = createDate,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ PostTitle(
+ modifier = Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ title = title,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ PostContent(
+ modifier = Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ content = content,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ PostImages(
+ imageUrls = imageUrls,
+ )
+ item {
+ CommentSize(commentSize = commentSize)
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ item {
+ Box(
+ modifier = Modifier
+ .height(4.dp)
+ .fillMaxWidth()
+ .background(WithpeaceTheme.colors.SystemGray3),
+ )
+ }
+}
+
+
+fun LazyListScope.PostImages(
+ imageUrls: List,
+) {
+ items(
+ items = imageUrls,
+ ) { imageUrl ->
+ GlideImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ imageOptions = ImageOptions(contentScale = ContentScale.FillWidth),
+ imageModel = { imageUrl },
+ previewPlaceholder = R.drawable.ic_freedom,
+ failure = {
+ Text(
+ text = stringResource(string.can_not_load_image),
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+}
+
+@Composable
+fun PostTitle(
+ modifier: Modifier = Modifier,
+ title: String,
+) {
+ Text(
+ modifier = modifier,
+ text = title,
+ style = WithpeaceTheme.typography.title2,
+ )
+}
+
+@Composable
+fun PostContent(
+ modifier: Modifier = Modifier,
+ content: String,
+) {
+ Text(
+ modifier = modifier,
+ text = content,
+ style = WithpeaceTheme.typography.body,
+ )
+}
+
+@Composable
+fun PostTopic(
+ modifier: Modifier = Modifier,
+ postTopic: PostTopicUiModel,
+) {
+ Box(
+ modifier = modifier
+ .padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding)
+ .background(
+ color = WithpeaceTheme.colors.MainPurple,
+ shape = RoundedCornerShape(20.dp),
+ ),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier.size(14.dp),
+ painter = painterResource(id = postTopic.iconResId),
+ contentDescription = postTopic.name,
+ tint = WithpeaceTheme.colors.SystemWhite,
+ )
+ Spacer(modifier = Modifier.width(11.dp))
+ Text(
+ text = stringResource(id = postTopic.textResId),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+fun PostUserProfile(
+ modifier: Modifier = Modifier,
+ createDate: DateUiModel,
+ user: PostUserUiModel,
+) {
+ val imageModifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ val context = LocalContext.current
+ Row(modifier = modifier) {
+ GlideImage(
+ modifier = imageModifier,
+ imageModel = { user.profileImageUrl },
+ previewPlaceholder = R.drawable.ic_freedom,
+ failure = {
+ Image(
+ painterResource(id = R.drawable.ic_default_profile),
+ modifier = imageModifier,
+ contentDescription = stringResource(string.basic_user_profile),
+ )
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Spacer(modifier = Modifier.height(5.dp))
+ Text(
+ text = user.name,
+ style = WithpeaceTheme.typography.body,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(modifier = Modifier.height(7.dp))
+ Text(
+ text = createDate.toRelativeString(context),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemGray4,
+ )
+ }
+ }
+}
+
+@Composable
+fun CommentSize(
+ commentSize: Int,
+) {
+ Row(
+ modifier = Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(id = drawable.ic_chat),
+ contentDescription = "댓글 개수",
+ modifier = Modifier.padding(end = 4.dp),
+ )
+ Text(
+ text = "$commentSize",
+ style = WithpeaceTheme.typography.caption,
+ )
+ }
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/RegisterCommentSection.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/RegisterCommentSection.kt
new file mode 100644
index 00000000..3963a17e
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/RegisterCommentSection.kt
@@ -0,0 +1,122 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+
+@Composable
+fun RegisterCommentSection(
+ modifier: Modifier = Modifier,
+ onClickRegisterButton: () -> Unit = {},
+ onTextChanged: (String) -> Unit = {},
+ text: String = "",
+) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+ Column {
+ HorizontalDivider(color = WithpeaceTheme.colors.SystemGray3)
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = WithpeaceTheme.padding.BasicHorizontalPadding,
+ vertical = 8.dp,
+ ),
+ shape = RoundedCornerShape(21.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ ) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .padding(
+ horizontal = WithpeaceTheme.padding.BasicHorizontalPadding,
+ vertical = 8.dp,
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CommentTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = 8.dp)
+ .weight(1f),
+ onTextChanged = onTextChanged,
+ text = text,
+ )
+ Icon(
+ modifier = Modifier.clickable {
+ onClickRegisterButton()
+ keyboardController?.hide()
+ },
+ painter = painterResource(id = R.drawable.ic_send),
+ contentDescription = stringResource(R.string.comment_register_icon_description),
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CommentTextField(
+ modifier: Modifier = Modifier,
+ onTextChanged: (String) -> Unit = {},
+ text: String = "",
+) {
+ BasicTextField(
+ value = text,
+ onValueChange = {
+ onTextChanged(it)
+ },
+ modifier = modifier,
+ enabled = true,
+ textStyle = WithpeaceTheme.typography.caption,
+ ) {
+ TextFieldDefaults.DecorationBox(
+ value = text,
+ innerTextField = it,
+ enabled = true,
+ singleLine = true,
+ visualTransformation = VisualTransformation.None,
+ placeholder = {
+ Text(
+ text = stringResource(R.string.please_type_comment),
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ interactionSource = remember { MutableInteractionSource() },
+ contentPadding = PaddingValues(0.dp),
+ colors = TextFieldDefaults.colors(
+ disabledTextColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedContainerColor = Color.Transparent,
+ ),
+ )
+ }
+}
diff --git a/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/navigation/PostDetailNavigation.kt b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/navigation/PostDetailNavigation.kt
new file mode 100644
index 00000000..15210987
--- /dev/null
+++ b/feature/postdetail/src/main/java/com/withpeace/withpeace/feature/postdetail/navigation/PostDetailNavigation.kt
@@ -0,0 +1,49 @@
+package com.withpeace.withpeace.feature.postdetail.navigation
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.tween
+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.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import com.withpeace.withpeace.feature.postdetail.PostDetailRoute
+
+const val POST_DETAIL_ROUTE = "post_detail_route"
+const val POST_DETAIL_ID_ARGUMENT = "post_id_argument"
+const val POST_DETAIL_ROUTE_WITH_ARGUMENT =
+ "$POST_DETAIL_ROUTE/{$POST_DETAIL_ID_ARGUMENT}"
+
+fun NavController.navigateToPostDetail(
+ postId: Long,
+ navOptions: NavOptions? = null,
+) = navigate(route = "$POST_DETAIL_ROUTE/$postId", navOptions)
+
+fun NavGraphBuilder.postDetailGraph(
+ onShowSnackBar: (String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onClickEditButton: (RegisterPostUiModel) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ composable(
+ route = POST_DETAIL_ROUTE_WITH_ARGUMENT,
+ arguments = listOf(
+ navArgument(POST_DETAIL_ID_ARGUMENT) { type = NavType.LongType },
+ ),
+ enterTransition = {
+ slideIntoContainer(
+ AnimatedContentTransitionScope.SlideDirection.Left,
+ animationSpec = tween(500),
+ )
+ },
+ ) {
+ PostDetailRoute(
+ onShowSnackBar = onShowSnackBar,
+ onClickBackButton = onClickBackButton,
+ onClickEditButton = onClickEditButton,
+ onAuthExpired = onAuthExpired
+ )
+ }
+}
diff --git a/feature/postdetail/src/main/res/drawable/ic_chat.xml b/feature/postdetail/src/main/res/drawable/ic_chat.xml
new file mode 100644
index 00000000..a2caa1c4
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_chat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_complain.xml b/feature/postdetail/src/main/res/drawable/ic_complain.xml
new file mode 100644
index 00000000..4022176f
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_complain.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_delete.xml b/feature/postdetail/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 00000000..048edcc9
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_edit.xml b/feature/postdetail/src/main/res/drawable/ic_edit.xml
new file mode 100644
index 00000000..a45e6ab1
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_edit.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_hide.xml b/feature/postdetail/src/main/res/drawable/ic_hide.xml
new file mode 100644
index 00000000..9ff27923
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_hide.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_more.xml b/feature/postdetail/src/main/res/drawable/ic_more.xml
new file mode 100644
index 00000000..eab1c085
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_more.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/feature/postdetail/src/main/res/drawable/ic_send.xml b/feature/postdetail/src/main/res/drawable/ic_send.xml
new file mode 100644
index 00000000..314e3002
--- /dev/null
+++ b/feature/postdetail/src/main/res/drawable/ic_send.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/postdetail/src/main/res/values/strings.xml b/feature/postdetail/src/main/res/values/strings.xml
new file mode 100644
index 00000000..b2e2ea39
--- /dev/null
+++ b/feature/postdetail/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+
+ 수정하기 아이콘
+ 수정하기
+ 삭제하기 아이콘
+ 삭제하기
+ 신고하기 아이콘
+ 신고하기
+ 사용자 글 안보기 아이콘
+ 이 사용자의 글 보지 않기
+ 네트워크 상태를 확인해주세요
+ 게시글이 존재하지 않습니다.
+ 취소하기
+ 게시글을 삭제할까요?
+ 게시글을 삭제하면 모든 데이터가\n삭제되고 다시 볼 수 없어요.
+ 기본 유저 이미지
+ 사진을 불러올 수 없어요
+ 댓글 더보기
+ 댓글 작성 아이콘
+ 댓글을 입력해주세요
+
diff --git a/feature/postdetail/src/test/java/com/withpeace/withpeace/feature/postdetail/ExampleUnitTest.kt b/feature/postdetail/src/test/java/com/withpeace/withpeace/feature/postdetail/ExampleUnitTest.kt
new file mode 100644
index 00000000..6c44ba46
--- /dev/null
+++ b/feature/postdetail/src/test/java/com/withpeace/withpeace/feature/postdetail/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.feature.postdetail
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
diff --git a/feature/postlist/.gitignore b/feature/postlist/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/postlist/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/postlist/build.gradle.kts b/feature/postlist/build.gradle.kts
new file mode 100644
index 00000000..edac9f9d
--- /dev/null
+++ b/feature/postlist/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.postlist"
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ //https://developer.android.com/reference/tools/gradle-api/4.1/com/android/build/api/dsl/UnitTestOptions#isreturndefaultvalues
+ }
+}
+
+dependencies {
+ implementation(libs.skydoves.landscapist.bom)
+ implementation(libs.skydoves.landscapist.glide)
+ implementation(libs.androidx.paging.common)
+ implementation(libs.androidx.pagingCompose)
+ testImplementation(libs.androidx.paging.testing)
+ testImplementation(libs.androidx.core.testing)
+}
diff --git a/feature/postlist/consumer-rules.pro b/feature/postlist/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/postlist/proguard-rules.pro b/feature/postlist/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/postlist/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/postlist/src/androidTest/java/com/withpeace/withpeace/feature/postlist/ExampleInstrumentedTest.kt b/feature/postlist/src/androidTest/java/com/withpeace/withpeace/feature/postlist/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..4851dd0a
--- /dev/null
+++ b/feature/postlist/src/androidTest/java/com/withpeace/withpeace/feature/postlist/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.feature.postlist
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.feature.postlist.test", appContext.packageName)
+ }
+}
diff --git a/feature/postlist/src/main/AndroidManifest.xml b/feature/postlist/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/postlist/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt
new file mode 100644
index 00000000..e46bcf4b
--- /dev/null
+++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt
@@ -0,0 +1,252 @@
+package com.withpeace.withpeace.feature.postlist
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.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.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+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.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.LoadState
+import androidx.paging.LoadStates
+import androidx.paging.PagingData
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemKey
+import com.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.theme.PretendardFont
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.WithpeaceCard
+import com.withpeace.withpeace.core.ui.DateUiModel
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.R
+import com.withpeace.withpeace.core.ui.post.PostUiModel
+import com.withpeace.withpeace.core.ui.toRelativeString
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.flowOf
+import java.time.LocalDateTime
+
+@Composable
+fun PostListRoute(
+ viewModel: PostListViewModel = hiltViewModel(),
+ navigateToDetail: (postId: Long) -> Unit,
+ onShowSnackBar: (String) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ val postListPagingData = viewModel.postListPagingFlow.collectAsLazyPagingItems()
+ val currentTopic by viewModel.currentTopic.collectAsStateWithLifecycle()
+ PostListScreen(
+ currentTopic = currentTopic,
+ postListPagingData = postListPagingData,
+ onTopicChanged = viewModel::onTopicChanged,
+ navigateToDetail = navigateToDetail,
+ )
+
+ LaunchedEffect(null) {
+ viewModel.uiEvent.collectLatest {
+ when (it) {
+ PostListUiEvent.NetworkError -> onShowSnackBar("네트워크를 확인해 주세요")
+ PostListUiEvent.UnAuthorizedError -> onAuthExpired()
+ }
+ }
+ }
+}
+
+@Composable
+fun PostListScreen(
+ currentTopic: PostTopicUiModel,
+ postListPagingData: LazyPagingItems,
+ onTopicChanged: (PostTopicUiModel) -> Unit = {},
+ navigateToDetail: (postId: Long) -> Unit = {},
+) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ TopicTabs(
+ currentTopic = currentTopic,
+ onClick = onTopicChanged,
+ tabPosition = currentTopic.index,
+ )
+ when (postListPagingData.loadState.refresh) {
+ is LoadState.Loading -> {
+ Box(Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+
+ is LoadState.Error -> {
+ Box(Modifier.fillMaxSize()) {
+ Text(
+ text = "네트워크 상태를 확인해주세요.",
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+
+ is LoadState.NotLoading -> {
+ PostListItems(
+ postListPagingData = postListPagingData,
+ navigateToDetail = navigateToDetail,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun PostListItems(
+ postListPagingData: LazyPagingItems,
+ navigateToDetail: (postId: Long) -> Unit = {},
+) {
+ val context = LocalContext.current
+ LazyColumn(
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(
+ count = postListPagingData.itemCount,
+ key = postListPagingData.itemKey { it.postId }
+ ) {
+ val post = postListPagingData[it] ?: throw IllegalStateException()
+ WithpeaceCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { navigateToDetail(post.postId) },
+ ) {
+ ConstraintLayout(
+ modifier = Modifier
+ .padding(vertical = 15.dp, horizontal = 16.dp)
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ ) {
+ val (column, image) = createRefs()
+ Column(
+ modifier = Modifier
+ .padding(end = 8.dp)
+ .constrainAs(column) {
+ top.linkTo(parent.top)
+ start.linkTo(parent.start)
+ end.linkTo(image.start)
+ width = Dimension.fillToConstraints
+ },
+ ) {
+ Text(
+ text = post.title,
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Bold,
+ fontSize = 16.sp,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ )
+ Text(
+ modifier = Modifier.padding(vertical = 8.dp),
+ text = post.content,
+ style = WithpeaceTheme.typography.caption,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = post.createDate.toRelativeString(context),
+ fontFamily = PretendardFont,
+ fontWeight = FontWeight.Normal,
+ color = WithpeaceTheme.colors.SystemGray2,
+ fontSize = 12.sp,
+ )
+ }
+ post.postImageUrl?.let {
+ GlideImage(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(RoundedCornerShape(5.dp))
+ .constrainAs(image) {
+ top.linkTo(parent.top)
+ bottom.linkTo(parent.bottom)
+ end.linkTo(parent.end)
+ },
+ imageModel = { it },
+ previewPlaceholder = R.drawable.ic_freedom,
+ )
+ }
+ }
+ }
+ }
+ item {
+ if (postListPagingData.loadState.append is LoadState.Loading) {
+ Column(
+ modifier = Modifier.padding(top = 8.dp)
+ .fillMaxWidth()
+ .background(
+ Color.Transparent,
+ ),
+ ) {
+ CircularProgressIndicator(
+ Modifier.align(Alignment.CenterHorizontally),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PostListScreenPreview() {
+ WithpeaceTheme {
+ PostListScreen(
+ currentTopic = PostTopicUiModel.ECONOMY,
+ postListPagingData =
+ flowOf(
+ PagingData.from(
+ List(10) {
+ PostUiModel(
+ postId = 6724,
+ title = "fugit",
+ content = "varius",
+ postTopic = PostTopicUiModel.ECONOMY,
+ createDate = DateUiModel(
+ date = LocalDateTime.now(),
+ ),
+ postImageUrl = null,
+ )
+ },
+ sourceLoadStates =
+ LoadStates(
+ refresh = LoadState.NotLoading(false),
+ append = LoadState.NotLoading(false),
+ prepend = LoadState.NotLoading(false),
+ ),
+ ),
+ ).collectAsLazyPagingItems(),
+ )
+ }
+}
diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListUiEvent.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListUiEvent.kt
new file mode 100644
index 00000000..81054720
--- /dev/null
+++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListUiEvent.kt
@@ -0,0 +1,7 @@
+package com.withpeace.withpeace.feature.postlist
+
+sealed interface PostListUiEvent {
+ data object UnAuthorizedError : PostListUiEvent
+
+ data object NetworkError : PostListUiEvent
+}
diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt
new file mode 100644
index 00000000..6431f276
--- /dev/null
+++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt
@@ -0,0 +1,70 @@
+package com.withpeace.withpeace.feature.postlist
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.map
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.usecase.GetPostsUseCase
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.PostUiModel
+import com.withpeace.withpeace.core.ui.post.toDomain
+import com.withpeace.withpeace.core.ui.post.toPostUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PostListViewModel @Inject constructor(
+ private val getPostsUseCase: GetPostsUseCase,
+) : ViewModel() {
+ private val _uiEvent = Channel()
+ val uiEvent = _uiEvent.receiveAsFlow()
+
+ private val _currentTopic = MutableStateFlow(PostTopicUiModel.FREEDOM)
+ val currentTopic = _currentTopic.asStateFlow()
+
+ private val _postListPagingFlow = MutableStateFlow(PagingData.empty())
+ val postListPagingFlow = _postListPagingFlow.asStateFlow()
+
+ init {
+ fetchPostList(currentTopic.value)
+ }
+
+ fun onTopicChanged(postTopic: PostTopicUiModel) {
+ _currentTopic.update { postTopic }
+ fetchPostList(postTopic)
+ }
+
+ private fun fetchPostList(postTopic: PostTopicUiModel) {
+ viewModelScope.launch {
+ _postListPagingFlow.update {
+ getPostsUseCase(
+ postTopic = postTopic.toDomain(),
+ pageSize = PAGE_SIZE,
+ onError = {
+ when (it) {
+ ClientError.AuthExpired -> _uiEvent.send(PostListUiEvent.UnAuthorizedError)
+ else -> _uiEvent.send(PostListUiEvent.NetworkError)
+ }
+ throw IllegalStateException() // LoadStateError를 내보내기 위함
+ },
+ ).map {
+ it.map { it.toPostUiModel() }
+ }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty()
+ }
+ }
+ }
+
+ companion object {
+ private const val PAGE_SIZE = 7
+ }
+}
diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/TopicTabs.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/TopicTabs.kt
new file mode 100644
index 00000000..5d906dd2
--- /dev/null
+++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/TopicTabs.kt
@@ -0,0 +1,63 @@
+package com.withpeace.withpeace.feature.postlist
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TabRowDefaults
+import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+
+@Composable
+fun TopicTabs(
+ currentTopic: PostTopicUiModel,
+ tabPosition: Int,
+ onClick: (PostTopicUiModel) -> Unit,
+) {
+ TabRow(
+ modifier = Modifier.wrapContentSize(),
+ selectedTabIndex = tabPosition,
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ indicator = { tabPositions ->
+ TabRowDefaults.SecondaryIndicator(
+ modifier = Modifier
+ .tabIndicatorOffset(tabPositions[tabPosition])
+ .padding(horizontal = 16.dp),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ },
+ ) {
+ PostTopicUiModel.entries.forEachIndexed { index, postTopic ->
+ val color = if (currentTopic == postTopic) WithpeaceTheme.colors.MainPurple
+ else WithpeaceTheme.colors.SystemGray2
+ Tab(
+ selected = postTopic == currentTopic,
+ onClick = { onClick(postTopic) },
+ text = {
+ Text(
+ text = stringResource(id = postTopic.textResId),
+ color = color,
+ )
+ },
+ icon = {
+ Icon(
+ modifier = Modifier.size(28.dp),
+ painter = painterResource(id = postTopic.iconResId),
+ contentDescription = stringResource(id = postTopic.textResId),
+ tint = color,
+ )
+ },
+ )
+ }
+ }
+}
+
diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt
new file mode 100644
index 00000000..792ce0ed
--- /dev/null
+++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt
@@ -0,0 +1,26 @@
+package com.withpeace.withpeace.feature.postlist.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.feature.postlist.PostListRoute
+
+const val POST_LIST_ROUTE = "post_list_rotue"
+
+fun NavController.navigateToPostList(navOptions: NavOptions? = null) =
+ navigate(POST_LIST_ROUTE, navOptions)
+
+fun NavGraphBuilder.postListGraph(
+ onShowSnackBar: (String) -> Unit,
+ navigateToPostDetail: (postId: Long) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ composable(POST_LIST_ROUTE) {
+ PostListRoute(
+ onShowSnackBar = onShowSnackBar,
+ navigateToDetail = navigateToPostDetail,
+ onAuthExpired = onAuthExpired
+ )
+ }
+}
diff --git a/feature/postlist/src/test/java/com/withpeace/withpeace/feature/postlist/PostListViewModelTest.kt b/feature/postlist/src/test/java/com/withpeace/withpeace/feature/postlist/PostListViewModelTest.kt
new file mode 100644
index 00000000..eba5a6ed
--- /dev/null
+++ b/feature/postlist/src/test/java/com/withpeace/withpeace/feature/postlist/PostListViewModelTest.kt
@@ -0,0 +1,103 @@
+package com.withpeace.withpeace.feature.postlist
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.testing.asPagingSourceFactory
+import androidx.paging.testing.asSnapshot
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.date.Date
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.post.Post
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.usecase.GetPostsUseCase
+import com.withpeace.withpeace.core.testing.MainDispatcherRule
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.toPostUiModel
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import java.time.LocalDateTime
+
+class PostListViewModelTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private lateinit var postListViewModel: PostListViewModel
+ private val getPostsUseCase = mockk(relaxed = true)
+
+ @Test
+ fun `주제를 바꿀 수 있다`() = runTest {
+ // given
+ postListViewModel = PostListViewModel(getPostsUseCase)
+ // when
+ postListViewModel.onTopicChanged(PostTopicUiModel.ECONOMY)
+ // then
+ val actual = postListViewModel.currentTopic.value
+ assertThat(actual).isEqualTo(PostTopicUiModel.ECONOMY)
+ }
+
+ @Test
+ fun `게시글 리스트를 가져올 수 있다`() = runTest {
+ // given
+ val testPosts = List(100) {
+ Post(
+ postId = it.toLong(),
+ title = "",
+ content = "",
+ postTopic = PostTopic.FREEDOM,
+ createDate = Date(date = LocalDateTime.MIN),
+ postImageUrl = null,
+ )
+ }
+ coEvery {
+ getPostsUseCase(
+ postTopic = PostTopic.FREEDOM,
+ pageSize = any(),
+ onError = any(),
+ )
+ } returns Pager(
+ config = PagingConfig(7),
+ pagingSourceFactory = testPosts.asPagingSourceFactory(),
+ ).flow
+ // when && then
+ postListViewModel = PostListViewModel(getPostsUseCase)
+ val actual = postListViewModel.postListPagingFlow.getFullScrollItems()
+ assertThat(actual).isEqualTo(testPosts.map { it.toPostUiModel() })
+ }
+
+ @Test
+ fun `게시글 목록을 가져올 때, 네트워크에 문제가 있다면 네트워크 에러 이벤트가 발생한다`() = runTest {
+ // given
+ val errorSlot = slot Unit>()
+ coEvery {
+ getPostsUseCase(
+ postTopic = any(),
+ pageSize = any(),
+ onError = capture(errorSlot),
+ )
+ } returns flow> {
+ errorSlot.captured.invoke(ResponseError.INVALID_TOKEN_ERROR)
+ }.catch { }
+ // when
+ postListViewModel = PostListViewModel(getPostsUseCase)
+ // then
+ postListViewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(PostListUiEvent.NetworkError)
+ }
+ }
+
+ private suspend fun Flow>.getFullScrollItems() =
+ asSnapshot { appendScrollWhile { true } }
+
+}
diff --git a/feature/profileeditor/.gitignore b/feature/profileeditor/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/profileeditor/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/profileeditor/build.gradle.kts b/feature/profileeditor/build.gradle.kts
new file mode 100644
index 00000000..914a6446
--- /dev/null
+++ b/feature/profileeditor/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.profileeditor"
+}
+
+dependencies {
+
+ implementation(project(":core:ui"))
+}
\ No newline at end of file
diff --git a/feature/profileeditor/consumer-rules.pro b/feature/profileeditor/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/profileeditor/proguard-rules.pro b/feature/profileeditor/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/profileeditor/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/profileeditor/src/androidTest/java/com/app/profileeditor/ExampleInstrumentedTest.kt b/feature/profileeditor/src/androidTest/java/com/app/profileeditor/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..01c6fd57
--- /dev/null
+++ b/feature/profileeditor/src/androidTest/java/com/app/profileeditor/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.app.profileeditor
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.app.profileeditor.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/AndroidManifest.xml b/feature/profileeditor/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..a5918e68
--- /dev/null
+++ b/feature/profileeditor/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt
new file mode 100644
index 00000000..4c2accbf
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorScreen.kt
@@ -0,0 +1,318 @@
+package com.app.profileeditor
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Arrangement
+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.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.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.app.profileeditor.uistate.ProfileEditUiEvent
+import com.app.profileeditor.uistate.ProfileUiModel
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceBackButtonTopAppBar
+import com.withpeace.withpeace.core.ui.profile.NickNameEditor
+import com.withpeace.withpeace.core.ui.profile.ProfileImageEditor
+import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState
+import com.withpeace.withpeace.feature.profileeditor.R
+
+@Composable
+fun ProfileEditorRoute(
+ onShowSnackBar: (message: String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onNavigateToGallery: () -> Unit,
+ viewModel: ProfileEditorViewModel,
+ onUpdateSuccess: (nickname: String, imageUrl: String) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ var showAlertDialog by remember { mutableStateOf(false) }
+ val profileInfo: ProfileUiModel by viewModel.profileEditUiState.collectAsStateWithLifecycle()
+ BackHandler {
+ if (profileInfo.isChanged) {
+ showAlertDialog = true
+ }
+ }
+ if (showAlertDialog) {
+ ModifySaveDialog(
+ onClickSave = {
+ showAlertDialog = false
+ viewModel.updateProfile()
+ },
+ onClickExit = {
+ showAlertDialog = false
+ onClickBackButton()
+ },
+ onModifyDismissRequest = {
+ showAlertDialog = false
+ },
+ )
+ }
+
+ LaunchedEffect(viewModel.profileEditUiEvent) {
+ viewModel.profileEditUiEvent.collect {
+ when (it) {
+ ProfileEditUiEvent.NicknameDuplicated -> onShowSnackBar("중복된 닉네임입니다.")
+
+ ProfileEditUiEvent.NicknameInvalidFormat -> onShowSnackBar("닉네임 형식이 올바르지 않습니다.")
+
+ ProfileEditUiEvent.UpdateFailure -> onShowSnackBar("서버와 통신 중 오류가 발생했습니다.")
+
+ ProfileEditUiEvent.ProfileUnchanged -> onShowSnackBar("수정사항이 없습니다.")
+
+ is ProfileEditUiEvent.UpdateSuccess -> {
+ onShowSnackBar("변경되었습니다.")
+ onUpdateSuccess(it.nickname, it.imageUrl)
+ }
+
+ ProfileEditUiEvent.UnAuthorized -> onAuthExpired()
+
+ }
+ }
+ }
+
+
+ ProfileEditorScreen(
+ profileInfo = profileInfo,
+ onClickBackButton = {
+ if (profileInfo.isChanged) {
+ showAlertDialog = true
+ } else {
+ onClickBackButton()
+ }
+ },
+ onNavigateToGallery = onNavigateToGallery,
+ onEditCompleted = {
+ viewModel.updateProfile()
+ },
+ onNickNameChanged = viewModel::onNickNameChanged,
+ onKeyBoardTimerEnd = {
+ viewModel.verifyNickname()
+ },
+ nicknameValidStatus = viewModel.profileNicknameValidUiState.collectAsStateWithLifecycle().value,
+ isChanged = profileInfo.isChanged,
+ )
+
+}
+
+@Composable
+fun ProfileEditorScreen(
+ profileInfo: ProfileUiModel,
+ isChanged: Boolean,
+ modifier: Modifier = Modifier,
+ onClickBackButton: () -> Unit,
+ onNavigateToGallery: () -> Unit,
+ onEditCompleted: () -> Unit,
+ onNickNameChanged: (String) -> Unit,
+ onKeyBoardTimerEnd: () -> Unit,
+ nicknameValidStatus: ProfileNicknameValidUiState,
+) {
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(top = 0.dp),
+ ) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ WithPeaceBackButtonTopAppBar(
+ modifier = modifier,
+ onClickBackButton = { onClickBackButton() },
+ title = {
+ Text(
+ text = stringResource(R.string.edit_profile),
+ style = WithpeaceTheme.typography.title1,
+ color = WithpeaceTheme.colors.SystemBlack,
+ )
+ },
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ ProfileImageEditor(
+ profileImage = profileInfo.profileImage,
+ modifier = modifier,
+ onNavigateToGallery = onNavigateToGallery,
+ contentDescription = stringResource(id = R.string.edit_profile),
+ )
+ Spacer(modifier = modifier.height(24.dp))
+ Text(
+ text = stringResource(com.withpeace.withpeace.core.ui.R.string.nickname_policy),
+ style = WithpeaceTheme.typography.caption,
+ color = WithpeaceTheme.colors.SystemGray1,
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ NickNameEditor(
+ nickname = profileInfo.nickname,
+ onNickNameChanged = {
+ onNickNameChanged(it)
+ },
+ onKeyBoardTimerEnd = onKeyBoardTimerEnd,
+ nicknameValidStatus = nicknameValidStatus,
+ isChanged = isChanged,
+ )
+
+ }
+ EditCompletedButton(onClick = { onEditCompleted() },isChanged = isChanged, nicknameValidUiState = nicknameValidStatus)
+ }
+}
+
+@Composable
+private fun EditCompletedButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ isChanged: Boolean,
+ nicknameValidUiState: ProfileNicknameValidUiState,
+) {
+ val isClickable =
+ isChanged and (nicknameValidUiState is ProfileNicknameValidUiState.Valid)
+ Button(
+ onClick = {
+ if (isClickable) {
+ onClick()
+ }
+ },
+ contentPadding = PaddingValues(0.dp),
+ modifier = modifier
+ .padding(
+ bottom = 40.dp,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ )
+ .fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = if (isClickable) WithpeaceTheme.colors.MainPurple else WithpeaceTheme.colors.SystemGray2),
+ shape = RoundedCornerShape(9.dp),
+ ) {
+ Text(
+ style = WithpeaceTheme.typography.body,
+ text = stringResource(R.string.edit_completed),
+ modifier = Modifier.padding(vertical = 18.dp),
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+}
+
+@Composable
+fun ModifySaveDialog(
+ modifier: Modifier = Modifier,
+ onModifyDismissRequest: () -> Unit,
+ onClickExit: () -> Unit,
+ onClickSave: () -> Unit,
+) {
+ Dialog(onDismissRequest = { onModifyDismissRequest() }) {
+ Surface(
+ modifier = modifier
+ .width(327.dp)
+ .clip(RoundedCornerShape(10.dp)),
+ ) {
+ ModifySaveDialogContent(
+ modifier = modifier,
+ onClickExit = onClickExit,
+ onClickSave = onClickSave,
+ )
+ }
+ }
+}
+
+@Composable
+fun ModifySaveDialogContent(
+ modifier: Modifier,
+ onClickExit: () -> Unit,
+ onClickSave: () -> Unit,
+) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.modify_save_request),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemGray1,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = modifier.height(16.dp))
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ TextButton(
+ modifier = modifier
+ .width(136.dp)
+ .border(
+ width = 1.dp,
+ shape = RoundedCornerShape(10.dp),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ .background(WithpeaceTheme.colors.SystemWhite),
+ onClick = { onClickExit() },
+ content = {
+ Text(
+ text = stringResource(R.string.dialog_exit),
+ color = WithpeaceTheme.colors.MainPurple,
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ TextButton(
+ modifier = modifier
+ .width(136.dp)
+ .background(WithpeaceTheme.colors.MainPurple, shape = RoundedCornerShape(10.dp)),
+ onClick = { onClickSave() },
+ content = {
+ Text(
+ text = stringResource(R.string.dialog_save),
+ color = WithpeaceTheme.colors.SystemWhite,
+ style = WithpeaceTheme.typography.caption,
+ )
+ },
+ )
+ }
+ Spacer(modifier = modifier.height(24.dp))
+ }
+}
+
+@Composable
+@Preview
+fun ProfileEditorPreview() {
+ WithpeaceTheme {
+ ProfileEditorScreen(
+ profileInfo = ProfileUiModel("nickname", "", false),
+ onClickBackButton = {},
+ onNavigateToGallery = {},
+ onEditCompleted = {},
+ onNickNameChanged = {},
+ onKeyBoardTimerEnd = {},
+ nicknameValidStatus = ProfileNicknameValidUiState.Valid,
+ isChanged = false
+ )
+ }
+}
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt
new file mode 100644
index 00000000..f758f45a
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileEditorViewModel.kt
@@ -0,0 +1,114 @@
+package com.app.profileeditor
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.app.profileeditor.navigation.PROFILE_IMAGE_URL_ARGUMENT
+import com.app.profileeditor.navigation.PROFILE_NICKNAME_ARGUMENT
+import com.app.profileeditor.uistate.ProfileEditUiEvent
+import com.app.profileeditor.uistate.ProfileUiModel
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.usecase.UpdateProfileUseCase
+import com.withpeace.withpeace.core.domain.usecase.VerifyNicknameUseCase
+import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import toDomain
+import toUiModel
+import javax.inject.Inject
+
+@HiltViewModel
+class ProfileEditorViewModel @Inject constructor(
+ savedStateHandle: SavedStateHandle,
+ private val verifyNicknameUseCase: VerifyNicknameUseCase,
+ private val updateProfileUseCase: UpdateProfileUseCase,
+) : ViewModel() {
+ private val baseProfileInfo = ProfileUiModel(
+ nickname = savedStateHandle.get(PROFILE_NICKNAME_ARGUMENT) ?: "",
+ profileImage = savedStateHandle.get(PROFILE_IMAGE_URL_ARGUMENT) ?: "default.png",
+ isChanged = false,
+ ) // 최초 정보에서 변경사항이 있는지 비교를 위한 필드
+
+ private val _profileEditUiState =
+ MutableStateFlow(
+ ProfileUiModel(baseProfileInfo.nickname, baseProfileInfo.profileImage, false),
+ )
+ val profileEditUiState = _profileEditUiState.asStateFlow()
+
+ private val _profileEditUiEvent = Channel()
+ val profileEditUiEvent = _profileEditUiEvent.receiveAsFlow()
+
+ private val _profileNicknameValidUiState =
+ MutableStateFlow(ProfileNicknameValidUiState.Valid)
+ val profileNicknameValidUiState = _profileNicknameValidUiState.asStateFlow()
+
+ fun onImageChanged(imageUri: String) {
+ _profileEditUiState.update {
+ return@update it.toDomain().copy(profileImage = imageUri).toUiModel(baseProfileInfo)
+ }
+ }
+
+ fun onNickNameChanged(nickname: String) {
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.UnVerified }
+ _profileEditUiState.update {
+ return@update it.toDomain().copy(nickname = nickname).toUiModel(baseProfileInfo)
+ }
+ }
+
+ fun verifyNickname() { // 닉네임만 바뀐 경우, 기본 값이 아닌 경우
+ if (_profileEditUiState.value.nickname == baseProfileInfo.nickname) {
+ return _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid }
+ }
+ viewModelScope.launch {
+ verifyNicknameUseCase(
+ onError = { error ->
+ when (error) {
+ ClientError.NicknameError.FormatInvalid ->
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidFormat }
+
+ ClientError.NicknameError.Duplicated ->
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidDuplicated }
+
+ else -> _profileEditUiEvent.send(ProfileEditUiEvent.UpdateFailure)
+ }
+ },
+ nickname = _profileEditUiState.value.nickname,
+ ).collect {
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid }
+ }
+ }
+ }
+
+ fun updateProfile() {
+ viewModelScope.launch {
+ updateProfileUseCase(
+ beforeProfile = baseProfileInfo.toDomain(),
+ afterProfile = _profileEditUiState.value.toDomain(),
+ onError = {
+ _profileEditUiEvent.send(
+ when (it) {
+ ResponseError.INVALID_ARGUMENT -> ProfileEditUiEvent.NicknameInvalidFormat
+ ResponseError.DUPLICATE_RESOURCE -> ProfileEditUiEvent.NicknameDuplicated
+ ClientError.ProfileNotChanged -> ProfileEditUiEvent.ProfileUnchanged
+ ClientError.AuthExpired -> ProfileEditUiEvent.UnAuthorized
+ else -> ProfileEditUiEvent.UpdateFailure
+ },
+ )
+ },
+ ).collect {
+ _profileEditUiEvent.send(
+ ProfileEditUiEvent.UpdateSuccess(
+ it.nickname?.value ?: _profileEditUiState.value.nickname,
+ it.profileImageUrl ?: _profileEditUiState.value.profileImage,
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileModelMapper.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileModelMapper.kt
new file mode 100644
index 00000000..b647ed7c
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/ProfileModelMapper.kt
@@ -0,0 +1,18 @@
+import com.app.profileeditor.uistate.ProfileUiModel
+import com.withpeace.withpeace.core.domain.model.profile.ChangingProfileInfo
+import com.withpeace.withpeace.core.domain.model.profile.ProfileChangingStatus
+
+internal fun ProfileUiModel.toDomain(): ChangingProfileInfo {
+ return ChangingProfileInfo(nickname, profileImage)
+}
+
+internal fun ChangingProfileInfo.toUiModel(baseProfile: ProfileUiModel): ProfileUiModel {
+ return ProfileUiModel(
+ nickname,
+ profileImage,
+ isChanged = when (getChangingStatus(baseProfile.toDomain())) {
+ ProfileChangingStatus.Same -> false
+ else -> true
+ },
+ )
+}
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/navigation/ProfileEditorNavigation.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/navigation/ProfileEditorNavigation.kt
new file mode 100644
index 00000000..0e683b7b
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/navigation/ProfileEditorNavigation.kt
@@ -0,0 +1,60 @@
+package com.app.profileeditor.navigation
+
+import androidx.hilt.navigation.compose.hiltViewModel
+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.app.profileeditor.ProfileEditorRoute
+import com.app.profileeditor.ProfileEditorViewModel
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+const val PROFILE_EDITOR_ROUTE = "profileEditorRoute"
+const val IMAGE_LIST_ARGUMENT = "image_list_argument"
+const val PROFILE_NICKNAME_ARGUMENT = "nickname_argument"
+const val PROFILE_IMAGE_URL_ARGUMENT = "profile_image_url_argument"
+const val PROFILE_EDITOR_ROUTE_WITH_ARGUMENT =
+ "$PROFILE_EDITOR_ROUTE/{$PROFILE_NICKNAME_ARGUMENT}/{$PROFILE_IMAGE_URL_ARGUMENT}"
+
+fun NavController.navigateProfileEditor(
+ navOptions: NavOptions? = null,
+ nickname: String?,
+ profileImageUrl: String?,
+) {
+ val encodedUrl = URLEncoder.encode(profileImageUrl, StandardCharsets.UTF_8.toString())
+ navigate("$PROFILE_EDITOR_ROUTE/${nickname ?: ""}/${encodedUrl ?: ""}", navOptions)
+}
+
+fun NavGraphBuilder.profileEditorNavGraph(
+ onShowSnackBar: (message: String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onNavigateToGallery: () -> Unit,
+ onUpdateSuccess: (nickname: String, imageUrl: String) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ composable(
+ route = PROFILE_EDITOR_ROUTE_WITH_ARGUMENT,
+ arguments = listOf(
+ navArgument(PROFILE_NICKNAME_ARGUMENT) { type = NavType.StringType },
+ navArgument(PROFILE_IMAGE_URL_ARGUMENT) { type = NavType.StringType },
+ ),
+ ) { entry ->
+ val selectedImageUri =
+ entry.savedStateHandle.get>(IMAGE_LIST_ARGUMENT) ?: emptyList()
+ val viewModel: ProfileEditorViewModel = hiltViewModel()
+ if (selectedImageUri.isNotEmpty()) {
+ viewModel.onImageChanged(imageUri = selectedImageUri.firstOrNull() ?: "default.png")
+ }
+ ProfileEditorRoute(
+ onShowSnackBar = onShowSnackBar,
+ onClickBackButton = onClickBackButton,
+ onNavigateToGallery = onNavigateToGallery,
+ viewModel = viewModel,
+ onUpdateSuccess = onUpdateSuccess,
+ onAuthExpired = onAuthExpired
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileEditUiEvent.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileEditUiEvent.kt
new file mode 100644
index 00000000..e9b3cbad
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileEditUiEvent.kt
@@ -0,0 +1,10 @@
+package com.app.profileeditor.uistate
+
+sealed interface ProfileEditUiEvent {
+ data object NicknameDuplicated : ProfileEditUiEvent
+ data object NicknameInvalidFormat : ProfileEditUiEvent
+ data class UpdateSuccess(val nickname: String, val imageUrl: String) : ProfileEditUiEvent
+ data object UpdateFailure : ProfileEditUiEvent
+ data object ProfileUnchanged : ProfileEditUiEvent
+ data object UnAuthorized : ProfileEditUiEvent
+}
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileUiModel.kt b/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileUiModel.kt
new file mode 100644
index 00000000..ec65414b
--- /dev/null
+++ b/feature/profileeditor/src/main/java/com/app/profileeditor/uistate/ProfileUiModel.kt
@@ -0,0 +1,7 @@
+package com.app.profileeditor.uistate
+
+data class ProfileUiModel(
+ val nickname: String,
+ val profileImage: String,
+ val isChanged: Boolean,
+)
\ No newline at end of file
diff --git a/feature/profileeditor/src/main/res/values/strings.xml b/feature/profileeditor/src/main/res/values/strings.xml
new file mode 100644
index 00000000..3d3014d4
--- /dev/null
+++ b/feature/profileeditor/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+
+ 프로필 수정
+ 수정 완료
+ 수정사항이 있습니다.\n저장하시겠습니까?
+ 나가기
+ 저장하기
+
\ No newline at end of file
diff --git a/feature/registerpost/.gitignore b/feature/registerpost/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/registerpost/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/registerpost/build.gradle.kts b/feature/registerpost/build.gradle.kts
new file mode 100644
index 00000000..57d472a7
--- /dev/null
+++ b/feature/registerpost/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.registerpost"
+}
+
+dependencies{
+ implementation(project(":core:permission"))
+ implementation(libs.skydoves.landscapist.glide)
+ implementation(libs.skydoves.landscapist.bom)
+}
diff --git a/feature/registerpost/consumer-rules.pro b/feature/registerpost/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/registerpost/proguard-rules.pro b/feature/registerpost/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/registerpost/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/registerpost/src/main/AndroidManifest.xml b/feature/registerpost/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8bdb7e14
--- /dev/null
+++ b/feature/registerpost/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostScreen.kt b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostScreen.kt
new file mode 100644
index 00000000..db6b9ab7
--- /dev/null
+++ b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostScreen.kt
@@ -0,0 +1,607 @@
+package com.withpeace.withpeace.feature.registerpost
+
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.animateScrollBy
+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.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+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.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+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.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.VisualTransformation
+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.skydoves.landscapist.glide.GlideImage
+import com.withpeace.withpeace.core.designsystem.R
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.designsystem.ui.KeyboardAware
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceBackButtonTopAppBar
+import com.withpeace.withpeace.core.designsystem.ui.WithPeaceCompleteButton
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.permission.ImagePermissionHelper
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import com.withpeace.withpeace.feature.registerpost.R.drawable
+import com.withpeace.withpeace.feature.registerpost.R.string
+import kotlinx.coroutines.launch
+
+@Composable
+fun RegisterPostRoute(
+ viewModel: RegisterPostViewModel = hiltViewModel(),
+ onShowSnackBar: (String) -> Unit,
+ onClickedBackButton: () -> Unit,
+ onCompleteRegisterPost: (postId: Long) -> Unit,
+ onNavigateToGallery: (imageLimit: Int, imageCount: Int) -> Unit,
+ onAuthExpired: () -> Unit,
+) {
+ val context = LocalContext.current
+
+ val postUiState = viewModel.registerPostUiModel.collectAsStateWithLifecycle().value
+ val showBottomSheet = viewModel.showBottomSheet.collectAsStateWithLifecycle().value
+ val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
+
+ KeyboardAware {
+ RegisterPostScreen(
+ registerPostUiState = postUiState,
+ onClickBackButton = onClickedBackButton,
+ onTitleChanged = viewModel::onTitleChanged,
+ onContentChanged = viewModel::onContentChanged,
+ onTopicChanged = viewModel::onTopicChanged,
+ onCompleteRegisterPost = viewModel::onRegisterPostCompleted,
+ onShowBottomSheetChanged = viewModel::onShowBottomSheetChanged,
+ showBottomSheet = showBottomSheet,
+ onImageUrlDeleted = viewModel::onImageUrlDeleted,
+ onNavigateToGallery = onNavigateToGallery,
+ isLoading = isLoading,
+ )
+ }
+ LaunchedEffect(null) {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ RegisterPostUiEvent.ContentBlank -> onShowSnackBar(context.getString(string.content_blank_error_message))
+ is RegisterPostUiEvent.RegisterSuccess -> {
+ onCompleteRegisterPost(event.postId)
+ }
+
+ is RegisterPostUiEvent.RegisterFail -> {
+ when (event.error) {
+ is ClientError.AuthExpired -> onAuthExpired()
+ else -> onShowSnackBar("서버와의 통신 중 오류가 발생했습니다.")
+ }
+ }
+
+ RegisterPostUiEvent.TitleBlank -> onShowSnackBar(context.getString(string.title_blank_error_message))
+ RegisterPostUiEvent.TopicBlank -> viewModel.onShowBottomSheetChanged(true)
+ }
+ }
+ }
+}
+
+@Composable
+fun RegisterPostScreen(
+ registerPostUiState: RegisterPostUiModel,
+ onClickBackButton: () -> Unit = {},
+ onTitleChanged: (String) -> Unit = {},
+ onContentChanged: (String) -> Unit = {},
+ onTopicChanged: (PostTopicUiModel) -> Unit = {},
+ onCompleteRegisterPost: () -> Unit,
+ onImageUrlDeleted: (Int) -> Unit,
+ onShowBottomSheetChanged: (Boolean) -> Unit = {},
+ showBottomSheet: Boolean,
+ onNavigateToGallery: (imageLimit: Int, imageCount: Int) -> Unit = { _, _ -> },
+ isLoading: Boolean,
+) {
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(WithpeaceTheme.colors.SystemWhite),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .weight(1f),
+ ) {
+ RegisterPostTopAppBar(
+ onClickBackButton = onClickBackButton,
+ onCompleteRegisterPost = onCompleteRegisterPost,
+ isLoading = isLoading,
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .verticalScroll(scrollState),
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(modifier = Modifier.fillMaxHeight()) {
+ RegisterPostTopic(
+ topic = registerPostUiState.topic,
+ onTopicChanged = onTopicChanged,
+ onShowBottomSheetChanged = onShowBottomSheetChanged,
+ showBottomSheet = showBottomSheet,
+ )
+ RegisterPostTitle(
+ title = registerPostUiState.title, onTitleChanged = onTitleChanged,
+ )
+ RegisterPostContent(
+ modifier = Modifier.fillMaxHeight(),
+ content = registerPostUiState.content,
+ onContentChanged = onContentChanged,
+ scrollByKeyboardHeight = {
+ scrollState.animateScrollBy(it, spring(dampingRatio = 5f))
+ },
+ )
+ }
+ PostImageList(
+ imageUrls = registerPostUiState.imageUrls,
+ onImageUrlDeleted = onImageUrlDeleted,
+ )
+ }
+ }
+ Column {
+ RegisterPostCamera(
+ onNavigateToGallery = {
+ onNavigateToGallery(
+ RegisterPostViewModel.IMAGE_MAX_SIZE,
+ registerPostUiState.imageUrls.size,
+ )
+ },
+ )
+ }
+ }
+ if (isLoading) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = WithpeaceTheme.colors.MainPurple,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun PostImageList(
+ modifier: Modifier = Modifier,
+ imageUrls: List,
+ onImageUrlDeleted: (Int) -> Unit,
+) {
+ LazyRow(
+ modifier = modifier,
+ contentPadding = PaddingValues(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ ) {
+ itemsIndexed(
+ items = imageUrls,
+ ) { index, imageUrl ->
+ Box(
+ modifier = Modifier
+ .size(118.dp)
+ .animateItemPlacement(),
+ ) {
+ GlideImage(
+ modifier = Modifier.align(Alignment.BottomStart)
+ .size(110.dp).clip(RoundedCornerShape(10.dp)),
+ imageModel = { imageUrl },
+ previewPlaceholder = drawable.ic_camera,
+ )
+ Image(
+ modifier = Modifier
+ .clickable {
+ onImageUrlDeleted(index)
+ }
+ .align(Alignment.TopEnd),
+ painter = painterResource(id = drawable.btn_picture_delete),
+ contentDescription = "ImageDelete",
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun RegisterPostTopAppBar(
+ modifier: Modifier = Modifier,
+ onClickBackButton: () -> Unit,
+ onCompleteRegisterPost: () -> Unit,
+ isLoading: Boolean,
+) {
+ Column {
+ WithPeaceBackButtonTopAppBar(
+ modifier = modifier.padding(end = 24.dp),
+ onClickBackButton = onClickBackButton,
+ title = {
+ Text(
+ text = stringResource(string.register_post_topbar_title),
+ style = WithpeaceTheme.typography.title1,
+ )
+ },
+ actions = {
+ WithPeaceCompleteButton(
+ onClick = onCompleteRegisterPost,
+ enabled = !isLoading,
+ )
+ },
+ )
+ Divider(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .fillMaxWidth()
+ .height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RegisterPostTopic(
+ modifier: Modifier = Modifier,
+ topic: PostTopicUiModel?,
+ onTopicChanged: (PostTopicUiModel) -> Unit,
+ onShowBottomSheetChanged: (Boolean) -> Unit,
+ showBottomSheet: Boolean,
+) {
+ val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ Column(
+ modifier = modifier.clickable { onShowBottomSheetChanged(true) },
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = WithpeaceTheme.padding.BasicHorizontalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = if (topic == null) {
+ stringResource(id = string.topic_hint)
+ } else {
+ stringResource(
+ id = topic.textResId,
+ )
+ },
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 16.dp),
+ style = WithpeaceTheme.typography.body,
+ )
+ Icon(
+ modifier = Modifier,
+ painter = painterResource(id = R.drawable.ic_backarrow_right),
+ contentDescription = "TopicArrow",
+ )
+ }
+ Divider(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .fillMaxWidth()
+ .height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ }
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ dragHandle = {},
+ containerColor = WithpeaceTheme.colors.SystemWhite,
+ sheetState = bottomSheetState,
+ onDismissRequest = { onShowBottomSheetChanged(false) },
+ windowInsets = WindowInsets(
+ bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(),
+ ),
+ shape = RoundedCornerShape(topStart = 20.dp,topEnd = 20.dp),
+ ) {
+ TopicBottomSheetContent(
+ currentTopic = topic,
+ onClickTopic = {
+ onTopicChanged(it)
+ onShowBottomSheetChanged(false)
+ },
+ )
+ }
+ }
+}
+
+@Composable
+fun TopicBottomSheetContent(
+ currentTopic: PostTopicUiModel?,
+ onClickTopic: (PostTopicUiModel) -> Unit,
+) {
+ Column {
+ Text(
+ modifier = Modifier.padding(start = 24.dp, top = 24.dp),
+ text = stringResource(string.topic_hint),
+ style = WithpeaceTheme.typography.title1,
+ )
+ LazyVerticalGrid(
+ contentPadding = PaddingValues(top = 40.dp, bottom = 40.dp, start = 24.dp, end = 24.dp),
+ columns = GridCells.Fixed(3),
+ horizontalArrangement = Arrangement.spacedBy(17.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ items(
+ items = PostTopicUiModel.entries,
+ ) { topicUiState ->
+ val color = if (currentTopic == topicUiState) {
+ WithpeaceTheme.colors.MainPurple
+ } else {
+ WithpeaceTheme.colors.SystemGray2
+ }
+ Column(
+ modifier = Modifier.size(93.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable {
+ onClickTopic(topicUiState)
+ }
+ .padding(vertical = 12.dp, horizontal = 24.dp)
+ .weight(1f),
+ painter = painterResource(topicUiState.iconResId),
+ contentDescription = topicUiState.iconResId.toString(),
+ tint = color,
+ )
+ Text(
+ text = stringResource(id = topicUiState.textResId),
+ style = WithpeaceTheme.typography.caption,
+ color = color,
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RegisterPostTitle(
+ modifier: Modifier = Modifier,
+ title: String,
+ onTitleChanged: (String) -> Unit,
+) {
+
+ val interactionSource = remember { MutableInteractionSource() }
+ Column(
+ modifier = modifier.padding(
+ horizontal = WithpeaceTheme.padding.BasicHorizontalPadding,
+ ),
+ ) {
+ BasicTextField(
+ modifier = Modifier
+ .padding(vertical = 16.dp)
+ .fillMaxWidth(),
+ value = title,
+ onValueChange = onTitleChanged,
+ enabled = true,
+ textStyle = WithpeaceTheme.typography.title1,
+ singleLine = true,
+ maxLines = 2,
+ minLines = 1,
+ ) {
+ TextFieldDefaults.DecorationBox(
+ value = title,
+ innerTextField = it,
+ enabled = true,
+ singleLine = false,
+ visualTransformation = VisualTransformation.None,
+ placeholder = {
+ Text(
+ text = stringResource(string.title_hint),
+ style = WithpeaceTheme.typography.title2,
+ color = WithpeaceTheme.colors.SystemGray2,
+ )
+ },
+ interactionSource = interactionSource,
+ contentPadding = PaddingValues(0.dp),
+ colors = TextFieldDefaults.colors(
+ disabledTextColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedContainerColor = Color.Transparent,
+ ),
+ )
+ }
+ Divider(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RegisterPostContent(
+ modifier: Modifier = Modifier,
+ content: String,
+ onContentChanged: (String) -> Unit,
+ scrollByKeyboardHeight: suspend (Float) -> Unit,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current)
+ var isContentFocus by remember {
+ mutableStateOf(false)
+ }
+
+ LaunchedEffect(key1 = keyboardHeight, key2 = content.lines().size) {
+ if (content.lines().size >= SCROLL_THRESHOLD_LINE && isContentFocus) {
+ coroutineScope.launch { scrollByKeyboardHeight(keyboardHeight.toFloat()) }
+ }
+ } // 키보드기 올라가거나, Content의 라인이 변할때마다 내용이 키보드 영역에 가려지지 않도록 키보드 영역만큼 스크롤해줌.
+
+ val interactionSource = remember { MutableInteractionSource() }
+ BasicTextField(
+ modifier = modifier
+ .padding(
+ vertical = 16.dp,
+ horizontal = WithpeaceTheme.padding.BasicHorizontalPadding,
+ )
+ .onFocusChanged { isContentFocus = it.isFocused }
+ .fillMaxSize(),
+ value = content,
+ onValueChange = onContentChanged,
+ enabled = true,
+ textStyle = WithpeaceTheme.typography.body,
+ minLines = 10,
+ ) {
+ TextFieldDefaults.DecorationBox(
+ value = content,
+ innerTextField = it,
+ enabled = true,
+ singleLine = false,
+ visualTransformation = VisualTransformation.None,
+ placeholder = {
+ Text(
+ text = stringResource(string.content_hint),
+ style = WithpeaceTheme.typography.body,
+ color = WithpeaceTheme.colors.SystemGray2,
+ )
+ },
+ interactionSource = interactionSource,
+ contentPadding = PaddingValues(0.dp),
+ colors = TextFieldDefaults.colors(
+ disabledTextColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ focusedContainerColor = Color.Transparent,
+ ),
+ )
+ }
+}
+
+@Composable
+fun RegisterPostCamera(
+ modifier: Modifier = Modifier,
+ onNavigateToGallery: () -> Unit,
+) {
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val context = LocalContext.current
+ val imagePermissionHelper = remember { ImagePermissionHelper(context) }
+ val launcher = imagePermissionHelper.getImageLauncher(
+ onPermissionGranted = onNavigateToGallery,
+ onPermissionDenied = { showDialog = true },
+ )
+
+ if (showDialog) {
+ imagePermissionHelper.ImagePermissionDialog { showDialog = false }
+ }
+
+ Column(
+ modifier = Modifier.padding(
+ horizontal = WithpeaceTheme.padding.BasicHorizontalPadding,
+ ),
+ ) {
+ Divider(
+ modifier = Modifier
+ .padding(vertical = 16.dp)
+ .fillMaxWidth()
+ .height(1.dp),
+ color = WithpeaceTheme.colors.SystemGray3,
+ )
+ Row(
+ modifier = modifier.padding(
+ bottom = 16.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ modifier = Modifier
+ .clickable {
+ imagePermissionHelper.onCheckSelfImagePermission(
+ onPermissionGranted = onNavigateToGallery,
+ onPermissionDenied = {
+ imagePermissionHelper.requestPermissionDialog(launcher)
+ },
+ )
+ }
+ .padding(end = 8.dp),
+ painter = painterResource(id = drawable.ic_camera),
+ contentDescription = "CameraIcon",
+ )
+ Text(text = stringResource(string.picture), style = WithpeaceTheme.typography.caption)
+ }
+ }
+}
+
+private const val SCROLL_THRESHOLD_LINE = 5
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+fun RegisterPostScreenPreview() {
+ WithpeaceTheme {
+ RegisterPostScreen(
+ registerPostUiState = RegisterPostUiModel(
+ imageUrls = listOf("", ""),
+ ),
+ onCompleteRegisterPost = {},
+ onImageUrlDeleted = {},
+ onShowBottomSheetChanged = {},
+ showBottomSheet = false,
+ isLoading = false,
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
+@Composable
+private fun TopicBottomSheetContentPreview() {
+ WithpeaceTheme {
+ TopicBottomSheetContent(currentTopic = null) {
+
+ }
+ }
+}
diff --git a/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostUiEvent.kt b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostUiEvent.kt
new file mode 100644
index 00000000..20599541
--- /dev/null
+++ b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostUiEvent.kt
@@ -0,0 +1,11 @@
+package com.withpeace.withpeace.feature.registerpost
+
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+
+sealed interface RegisterPostUiEvent {
+ data object TitleBlank : RegisterPostUiEvent
+ data object ContentBlank : RegisterPostUiEvent
+ data object TopicBlank : RegisterPostUiEvent
+ data class RegisterSuccess(val postId: Long) : RegisterPostUiEvent
+ data class RegisterFail(val error: CheonghaError) : RegisterPostUiEvent
+}
diff --git a/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModel.kt b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModel.kt
new file mode 100644
index 00000000..16eefb6f
--- /dev/null
+++ b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModel.kt
@@ -0,0 +1,116 @@
+package com.withpeace.withpeace.feature.registerpost
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.usecase.RegisterPostUseCase
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import com.withpeace.withpeace.core.ui.post.toDomain
+import com.withpeace.withpeace.core.ui.post.toUi
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class RegisterPostViewModel @Inject constructor(
+ private val registerPostUseCase: RegisterPostUseCase,
+) : ViewModel() {
+
+ private var isUpdate: Boolean = false
+
+ private val registerPost = MutableStateFlow(
+ RegisterPost(
+ id = null,
+ title = "",
+ content = "",
+ topic = null,
+ images = LimitedImages(emptyList()),
+ ),
+ )
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading = _isLoading.asStateFlow()
+
+ val registerPostUiModel = registerPost.map { it.toUi() }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RegisterPostUiModel(),
+ )
+
+ private val _uiEvent = Channel()
+ val uiEvent = _uiEvent.receiveAsFlow()
+
+ private val _showBottomSheet = MutableStateFlow(false)
+ val showBottomSheet = _showBottomSheet.asStateFlow()
+
+ fun onTitleChanged(inputTitle: String) {
+ registerPost.update { it.copy(title = inputTitle) }
+ }
+
+ fun onContentChanged(inputContent: String) {
+ registerPost.update { it.copy(content = inputContent) }
+ }
+
+ fun onTopicChanged(inputTopic: PostTopicUiModel) {
+ registerPost.update { it.copy(topic = inputTopic.toDomain()) }
+ }
+
+ fun onImageUrlsAdded(imageUrls: List) {
+ registerPost.update { it.copy(images = it.images.addImages(imageUrls)) }
+ }
+
+ fun onImageUrlDeleted(index: Int) {
+ registerPost.update { it.copy(images = it.images.deleteImage(index)) }
+ }
+
+ fun onShowBottomSheetChanged(input: Boolean) {
+ _showBottomSheet.update { input }
+ }
+
+ fun onRegisterPostCompleted() {
+ val registerPostValue = registerPost.value
+ viewModelScope.launch {
+ when {
+ registerPostValue.title.isBlank() -> _uiEvent.send(RegisterPostUiEvent.TitleBlank)
+ registerPostValue.content.isBlank() -> _uiEvent.send(RegisterPostUiEvent.ContentBlank)
+ registerPostValue.topic == null -> _uiEvent.send(RegisterPostUiEvent.TopicBlank)
+ else -> registerPostUseCase(post = registerPostValue) {
+ _uiEvent.send(RegisterPostUiEvent.RegisterFail(it))
+ }.onStart {
+ _isLoading.update { true }
+ }.onCompletion {
+ _isLoading.update { false }
+ }.collect { postId ->
+ _uiEvent.send(RegisterPostUiEvent.RegisterSuccess(postId))
+ }
+ }
+ }
+ }
+
+ fun initRegisterPost(originPost: RegisterPostUiModel?) {
+ if (!isUpdate) {
+ originPost?.let { registerPostUiModel ->
+ registerPost.update { registerPostUiModel.toDomain() }
+ }
+ isUpdate = true
+ }
+ }
+
+ companion object {
+ const val IMAGE_MAX_SIZE = 5
+ }
+}
diff --git a/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/navigation/RegisterPostNavigation.kt b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/navigation/RegisterPostNavigation.kt
new file mode 100644
index 00000000..16c11be9
--- /dev/null
+++ b/feature/registerpost/src/main/java/com/withpeace/withpeace/feature/registerpost/navigation/RegisterPostNavigation.kt
@@ -0,0 +1,48 @@
+package com.withpeace.withpeace.feature.registerpost.navigation
+
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import com.withpeace.withpeace.feature.registerpost.RegisterPostRoute
+import com.withpeace.withpeace.feature.registerpost.RegisterPostViewModel
+
+const val REGISTER_POST_ROUTE = "register_post_route"
+const val IMAGE_LIST_ARGUMENT = "image_list_argument"
+const val REGISTER_POST_ARGUMENT = "register_post_argument"
+
+fun NavController.navigateToRegisterPost(navOptions: NavOptions? = null) = navigate(
+ REGISTER_POST_ROUTE, navOptions,
+)
+
+fun NavGraphBuilder.registerPostNavGraph(
+ onShowSnackBar: (String) -> Unit,
+ onClickBackButton: () -> Unit,
+ onCompleteRegisterPost: (postId: Long) -> Unit,
+ onNavigateToGallery: (imageLimit: Int, imageCount: Int) -> Unit,
+ originPost: RegisterPostUiModel?,
+ onAuthExpired: () -> Unit,
+) {
+ composable(route = REGISTER_POST_ROUTE) { entry ->
+ val viewModel: RegisterPostViewModel = hiltViewModel()
+
+ viewModel.initRegisterPost(originPost)
+
+ // 갤러리에서 받아온 이미지를 추가하고, 화면회전에 대처하기 위해 savedStateHandle을 초기화해준다.
+ val selectedImageUrls =
+ entry.savedStateHandle.get>(IMAGE_LIST_ARGUMENT)?: emptyList()
+ viewModel.onImageUrlsAdded(selectedImageUrls)
+ entry.savedStateHandle[IMAGE_LIST_ARGUMENT] = emptyList()
+
+ RegisterPostRoute(
+ viewModel = viewModel,
+ onShowSnackBar = onShowSnackBar,
+ onClickedBackButton = onClickBackButton,
+ onCompleteRegisterPost = onCompleteRegisterPost,
+ onNavigateToGallery = onNavigateToGallery,
+ onAuthExpired = onAuthExpired
+ )
+ }
+}
diff --git a/feature/registerpost/src/main/res/drawable/btn_picture_delete.xml b/feature/registerpost/src/main/res/drawable/btn_picture_delete.xml
new file mode 100644
index 00000000..051ba434
--- /dev/null
+++ b/feature/registerpost/src/main/res/drawable/btn_picture_delete.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/feature/registerpost/src/main/res/drawable/ic_camera.xml b/feature/registerpost/src/main/res/drawable/ic_camera.xml
new file mode 100644
index 00000000..7fe76a29
--- /dev/null
+++ b/feature/registerpost/src/main/res/drawable/ic_camera.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/feature/registerpost/src/main/res/values/strings.xml b/feature/registerpost/src/main/res/values/strings.xml
new file mode 100644
index 00000000..428e2c39
--- /dev/null
+++ b/feature/registerpost/src/main/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+
+ 내용을 입력해 주세요
+ 제목을 입력해 주세요
+ 게시글의 주제를 선택해주세요
+ 글 쓰기
+ 제목을 입력해주세요
+ 내용을 입력해주세요
+ 사진
+
diff --git a/feature/registerpost/src/test/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModelTest.kt b/feature/registerpost/src/test/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModelTest.kt
new file mode 100644
index 00000000..b5a8085e
--- /dev/null
+++ b/feature/registerpost/src/test/java/com/withpeace/withpeace/feature/registerpost/RegisterPostViewModelTest.kt
@@ -0,0 +1,195 @@
+package com.withpeace.withpeace.feature.registerpost
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import com.withpeace.withpeace.core.domain.model.error.CheonghaError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.model.image.LimitedImages
+import com.withpeace.withpeace.core.domain.model.post.PostTopic
+import com.withpeace.withpeace.core.domain.model.post.RegisterPost
+import com.withpeace.withpeace.core.domain.usecase.RegisterPostUseCase
+import com.withpeace.withpeace.core.testing.MainDispatcherRule
+import com.withpeace.withpeace.core.ui.post.PostTopicUiModel
+import com.withpeace.withpeace.core.ui.post.RegisterPostUiModel
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.slot
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class RegisterPostViewModelTest {
+ private lateinit var viewModel: RegisterPostViewModel
+ private val registerPostUseCase: RegisterPostUseCase = mockk(relaxed = true)
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Before
+ fun setup() {
+ viewModel = RegisterPostViewModel(registerPostUseCase)
+ }
+
+ @Test
+ fun `처음 내용은 모두 비어있다`() {
+ // when & then
+ assertThat(viewModel.registerPostUiModel.value).isEqualTo(
+ RegisterPostUiModel(
+ id = null,
+ title = "",
+ content = "",
+ topic = null,
+ imageUrls = emptyList(),
+ ),
+ )
+ }
+
+ @Test
+ fun `제목을 바꿀 수 있다`() = runTest {
+ // when
+ viewModel.onTitleChanged("제목")
+
+ // then
+ viewModel.registerPostUiModel.test {
+ val acutal = awaitItem().title
+ assertThat(acutal).isEqualTo("제목")
+ }
+ }
+
+ @Test
+ fun `내용을 바꿀 수 있다`() = runTest {
+ // when
+ viewModel.onContentChanged("내용")
+ // then
+ viewModel.registerPostUiModel.test {
+ val acutal = awaitItem().content
+ assertThat(acutal).isEqualTo("내용")
+ }
+ }
+
+ @Test
+ fun `주제를 바꿀 수 있다`() = runTest {
+ // when
+ viewModel.onTopicChanged(PostTopicUiModel.FREEDOM)
+ // then
+ viewModel.registerPostUiModel.test {
+ val acutal = awaitItem().topic
+ assertThat(acutal).isEqualTo(PostTopicUiModel.FREEDOM)
+ }
+ }
+
+ @Test
+ fun `이미지 여러개를 추가할 수 있다`() = runTest {
+ // when
+ viewModel.onImageUrlsAdded(listOf("1", "2"))
+ // then
+ viewModel.registerPostUiModel.test {
+ val acutal = awaitItem().imageUrls
+ assertThat(acutal).isEqualTo(listOf("1","2"))
+ }
+ }
+
+ @Test
+ fun `이미지를 삭제할 수 있다`() = runTest {
+ // given
+ viewModel.onImageUrlsAdded(listOf("1,", "2"))
+ // when
+ viewModel.onImageUrlDeleted(0)
+ // then
+ viewModel.registerPostUiModel.test {
+ val acutal = awaitItem().imageUrls
+ assertThat(acutal).isEqualTo(listOf("2"))
+ }
+ }
+
+ @Test
+ fun `제목,내용,주제 모두 비어있을 때, 게시글 등록하면, TitleBlank 이벤트가 발생한다`() = runTest {
+ // when
+ viewModel.onRegisterPostCompleted()
+ // then
+ viewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(RegisterPostUiEvent.TitleBlank)
+ }
+ }
+
+ @Test
+ fun `주제,내용이 비어있을 때, 게시글 등록하면, ContentBlank 이벤트가 발생한다`() = runTest {
+ // given
+ viewModel.onTitleChanged("제목")
+ // when
+ viewModel.onRegisterPostCompleted()
+ // then
+ viewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(RegisterPostUiEvent.ContentBlank)
+ }
+ }
+
+ @Test
+ fun `주제가 비어있을 때, 게시글 등록하면, TopicBlank 이벤트가 발생한다`() = runTest {
+ // given
+ viewModel.onTitleChanged("제목")
+ viewModel.onContentChanged("내용")
+ // when
+ viewModel.onRegisterPostCompleted()
+ // then
+ viewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(RegisterPostUiEvent.TopicBlank)
+ }
+ }
+
+ @Test
+ fun `게시글 내용이 모두 채워졌고, 성공적으로 게시글 등록하면, 등록 성공 이벤트가 발생한다`() = runTest {
+ // given
+ viewModel.onContentChanged("내용")
+ viewModel.onTopicChanged(PostTopicUiModel.FREEDOM)
+ viewModel.onTitleChanged("제목")
+ coEvery {
+ registerPostUseCase(
+ post = RegisterPost(
+ id = null,
+ title = "제목", content = "내용", topic = PostTopic.FREEDOM,
+ images = LimitedImages(
+ urls = listOf(),
+ maxCount = 5,
+ alreadyExistCount = 0,
+ ),
+ ),
+ onError = any(),
+ )
+ } returns flow { emit(1L) }
+ // when
+ viewModel.onRegisterPostCompleted()
+ // then
+ viewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(RegisterPostUiEvent.RegisterSuccess(1L))
+ }
+ }
+
+ @Test
+ fun `게시글 내용이 모두 채워졌고, 게시글 등록에 실패하면, 등록 실패 이벤트가 발생한다`() = runTest {
+ // given
+ viewModel.onContentChanged("내용")
+ viewModel.onTopicChanged(PostTopicUiModel.FREEDOM)
+ viewModel.onTitleChanged("제목")
+ val slot = slot Unit>()
+ coEvery {
+ registerPostUseCase(
+ post = any(),
+ onError = capture(slot),
+ )
+ } returns flow { slot.captured.invoke(ResponseError.UNKNOWN_ERROR) }
+ // when
+ viewModel.onRegisterPostCompleted()
+ // then
+ viewModel.uiEvent.test {
+ val actual = awaitItem()
+ assertThat(actual).isEqualTo(RegisterPostUiEvent.RegisterFail(ResponseError.UNKNOWN_ERROR))
+ }
+ }
+}
diff --git a/feature/signup/.gitignore b/feature/signup/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/feature/signup/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/feature/signup/build.gradle.kts b/feature/signup/build.gradle.kts
new file mode 100644
index 00000000..791fdec8
--- /dev/null
+++ b/feature/signup/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ id("convention.feature")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.feature.signup"
+}
+
+dependencies {
+ implementation(project(":core:permission"))
+ implementation(libs.skydoves.landscapist.bom)
+ implementation(libs.skydoves.landscapist.glide)
+}
\ No newline at end of file
diff --git a/feature/signup/consumer-rules.pro b/feature/signup/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/feature/signup/proguard-rules.pro b/feature/signup/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/feature/signup/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/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt b/feature/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt
new file mode 100644
index 00000000..f9defe41
--- /dev/null
+++ b/feature/signup/src/androidTest/java/com/withpeace/withpeace/feature/signup/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.withpeace.withpeace.feature.signup
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.withpeace.withpeace.feature.signup.test", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt
new file mode 100644
index 00000000..53df4337
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpMapper.kt
@@ -0,0 +1,8 @@
+package com.withpeace.withpeace.feature.signup
+
+import com.withpeace.withpeace.core.domain.model.SignUpInfo
+import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel
+
+fun SignUpUiModel.toDomain(): SignUpInfo {
+ return SignUpInfo(nickname, profileImage)
+}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt
new file mode 100644
index 00000000..f8e63670
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpScreen.kt
@@ -0,0 +1,158 @@
+package com.withpeace.withpeace.feature.signup
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme
+import com.withpeace.withpeace.core.ui.profile.NickNameEditor
+import com.withpeace.withpeace.core.ui.profile.ProfileImageEditor
+import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState
+import com.withpeace.withpeace.feature.signup.uistate.SignUpUiEvent
+import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel
+
+@Composable
+fun SignUpRoute(
+ viewModel: SignUpViewModel,
+ onShowSnackBar: (message: String) -> Unit,
+ onNavigateToGallery: () -> Unit,
+ onSignUpSuccess: () -> Unit,
+) {
+ val signUpInfo = viewModel.signUpUiModel.collectAsStateWithLifecycle()
+ val nicknameValidStatus = viewModel.profileNicknameValidUiState.collectAsStateWithLifecycle()
+ SignUpScreen(
+ isChanged = signUpInfo.value.nickname.isNotEmpty(),
+ signUpInfo = signUpInfo.value,
+ onNavigateToGallery = onNavigateToGallery,
+ onSignUpClick = viewModel::signUp,
+ onNickNameChanged = viewModel::onNickNameChanged,
+ onKeyBoardTimerEnd = viewModel::verifyNickname,
+ nicknameValidStatus = nicknameValidStatus.value,
+ )
+ LaunchedEffect(viewModel.signUpEvent) {
+ viewModel.signUpEvent.collect {
+ when (it) {
+ SignUpUiEvent.NicknameInValid -> onShowSnackBar("닉네임 등록을 완료해주세요")
+ SignUpUiEvent.SignUpFail -> onShowSnackBar("서버와 통신 중 오류가 발생했습니다")
+ SignUpUiEvent.SignUpSuccess -> onSignUpSuccess()
+ SignUpUiEvent.UnAuthorized -> onShowSnackBar("인가 되지 않은 게정이에요")
+ SignUpUiEvent.VerifyFail -> onShowSnackBar("서버와 통신 중 오류가 발생했습니다")
+ }
+ }
+ }
+}
+
+@Composable
+fun SignUpScreen(
+ signUpInfo: SignUpUiModel,
+ isChanged: Boolean,
+ modifier: Modifier = Modifier,
+ onNavigateToGallery: () -> Unit,
+ onSignUpClick: () -> Unit,
+ onNickNameChanged: (String) -> Unit,
+ onKeyBoardTimerEnd: () -> Unit,
+ nicknameValidStatus: ProfileNicknameValidUiState,
+) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = Modifier.height(104.dp))
+ Text(
+ text = stringResource(
+ R.string.create_profile,
+ ),
+ color = WithpeaceTheme.colors.SystemBlack,
+ style = WithpeaceTheme.typography.title1,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ ProfileImageEditor(
+ profileImage = signUpInfo.profileImage,
+ modifier = modifier,
+ onNavigateToGallery = { onNavigateToGallery() },
+ contentDescription = stringResource(R.string.set_up_profile_image),
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.nickname_policy),
+ color = WithpeaceTheme.colors.SystemGray1,
+ style = WithpeaceTheme.typography.caption,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ NickNameEditor(
+ nickname = signUpInfo.nickname,
+ onNickNameChanged = onNickNameChanged,
+ isChanged = isChanged, // nickname.isNotEmpty()
+ modifier = modifier,
+ nicknameValidStatus = nicknameValidStatus,
+ onKeyBoardTimerEnd = onKeyBoardTimerEnd,
+ )
+ Spacer(modifier = Modifier.height(72.dp))
+ }
+ SignUpButton(onClick = onSignUpClick)
+ }
+}
+
+@Composable
+private fun SignUpButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = {
+ onClick()
+ },
+ contentPadding = PaddingValues(0.dp),
+ modifier = modifier
+ .padding(
+ bottom = 40.dp,
+ end = WithpeaceTheme.padding.BasicHorizontalPadding,
+ start = WithpeaceTheme.padding.BasicHorizontalPadding,
+ )
+ .fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = WithpeaceTheme.colors.MainPurple),
+ shape = RoundedCornerShape(9.dp),
+ ) {
+ Text(
+ style = WithpeaceTheme.typography.body,
+ text = stringResource(R.string.sign_up_completed),
+ modifier = Modifier.padding(vertical = 18.dp),
+ color = WithpeaceTheme.colors.SystemWhite,
+ )
+ }
+}
+
+@Preview(widthDp = 400, heightDp = 900, showBackground = true)
+@Composable
+fun SignUpPreview() {
+ SignUpScreen(
+ isChanged = false,
+ onNavigateToGallery = {},
+ onSignUpClick = {},
+ onNickNameChanged = { },
+ onKeyBoardTimerEnd = {},
+ nicknameValidStatus = ProfileNicknameValidUiState.InValidDuplicated,
+ signUpInfo = SignUpUiModel(nickname = "", profileImage = null),
+ )
+}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt
new file mode 100644
index 00000000..0d36db06
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/SignUpViewModel.kt
@@ -0,0 +1,98 @@
+package com.withpeace.withpeace.feature.signup
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.withpeace.withpeace.core.domain.model.error.ClientError
+import com.withpeace.withpeace.core.domain.model.error.ResponseError
+import com.withpeace.withpeace.core.domain.usecase.SignUpUseCase
+import com.withpeace.withpeace.core.domain.usecase.VerifyNicknameUseCase
+import com.withpeace.withpeace.core.ui.profile.ProfileNicknameValidUiState
+import com.withpeace.withpeace.feature.signup.uistate.SignUpUiEvent
+import com.withpeace.withpeace.feature.signup.uistate.SignUpUiModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SignUpViewModel @Inject constructor(
+ private val verifyNicknameUseCase: VerifyNicknameUseCase,
+ private val signUpUseCase: SignUpUseCase,
+) : ViewModel() {
+ private val _signUpInfo = MutableStateFlow(
+ SignUpUiModel(
+ "", null,
+ ),
+ )
+ val signUpUiModel = _signUpInfo.asStateFlow()
+
+ private val _profileNicknameValidUiState =
+ MutableStateFlow(ProfileNicknameValidUiState.Valid)
+ val profileNicknameValidUiState = _profileNicknameValidUiState.asStateFlow()
+
+ private val _signUpEvent = Channel()
+ val signUpEvent = _signUpEvent.receiveAsFlow()
+
+ fun onNickNameChanged(nickname: String) {
+ _signUpInfo.update { it.copy(nickname = nickname) }
+ }
+
+ fun verifyNickname() {
+ viewModelScope.launch {
+ if (_signUpInfo.value.nickname.isEmpty()) {
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid }
+ return@launch
+ }
+ verifyNicknameUseCase(
+ nickname = _signUpInfo.value.nickname,
+ onError = { error ->
+ when (error) {
+ ClientError.NicknameError.FormatInvalid ->
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidFormat }
+
+ ClientError.NicknameError.Duplicated ->
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.InValidDuplicated }
+
+ else -> _signUpEvent.send(SignUpUiEvent.VerifyFail)
+ }
+ },
+ ).collect {
+ _profileNicknameValidUiState.update { ProfileNicknameValidUiState.Valid }
+ }
+ }
+ }
+
+ fun onImageChanged(imageUri: String?) {
+ _signUpInfo.update { it.copy(profileImage = imageUri) }
+ }
+
+ fun signUp() {
+ viewModelScope.launch {
+ if (_signUpInfo.value.nickname.isEmpty() ||
+ profileNicknameValidUiState.value !is ProfileNicknameValidUiState.Valid
+ ) {
+ _signUpEvent.send(SignUpUiEvent.NicknameInValid)
+ return@launch
+ }
+ viewModelScope.launch {
+ signUpUseCase(
+ signUpUiModel.value.toDomain(),
+ onError = {
+ when (it) {
+ ResponseError.INVALID_ARGUMENT -> _signUpEvent.send(SignUpUiEvent.NicknameInValid)
+ ResponseError.DUPLICATE_RESOURCE -> _signUpEvent.send(SignUpUiEvent.NicknameInValid)
+ else -> _signUpEvent.send(SignUpUiEvent.SignUpFail)
+ }
+ },
+ ).collect {
+ _signUpEvent.send(SignUpUiEvent.SignUpSuccess)
+ }
+ }
+ }
+ }
+}
+
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt
new file mode 100644
index 00000000..cdf5712e
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/navigation/SignUpNavigation.kt
@@ -0,0 +1,37 @@
+package com.withpeace.withpeace.feature.signup.navigation
+
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.withpeace.withpeace.feature.signup.SignUpRoute
+import com.withpeace.withpeace.feature.signup.SignUpViewModel
+
+const val SIGN_UP_ROUTE = "signUpRoute"
+private const val IMAGE_LIST_ARGUMENT = "image_list_argument"
+
+fun NavController.navigateSignUp(navOptions: NavOptions? = null) {
+ navigate(SIGN_UP_ROUTE, navOptions)
+}
+
+fun NavGraphBuilder.signUpNavGraph(
+ onShowSnackBar: (message: String) -> Unit,
+ onNavigateToGallery: () -> Unit,
+ onSignUpSuccess: () -> Unit,
+) {
+ composable(route = SIGN_UP_ROUTE) { entry ->
+ val selectedImageUri =
+ entry.savedStateHandle.get>(IMAGE_LIST_ARGUMENT) ?: emptyList()
+ val viewModel: SignUpViewModel = hiltViewModel()
+ if (selectedImageUri.isNotEmpty()) {
+ viewModel.onImageChanged(imageUri = selectedImageUri.firstOrNull() ?: null)
+ }
+ SignUpRoute(
+ onShowSnackBar = onShowSnackBar,
+ onNavigateToGallery = onNavigateToGallery,
+ viewModel = viewModel,
+ onSignUpSuccess = onSignUpSuccess
+ )
+ }
+}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt
new file mode 100644
index 00000000..f812f340
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiEvent.kt
@@ -0,0 +1,9 @@
+package com.withpeace.withpeace.feature.signup.uistate
+
+sealed interface SignUpUiEvent {
+ data object SignUpSuccess : SignUpUiEvent
+ data object SignUpFail : SignUpUiEvent
+ data object VerifyFail : SignUpUiEvent
+ data object UnAuthorized : SignUpUiEvent
+ data object NicknameInValid : SignUpUiEvent
+}
\ No newline at end of file
diff --git a/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt
new file mode 100644
index 00000000..4c485617
--- /dev/null
+++ b/feature/signup/src/main/java/com/withpeace/withpeace/feature/signup/uistate/SignUpUiModel.kt
@@ -0,0 +1,6 @@
+package com.withpeace.withpeace.feature.signup.uistate
+
+data class SignUpUiModel(
+ val nickname: String,
+ val profileImage: String?,
+)
diff --git a/feature/signup/src/main/res/drawable/app_logo.png b/feature/signup/src/main/res/drawable/app_logo.png
new file mode 100644
index 00000000..544764ca
Binary files /dev/null and b/feature/signup/src/main/res/drawable/app_logo.png differ
diff --git a/feature/signup/src/main/res/values/strings.xml b/feature/signup/src/main/res/values/strings.xml
new file mode 100644
index 00000000..5347d73a
--- /dev/null
+++ b/feature/signup/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+
+ 프로필을 설정해주세요!
+ 닉네임은 2~10자의 한글, 영문만 가능합니다.
+ 닉네임을 입력하세요
+ 가입 완료
+ 프로필 이미지 설정
+
\ No newline at end of file
diff --git a/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt b/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt
new file mode 100644
index 00000000..129e2610
--- /dev/null
+++ b/feature/signup/src/test/java/com/withpeace/withpeace/feature/signup/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.withpeace.withpeace.feature.signup
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/google-login/.gitignore b/google-login/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/google-login/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/google-login/build.gradle.kts b/google-login/build.gradle.kts
new file mode 100644
index 00000000..c16ef1fa
--- /dev/null
+++ b/google-login/build.gradle.kts
@@ -0,0 +1,30 @@
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+
+fun getLocalPropertyString(propertyKey: String): String {
+ return gradleLocalProperties(rootDir).getProperty(propertyKey)
+}
+
+plugins {
+ id("com.android.library")
+ id("convention.android.compose")
+ id("convention.android.hilt")
+ id("convention.coroutine")
+}
+
+android {
+ namespace = "com.withpeace.withpeace.googlelogin"
+
+ defaultConfig {
+ buildConfigField(
+ "String",
+ "GOOGLE_CLIENT_ID",
+ getLocalPropertyString("GOOGLE_CLIENT_ID"),
+ )
+ }
+}
+
+dependencies {
+ implementation(libs.google.login)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play.service)
+}
diff --git a/google-login/consumer-rules.pro b/google-login/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/google-login/proguard-rules.pro b/google-login/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/google-login/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/google-login/src/main/AndroidManifest.xml b/google-login/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c45e6dfd
--- /dev/null
+++ b/google-login/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/google-login/src/main/kotlin/com/withpeace/withpeace/googlelogin/GoogleLoginManager.kt b/google-login/src/main/kotlin/com/withpeace/withpeace/googlelogin/GoogleLoginManager.kt
new file mode 100644
index 00000000..303ab9a9
--- /dev/null
+++ b/google-login/src/main/kotlin/com/withpeace/withpeace/googlelogin/GoogleLoginManager.kt
@@ -0,0 +1,52 @@
+package com.withpeace.withpeace.googlelogin
+
+import android.content.Context
+import androidx.credentials.Credential
+import androidx.credentials.CredentialManager
+import androidx.credentials.CustomCredential
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetCredentialResponse
+import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
+import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
+
+class GoogleLoginManager(val context: Context) {
+ private val credentialManager = CredentialManager.create(context)
+
+ private val googleIdOption: GetSignInWithGoogleOption =
+ GetSignInWithGoogleOption.Builder(BuildConfig.GOOGLE_CLIENT_ID)
+ .build()
+
+ private val credentialRequest = GetCredentialRequest(listOf(googleIdOption))
+
+ suspend fun startLogin(
+ onSuccessLogin: (String) -> Unit,
+ onFailLogin: (String?) -> Unit,
+ ) {
+ runCatching {
+ val result = credentialManager.getCredential(context, credentialRequest)
+ handleSignIn(result, onSuccessLogin, onFailLogin)
+ }
+ }
+
+ private fun handleSignIn(
+ result: GetCredentialResponse,
+ onSuccessLogin: (String) -> Unit,
+ onFailLogin: (String?) -> Unit,
+ ) {
+ val credential = result.credential
+ if (credential.isCustomAndRightType()) {
+ runCatching {
+ GoogleIdTokenCredential.createFrom(credential.data)
+ }.onSuccess {
+ onSuccessLogin(it.idToken)
+ }.onFailure {
+ onFailLogin(it.toString())
+ }
+ } else {
+ onFailLogin(null)
+ }
+ }
+
+ private fun Credential.isCustomAndRightType() =
+ this is CustomCredential && type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..3c5031eb
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 00000000..0c24989d
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,202 @@
+[versions]
+androidGradlePlugin = "8.2.2"
+androidDesugarJdkLibs = "1.2.2"
+androidxCore = "1.12.0"
+androidxAppCompat = "1.6.1"
+androidxLifecycle = "2.7.0"
+androidxComposeBom = "2023.10.01"
+androidxComposeCompiler = "1.5.1"
+androidxComposeNavigation = "2.7.6"
+composeIconExtended = "1.5.4"
+androidxActivity = "1.8.2"
+coreSplashscreen = "1.0.1"
+hilt = "2.48"
+hiltNavigationCompose = "1.1.0"
+androidxConstraintlayout = "1.0.1"
+
+lifecycleRuntimeKtx = "2.7.0"
+okhttp = "4.11.0"
+retrofit = "2.9.0"
+retrofitKotlinxSerializationJson = "1.0.0"
+kotlinxSerializationJson = "1.5.1"
+kotlinxDatetime = "0.2.1"
+kotlinxImmutable = "0.3.5"
+tikxml = "0.8.13"
+
+landscapist = "2.2.13"
+sandwich = "1.3.7"
+composeShimmer = "1.0.5"
+
+junit4 = "4.13.2"
+junitVintageEngine = "5.10.0"
+kotlin = "1.9.0"
+truth = "1.4.0"
+
+androidxTest = "1.5.0"
+androidxTestExt = "1.1.4"
+androidxEspresso = "3.5.0"
+kotest = "5.6.2"
+# https://github.com/detekt/detekt
+detekt = "1.23.0"
+ktlint = "10.2.1"
+mockk = "1.13.5"
+turbine = "1.0.0"
+
+coroutine = "1.7.2"
+
+androidxDatastore = "1.0.0"
+room = "2.6.1"
+
+ossLicenses = "17.0.1"
+ossLicensesPlugin = "0.10.6"
+
+androidxGlance = "1.0.0-beta01"
+glanceExperimentalTools = "0.2.2"
+junit = "1.1.5"
+material = "1.11.0"
+material3Android = "1.2.0"
+material3 = "1.2.1"
+multidex = "2.0.1"
+
+google-login = "1.1.0"
+
+credential = "1.2.0"
+
+paging = "3.3.0-alpha02"
+coreTesting = "2.2.0"
+
+dependencyGraph = "0.8.0"
+
+firebasePlugin = "4.3.15"
+firebaseBom = "32.7.4"
+firebaseCrashlytics = "2.9.9"
+
+[libraries]
+android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
+android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
+kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
+hilt-gradlePlugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" }
+
+androidx-core-testing = {module = "androidx.arch.core:core-testing", version.ref="coreTesting"}
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }
+
+androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidxConstraintlayout" }
+androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
+androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3",version.ref = "material3" }
+androidx-compose-icon = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "composeIconExtended" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" }
+androidx-compose-navigation-test = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxComposeNavigation" }
+androidx-paging = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
+androidx-pagingCompose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
+androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
+androidx-paging-testing= {module="androidx.paging:paging-testing",version.ref = "paging"}
+
+androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credential" }
+androidx-credentials-play-service = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credential" }
+
+hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
+
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
+hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
+kotlinx-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxImmutable" }
+tikxml-annotation = { group = "com.tickaroo.tikxml", name = "annotation", version.ref = "tikxml" }
+tikxml-core = { group = "com.tickaroo.tikxml", name = "core", version.ref = "tikxml" }
+retrofit-tikxml-converter = { group = "com.tickaroo.tikxml", name = "retrofit-converter", version.ref = "tikxml" }
+tikxml-processor = { group = "com.tickaroo.tikxml", name = "processor", version.ref = "tikxml" }
+
+skydoves-landscapist-bom = { group = "com.github.skydoves", name = "landscapist-bom", version.ref = "landscapist" }
+skydoves-landscapist-coil = { group = "com.github.skydoves", name = "landscapist-coil", version.ref = "landscapist" }
+skydoves-landscapist-glide = { group = "com.github.skydoves", name = "landscapist-glide", version.ref = "landscapist" }
+skydoves-landscapist-placeholder = { group = "com.github.skydoves", name = "landscapist-placeholder", version.ref = "landscapist" }
+landscapist-animation = { group = "com.github.skydoves", name = "landscapist-animation" }
+compose-shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version.ref = "composeShimmer" }
+skydoves-sandwich = { group = "com.github.skydoves", name = "sandwich", version.ref = "sandwich" }
+
+truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
+junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
+junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junitVintageEngine" }
+androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" }
+androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" }
+androidx-test-espresso-idling = { group = "androidx.test.espresso", name = "espresso-idling-resource", version.ref = "androidxEspresso" }
+androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTest" }
+inject = "javax.inject:javax.inject:1"
+kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" }
+kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
+kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
+turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
+
+coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" }
+coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutine" }
+coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine" }
+
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
+
+oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
+
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+
+multidex = { group = "androidx.multidex", name = "multidex", version.ref = "multidex" }
+
+google-login = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "google-login" }
+
+# verify
+verify-detektFormatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
+
+# plugin
+verify-detektPlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
+androidx-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" }
+oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "ossLicensesPlugin" }
+
+androidx-glance = { group = "androidx.glance", name = "glance", version.ref = "androidxGlance" }
+androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "androidxGlance" }
+glance-tools-appwidget-host = { group = "com.google.android.glance.tools", name = "appwidget-host", version.ref = "glanceExperimentalTools" }
+junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
+
+## firebase
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
+firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
+
+[bundles]
+
+[plugins]
+dependency-graph = { id = "com.vanniktech.dependency.graph.generator", version.ref = "dependencyGraph" }
+android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
+android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+verify-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
+ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
+firebase-services = { id = "com.google.gms.google-services", version.ref = "firebasePlugin" }
+firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" }
+
+
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..901f822f
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Feb 07 21:09:35 KST 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 00000000..4f906e0c
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 00000000..5197b18a
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,38 @@
+pluginManagement {
+ includeBuild("build-logic")
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "withpeace"
+include(":app")
+include(":google-login")
+include(":feature:login")
+include(":core:network")
+include(":core:data")
+include(":core:domain")
+include(":core:datastore")
+include(":core:designsystem")
+include(":core:interceptor")
+include(":core:testing")
+include(":core:ui")
+include(":core:imagestorage")
+include(":core:permission")
+include(":feature:signup")
+include(":feature:home")
+include(":feature:postlist")
+include(":feature:postdetail")
+include(":feature:registerpost")
+include(":feature:mypage")
+include(":feature:gallery")
+include(":feature:profileeditor")