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")