diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4529bfbec..37ce2af8e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,14 +1,10 @@ -name: Android CI +name: Android CI CD on: - push: - branches: [ "develop-AN" ] - paths: - - 'android/**' pull_request: - branches: [ "develop-AN" ] - paths: - - 'android/**' + branches: + - "develop" + - "release*" defaults: run: @@ -89,7 +85,7 @@ jobs: echo "native_app_key=$NATIVE_APP_KEY" >> ./local.properties - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -106,3 +102,71 @@ jobs: - name: Run Unit Test run: ./gradlew test + + deploy: + runs-on: ubuntu-latest + needs: build_and_test + if: startsWith(github.event.pull_request.base.ref, 'release-') + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create google-services.json + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: | + echo "$GOOGLE_SERVICES_JSON" > app/google-services.json + + - name: Create service_account.json + id: createServiceAccount + run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > app/service_account.json + + - name: Set up environment variable for BuildConfig + env: + BASE_URL: ${{ secrets.BASE_URL }} + TOKEN: ${{ secrets.TOKEN }} + NATIVE_APP_KEY: ${{ secrets.NATIVE_APP_KEY }} + run: | + echo "base_url=$BASE_URL" >> ./local.properties + echo "token=$TOKEN" >> ./local.properties + echo "native_app_key=$NATIVE_APP_KEY" >> ./local.properties + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/buildSrc/**/*.kt') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build release AAB + run: ./gradlew bundleRelease + + - name: Sign AAB + id: sign + uses: r0adkll/sign-android-release@v1 + with: + releaseDirectory: ./android/app/build/outputs/bundle/release + output: ./android/build/release/signed + signingKeyBase64: ${{ secrets.ENCODED_KEYSTORE }} + alias: ${{ secrets.AN_ALIAS }} + keyStorePassword: ${{ secrets.AN_KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.AN_KEY_PASSWORD }} + + - name: Upload AAB to Google Play + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: com.zzang.chongdae + releaseFiles: ./android/app/build/outputs/bundle/release/app-release.aab + track: "총대마켓 - 비공개 테스트" diff --git a/.github/workflows/backend-dev-ci-cd.yml b/.github/workflows/backend-dev-ci-cd.yml index a1f703ad1..255ca93dd 100644 --- a/.github/workflows/backend-dev-ci-cd.yml +++ b/.github/workflows/backend-dev-ci-cd.yml @@ -2,14 +2,14 @@ name: Backend Dev CI/CD Workflow on: push: - branches: [ "develop-BE" ] + branches: [ "develop" ] paths: - "backend/**" - ".github/workflows/backend-dev-ci-cd.yml" - "Dockerfile" # pull_request: - # branches: [ "develop-BE" ] - # paths: + # branches: [ "develop" ] + # paths: # - "backend/**" # - ".github/workflows/backend-dev-ci-cd.yml" # - "Dockerfile" diff --git a/.github/workflows/backend-prod-ci-cd.yml b/.github/workflows/backend-prod-ci-cd.yml index 1b72e3b17..8c605ad52 100644 --- a/.github/workflows/backend-prod-ci-cd.yml +++ b/.github/workflows/backend-prod-ci-cd.yml @@ -8,7 +8,7 @@ on: - ".github/workflows/backend-prod-ci-cd.yml" - "Dockerfile" # pull_request: - # branches: [ "develop-BE" ] + # branches: [ "develop" ] # paths: # - "backend/**" # - ".github/workflows/backend-prod-ci-cd.yml" @@ -57,16 +57,30 @@ jobs: docker tag ${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }} ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} docker push ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} - deploy: + deploy-a: needs: build-and-test - runs-on: [ self-hosted, prod ] + runs-on: prod-a steps: - - name: Pull Image And Restart Container + - name: Pull Image And Restart Container on Production A run: | docker login -u ${{ secrets.BE_DOCKERHUB_USERNAME }} -p ${{ secrets.BE_DOCKERHUB_PASSWORD }} docker stop ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true docker rm ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true docker image prune -a -f docker pull ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} - docker run --name ${{ secrets.BE_DOCKER_CONTAINER_NAME }} --network nginx_network -d -v /logs:/logs -e SPRING_PROFILES_ACTIVE=prod ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} + docker run --name ${{ secrets.BE_DOCKER_CONTAINER_NAME }} -d -v /logs:/logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} + + deploy-b: + needs: deploy-a + runs-on: prod-b + + steps: + - name: Pull Image And Restart Container on Production B + run: | + docker login -u ${{ secrets.BE_DOCKERHUB_USERNAME }} -p ${{ secrets.BE_DOCKERHUB_PASSWORD }} + docker stop ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true + docker rm ${{ secrets.BE_DOCKER_CONTAINER_NAME }} | true + docker image prune -a -f + docker pull ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} + docker run --name ${{ secrets.BE_DOCKER_CONTAINER_NAME }} -d -v /logs:/logs -p 80:8080 -e SPRING_PROFILES_ACTIVE=prod ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }}:${GITHUB_SHA::7} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index add5aac42..b7b8cc5c9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("com.google.gms.google-services") kotlin("plugin.serialization") version "2.0.0" id("com.google.firebase.crashlytics") + id("com.google.dagger.hilt.android") } android { @@ -28,8 +29,8 @@ android { applicationId = "com.zzang.chongdae" minSdk = 26 targetSdk = 34 - versionCode = 2 - versionName = "1.1.0" + versionCode = 6 + versionName = "1.1.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder" @@ -49,11 +50,7 @@ android { buildTypes { debug { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) + isMinifyEnabled = false } release { isMinifyEnabled = true @@ -86,89 +83,93 @@ android { } dependencies { - val navigationVersion = "2.7.7" - val fragmentVersion = "1.8.1" - implementation("androidx.core:core-ktx:1.10.1") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.10.0") - implementation("androidx.activity:activity-ktx:1.8.2") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.test.ext:junit-ktx:1.1.5") - testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") - testImplementation("org.assertj:assertj-core:3.25.3") - testImplementation("io.kotest:kotest-runner-junit5:5.8.0") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.test:runner:1.4.0") - androidTestImplementation("org.junit.jupiter:junit-jupiter:5.10.2") - androidTestImplementation("org.assertj:assertj-core:3.25.3") - androidTestImplementation("io.kotest:kotest-runner-junit5:5.8.0") - androidTestImplementation("de.mannodermaus.junit5:android-test-core:1.3.0") - androidTestRuntimeOnly("de.mannodermaus.junit5:android-test-runner:1.3.0") - // Testing Navigation - androidTestImplementation("androidx.navigation:navigation-testing:$navigationVersion") - - implementation("androidx.room:room-runtime:2.6.1") - kapt("androidx.room:room-compiler:2.6.1") - implementation("androidx.room:room-ktx:2.6.1") - implementation("com.google.code.gson:gson:2.8.8") - - implementation("com.github.bumptech.glide:glide:4.12.0") - kapt("com.github.bumptech.glide:compiler:4.12.0") - testImplementation("androidx.arch.core:core-testing:2.1.0") - implementation("com.squareup.okhttp3:mockwebserver:4.12.0") - - implementation("com.squareup.retrofit2:retrofit:2.11.0") - implementation("com.squareup.retrofit2:converter-gson:2.11.0") - - implementation("androidx.room:room-ktx:2.6.1") - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") - implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") - - kapt("com.github.bumptech.glide:compiler:4.13.2") - - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") - implementation("androidx.activity:activity-ktx:1.9.0") - implementation("androidx.fragment:fragment-ktx:1.7.0") - implementation("androidx.core:core-ktx:1.10.1") implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment) + implementation(libs.androidx.constraintlayout) + + // Test + implementation(libs.androidx.junit) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertj.core) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.core.testing) + + // Android Test + androidTestImplementation(libs.junit.jupiter) + androidTestImplementation(libs.assertj.core) + androidTestImplementation(libs.kotest.runner.junit5) + androidTestImplementation(libs.mannodermaus.test.core) + androidTestImplementation(libs.mannodermaus.test.runner) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + + // Espresso 및 관련 + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) + + // UI Test: Fragment Scenario + debugImplementation(libs.androidx.fragment.testing) + androidTestImplementation(libs.androidx.fragment.testing) + + // DataStore + implementation(libs.androidx.datastore.preferences) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + + // Room + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + + // json + implementation(libs.kotlinx.serialization.json) + + // Glide + implementation(libs.glide) + kapt(libs.glide.compiler) + + // Retrofit + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.retrofit.kotlinx.serialization) // Navigation - implementation("androidx.navigation:navigation-fragment-ktx:$navigationVersion") - implementation("androidx.navigation:navigation-ui-ktx:$navigationVersion") - - // UI Test - Fragment Scenario - debugImplementation("androidx.fragment:fragment-testing-manifest:$fragmentVersion") - androidTestImplementation("androidx.fragment:fragment-testing:$fragmentVersion") - - // Espresso - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.test:runner:1.4.0") - - // Espresso RecyclerView Actions - androidTestImplementation("androidx.test.espresso:espresso-contrib:3.3.0") + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + androidTestImplementation(libs.androidx.navigation.testing) // Pagination - implementation("androidx.paging:paging-runtime-ktx:3.3.0") + implementation(libs.androidx.paging.runtime) // WebView - implementation("androidx.webkit:webkit:1.9.0") + implementation(libs.androidx.webkit) // Firebase - implementation(platform("com.google.firebase:firebase-bom:33.1.2")) - implementation("com.google.firebase:firebase-analytics") + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) + implementation(libs.firebase.crashlytics) // 카카오 로그인 - implementation("com.kakao.sdk:v2-all:2.20.3") + implementation(libs.kakao.sdk) - // data store - implementation("androidx.datastore:datastore-preferences:1.0.0") + // Mockk + implementation(libs.mockwebserver) + testImplementation(libs.mockk) - implementation("com.google.firebase:firebase-crashlytics") + // Swipe Refresh Layout + implementation(libs.androidx.swiperefreshlayout) + + // Hilt + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) +} - // mockk - testImplementation("io.mockk:mockk:1.13.10") +kapt { + correctErrorTypes = true } diff --git a/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt b/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt deleted file mode 100644 index 82e05c116..000000000 --- a/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zzang.chongdae - -import androidx.fragment.app.testing.FragmentScenario -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.zzang.chongdae.presentation.view.comment.CommentRoomsFragment -import org.junit.Before -import org.junit.Test -import org.junit.jupiter.api.DisplayName -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class CommentRoomsFragmentTest { - private lateinit var scenario: FragmentScenario - - @Before - fun setUp() { - scenario = FragmentScenario.launchInContainer(CommentRoomsFragment::class.java) - } - - @Test - @DisplayName("댓글방 목록으로 이동하면 채팅이라는 텍스트뷰가 보여야 한다") - fun commentRoomTest1() { - // then - onView(withId(R.id.tv_comment_text)).check(matches(isDisplayed())) - } -} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 236f2f386..d411e8557 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,8 +36,20 @@ + android:exported="true" + android:windowSoftInputMode="stateAlwaysHidden|adjustPan"> + + + + + + + + + + + suspend fun saveRefresh(): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt new file mode 100644 index 000000000..9977c7a8a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/auth/repository/AuthRepositoryImpl.kt @@ -0,0 +1,26 @@ +package com.zzang.chongdae.auth.repository + +import com.zzang.chongdae.auth.dto.request.AccessTokenRequest +import com.zzang.chongdae.auth.mapper.toDomain +import com.zzang.chongdae.auth.model.Member +import com.zzang.chongdae.auth.source.AuthRemoteDataSource +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthDataSourceQualifier +import javax.inject.Inject + +class AuthRepositoryImpl + @Inject + constructor( + @AuthDataSourceQualifier private val authRemoteDataSource: AuthRemoteDataSource, + ) : AuthRepository { + override suspend fun saveLogin(accessToken: String): Result { + return authRemoteDataSource.saveLogin( + accessTokenRequest = AccessTokenRequest(accessToken), + ).map { it.toDomain() } + } + + override suspend fun saveRefresh(): Result { + return authRemoteDataSource.saveRefresh() + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt new file mode 100644 index 000000000..8bdcc7403 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSource.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.auth.source + +import com.zzang.chongdae.auth.dto.request.AccessTokenRequest +import com.zzang.chongdae.auth.dto.response.MemberResponse +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result + +interface AuthRemoteDataSource { + suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result + + suspend fun saveRefresh(): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt new file mode 100644 index 000000000..fd97158dd --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/auth/source/AuthRemoteDataSourceImpl.kt @@ -0,0 +1,24 @@ +package com.zzang.chongdae.auth.source + +import com.zzang.chongdae.auth.api.AuthApiService +import com.zzang.chongdae.auth.dto.request.AccessTokenRequest +import com.zzang.chongdae.auth.dto.response.MemberResponse +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.data.remote.util.safeApiCall +import com.zzang.chongdae.di.annotations.AuthApiServiceQualifier +import javax.inject.Inject + +class AuthRemoteDataSourceImpl + @Inject + constructor( + @AuthApiServiceQualifier private val service: AuthApiService, + ) : AuthRemoteDataSource { + override suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result { + return safeApiCall { service.postLogin(accessTokenRequest) } + } + + override suspend fun saveRefresh(): Result { + return safeApiCall { service.postRefresh() } + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt b/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt new file mode 100644 index 000000000..0d257498f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/common/datastore/UserPreferencesDataStore.kt @@ -0,0 +1,70 @@ +package com.zzang.chongdae.common.datastore + +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 com.zzang.chongdae.di.annotations.DataStoreQualifier +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class UserPreferencesDataStore + @Inject + constructor( + @DataStoreQualifier private val dataStore: DataStore, + ) { + val memberIdFlow: Flow = + dataStore.data.map { preferences -> + preferences[MEMBER_ID_KEY] + } + + val nickNameFlow: Flow = + dataStore.data.map { preferences -> + preferences[NICKNAME_KEY] + } + + val accessTokenFlow: Flow = + dataStore.data.map { preferences -> + preferences[ACCESS_TOKEN_KEY] + } + + val refreshTokenFlow: Flow = + dataStore.data.map { preferences -> + preferences[REFRESH_TOKEN_KEY] + } + + suspend fun saveMember( + memberId: Long, + nickName: String, + ) { + dataStore.edit { preferences -> + preferences[MEMBER_ID_KEY] = memberId + preferences[NICKNAME_KEY] = nickName + } + } + + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = accessToken + preferences[REFRESH_TOKEN_KEY] = refreshToken + } + } + + suspend fun removeAllData() { + dataStore.edit { preferences -> + preferences.clear() + } + } + + companion object { + val MEMBER_ID_KEY = longPreferencesKey("member_id_key") + val NICKNAME_KEY = stringPreferencesKey("nickname_key") + val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token_key") + val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token_key") + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt b/android/app/src/main/java/com/zzang/chongdae/common/firebase/FirebaseAnalyticsManager.kt similarity index 95% rename from android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt rename to android/app/src/main/java/com/zzang/chongdae/common/firebase/FirebaseAnalyticsManager.kt index 11334b627..29fee0962 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt +++ b/android/app/src/main/java/com/zzang/chongdae/common/firebase/FirebaseAnalyticsManager.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.presentation.util +package com.zzang.chongdae.common.firebase import android.os.Bundle import com.google.firebase.analytics.FirebaseAnalytics diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt b/android/app/src/main/java/com/zzang/chongdae/common/handler/DataError.kt similarity index 87% rename from android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt rename to android/app/src/main/java/com/zzang/chongdae/common/handler/DataError.kt index 6c3ec920d..6e6935729 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt +++ b/android/app/src/main/java/com/zzang/chongdae/common/handler/DataError.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.domain.util +package com.zzang.chongdae.common.handler sealed interface DataError : Error { enum class Network : DataError { diff --git a/android/app/src/main/java/com/zzang/chongdae/common/handler/Error.kt b/android/app/src/main/java/com/zzang/chongdae/common/handler/Error.kt new file mode 100644 index 000000000..c0ab2f70c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/common/handler/Error.kt @@ -0,0 +1,3 @@ +package com.zzang.chongdae.common.handler + +sealed interface Error diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/util/Result.kt b/android/app/src/main/java/com/zzang/chongdae/common/handler/Result.kt similarity index 93% rename from android/app/src/main/java/com/zzang/chongdae/domain/util/Result.kt rename to android/app/src/main/java/com/zzang/chongdae/common/handler/Result.kt index 1d994e79c..1d69cd1d5 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/util/Result.kt +++ b/android/app/src/main/java/com/zzang/chongdae/common/handler/Result.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.domain.util +package com.zzang.chongdae.common.handler typealias RootError = Error diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/source/OfferingLocalDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/source/OfferingLocalDataSourceImpl.kt index 0c0c4e189..d7b545f7e 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/local/source/OfferingLocalDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/source/OfferingLocalDataSourceImpl.kt @@ -3,13 +3,19 @@ package com.zzang.chongdae.data.local.source import com.zzang.chongdae.data.local.dao.OfferingDao import com.zzang.chongdae.data.local.model.OfferingEntity import com.zzang.chongdae.data.source.offering.OfferingLocalDataSource +import com.zzang.chongdae.di.annotations.OfferingDaoQualifier +import javax.inject.Inject -class OfferingLocalDataSourceImpl(private val offeringDao: OfferingDao) : OfferingLocalDataSource { - override suspend fun insertOfferings(offerings: List) { - offeringDao.insertAll(offerings) - } +class OfferingLocalDataSourceImpl + @Inject + constructor( + @OfferingDaoQualifier private val offeringDao: OfferingDao, + ) : OfferingLocalDataSource { + override suspend fun insertOfferings(offerings: List) { + offeringDao.insertAll(offerings) + } - override suspend fun insertOffering(offering: OfferingEntity) { - offeringDao.insertOffering(offering) + override suspend fun insertOffering(offering: OfferingEntity) { + offeringDao.insertOffering(offering) + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/source/UserPreferencesDataStore.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/source/UserPreferencesDataStore.kt deleted file mode 100644 index 1f257360d..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/local/source/UserPreferencesDataStore.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.zzang.chongdae.data.local.source - -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 - -class UserPreferencesDataStore(private val dataStore: DataStore) { - val memberIdFlow: Flow = - dataStore.data.map { preferences -> - preferences[MEMBER_ID_KEY] - } - - val nickNameFlow: Flow = - dataStore.data.map { preferences -> - preferences[NICKNAME_KEY] - } - - val accessTokenFlow: Flow = - dataStore.data.map { preferences -> - preferences[ACCESS_TOKEN_KEY] - } - - val refreshTokenFlow: Flow = - dataStore.data.map { preferences -> - preferences[REFRESH_TOKEN_KEY] - } - - suspend fun saveMember( - memberId: Long, - nickName: String, - ) { - dataStore.edit { preferences -> - preferences[MEMBER_ID_KEY] = memberId - preferences[NICKNAME_KEY] = nickName - } - } - - suspend fun saveTokens( - accessToken: String, - refreshToken: String, - ) { - dataStore.edit { preferences -> - preferences[ACCESS_TOKEN_KEY] = accessToken - preferences[REFRESH_TOKEN_KEY] = refreshToken - } - } - - suspend fun removeAllData() { - dataStore.edit { preferences -> - preferences.clear() - } - } - - companion object { - val MEMBER_ID_KEY = longPreferencesKey("member_id_key") - val NICKNAME_KEY = stringPreferencesKey("nickname_key") - val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token_key") - val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token_key") - } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentsMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentsMapper.kt deleted file mode 100644 index 0d8f09e26..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentsMapper.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.zzang.chongdae.data.mapper - -import com.zzang.chongdae.data.local.model.CommentEntity -import com.zzang.chongdae.data.remote.dto.response.comment.CommentResponse -import com.zzang.chongdae.domain.model.Comment - -fun CommentResponse.toDomain(): Comment { - return Comment( - content = this.content, - commentCreatedAt = this.commentCreatedAtResponse.toDomain(), - isMine = this.isMine, - isProposer = this.isProposer, - nickname = this.nickname, - ) -} - -fun mapToCommentEntity( - offeringId: Long, - commentResponse: CommentResponse, -): CommentEntity { - return CommentEntity( - offeringId = offeringId, - commentId = commentResponse.commentId, - content = commentResponse.content, - isMine = commentResponse.isMine, - isProposer = commentResponse.isProposer, - nickname = commentResponse.nickname, - commentCreatedAtDate = commentResponse.commentCreatedAtResponse.date, - commentCreatedAtTime = commentResponse.commentCreatedAtResponse.time, - ) -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/MemberMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/mapper/MemberMapper.kt deleted file mode 100644 index 1e34a613e..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/MemberMapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zzang.chongdae.data.mapper - -import com.zzang.chongdae.data.remote.dto.response.auth.MemberResponse -import com.zzang.chongdae.domain.model.Member - -fun MemberResponse.toDomain(): Member { - return Member( - memberId = this.memberId, - nickName = this.nickname, - ) -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/NetworkManager.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/NetworkManager.kt index 30098821e..671e82596 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/NetworkManager.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/NetworkManager.kt @@ -4,7 +4,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.zzang.chongdae.BuildConfig import com.zzang.chongdae.ChongdaeApp import com.zzang.chongdae.ChongdaeApp.Companion.dataStore -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.auth.api.AuthApiService +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore import com.zzang.chongdae.data.remote.util.TokensCookieJar import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType @@ -21,7 +22,8 @@ object NetworkManager { } private fun getRetrofit(): Retrofit { - val userDataStore = UserPreferencesDataStore(ChongdaeApp.chongdaeApplicationContext.dataStore) + val userDataStore = + UserPreferencesDataStore(ChongdaeApp.chongdaeAppContext.dataStore) if (instance == null) { val contentType = "application/json".toMediaType() instance = diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/OfferingApiService.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/OfferingApiService.kt index 8107fd35f..57225068b 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/OfferingApiService.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/OfferingApiService.kt @@ -1,5 +1,6 @@ package com.zzang.chongdae.data.remote.api +import com.zzang.chongdae.data.remote.dto.request.OfferingModifyRequest import com.zzang.chongdae.data.remote.dto.request.OfferingWriteRequest import com.zzang.chongdae.data.remote.dto.request.ProductUrlRequest import com.zzang.chongdae.data.remote.dto.response.offering.FiltersResponse @@ -11,8 +12,10 @@ import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOffering import okhttp3.MultipartBody import retrofit2.Response 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.Part import retrofit2.http.Path @@ -60,4 +63,15 @@ interface OfferingApiService { suspend fun postProductImageS3( @Part image: MultipartBody.Part, ): Response + + @PATCH("/offerings/{offering-id}") + suspend fun patchOffering( + @Path("offering-id") offeringId: Long, + @Body offeringModifyRequest: OfferingModifyRequest, + ): Response + + @DELETE("/offerings/{offering-id}") + suspend fun deleteOffering( + @Path("offering-id") offeringId: Long, + ): Response } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingModifyRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingModifyRequest.kt new file mode 100644 index 000000000..bc0ee9525 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingModifyRequest.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OfferingModifyRequest( + @SerialName("title") val title: String, + @SerialName("productUrl") val productUrl: String?, + @SerialName("thumbnailUrl") val thumbnailUrl: String?, + @SerialName("totalCount") val totalCount: Int, + @SerialName("totalPrice") val totalPrice: Int, + @SerialName("originPrice") val originPrice: Int?, + @SerialName("meetingAddress") val meetingAddress: String, + @SerialName("meetingAddressDong") val meetingAddressDong: String?, + @SerialName("meetingAddressDetail") val meetingAddressDetail: String, + @SerialName("meetingDate") val meetingDate: String, + @SerialName("description") val description: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingDetailResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingDetailResponse.kt index efcde9653..e4db2180d 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingDetailResponse.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingDetailResponse.kt @@ -17,6 +17,7 @@ data class OfferingDetailResponse( @SerialName("thumbnailUrl") val thumbnailUrl: String?, @SerialName("dividedPrice") val dividedPrice: Int, @SerialName("totalPrice") val totalPrice: Int, + @SerialName("originPrice") val originPrice: Int?, @SerialName("status") val condition: RemoteOfferingStatus, @SerialName("isProposer") val isProposer: Boolean, @SerialName("nickname") val nickname: String, diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingModifyResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingModifyResponse.kt new file mode 100644 index 000000000..7021e8dfb --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingModifyResponse.kt @@ -0,0 +1,22 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OfferingModifyResponse( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("productUrl") val productUrl: String?, + @SerialName("meetingAddress") val meetingAddress: String, + @SerialName("meetingAddressDetail") val meetingAddressDetail: String, + @SerialName("description") val description: String, + @SerialName("meetingDate") val meetingDate: String, + @SerialName("currentCount") val currentCount: Int, + @SerialName("totalCount") val totalCount: Int, + @SerialName("thumbnailUrl") val thumbnailUrl: String?, + @SerialName("dividedPrice") val dividedPrice: Int, + @SerialName("totalPrice") val totalPrice: Int, + @SerialName("status") val condition: RemoteOfferingStatus, + @SerialName("nickname") val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentCreatedAtMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentCreatedAtMapper.kt similarity index 87% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentCreatedAtMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentCreatedAtMapper.kt index 987c3bb08..2a1077966 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentCreatedAtMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentCreatedAtMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.comment.CommentCreatedAtResponse import com.zzang.chongdae.domain.model.CommentCreatedAt diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentOfferingInfoMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentOfferingInfoMapper.kt similarity index 90% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentOfferingInfoMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentOfferingInfoMapper.kt index 9240ac815..eee2fdb3f 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentOfferingInfoMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentOfferingInfoMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.comment.CommentOfferingInfoResponse import com.zzang.chongdae.domain.model.CommentOfferingInfo diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentRoomResponseMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentRoomResponseMapper.kt similarity index 90% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentRoomResponseMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentRoomResponseMapper.kt index d38acfaed..8a31e38b8 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentRoomResponseMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentRoomResponseMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.commentroom.CommentRoomResponse import com.zzang.chongdae.domain.model.CommentRoom diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentsMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentsMapper.kt new file mode 100644 index 000000000..3a694d3ca --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CommentsMapper.kt @@ -0,0 +1,14 @@ +package com.zzang.chongdae.data.remote.mapper + +import com.zzang.chongdae.data.remote.dto.response.comment.CommentResponse +import com.zzang.chongdae.domain.model.Comment + +fun CommentResponse.toDomain(): Comment { + return Comment( + content = this.content, + commentCreatedAt = this.commentCreatedAtResponse.toDomain(), + isMine = this.isMine, + isProposer = this.isProposer, + nickname = this.nickname, + ) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CurrentCountMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CurrentCountMapper.kt similarity index 68% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/CurrentCountMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CurrentCountMapper.kt index 9d7cb36e0..317041114 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/CurrentCountMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/CurrentCountMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.domain.model.CurrentCount diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/FilterMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/FilterMapper.kt similarity index 95% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/FilterMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/FilterMapper.kt index 1209d172a..c7ed5e973 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/FilterMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/FilterMapper.kt @@ -1,6 +1,6 @@ @file:Suppress("UNUSED_EXPRESSION") -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.RemoteFilter import com.zzang.chongdae.data.remote.dto.response.offering.RemoteFilterName diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/LocalDateTimeMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/LocalDateTimeMapper.kt similarity index 90% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/LocalDateTimeMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/LocalDateTimeMapper.kt index 6b4bbe711..c138019a2 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/LocalDateTimeMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/LocalDateTimeMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import java.time.LocalDate import java.time.LocalDateTime diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/MeetingsResponseMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/MeetingsResponseMapper.kt similarity index 89% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/MeetingsResponseMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/MeetingsResponseMapper.kt index 49e75195f..a0a79e7a1 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/MeetingsResponseMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/MeetingsResponseMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.MeetingsResponse import com.zzang.chongdae.domain.model.Meetings diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingConditionMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingConditionMapper.kt similarity index 91% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingConditionMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingConditionMapper.kt index 1231fc25f..e5e332971 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingConditionMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingConditionMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOfferingStatus import com.zzang.chongdae.domain.model.OfferingCondition diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingDetailResponseMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingDetailResponseMapper.kt similarity index 91% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingDetailResponseMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingDetailResponseMapper.kt index e40e3440e..2b77a7337 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingDetailResponseMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingDetailResponseMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.OfferingDetailResponse import com.zzang.chongdae.domain.model.OfferingDetail @@ -13,6 +13,7 @@ fun OfferingDetailResponse.toDomain() = dividedPrice = this.dividedPrice, thumbnailUrl = this.thumbnailUrl, totalPrice = this.totalPrice, + originPrice = this.originPrice, meetingDate = this.meetingDate.toLocalDateTime(), currentCount = this.currentCount.toCurrentCount(), totalCount = this.totalCount, diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingMapper.kt similarity index 92% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingMapper.kt index 09818f45b..fd4696e89 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOffering import com.zzang.chongdae.domain.model.Offering diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingModifyMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingModifyMapper.kt new file mode 100644 index 000000000..2a1f7ca5f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingModifyMapper.kt @@ -0,0 +1,41 @@ +package com.zzang.chongdae.data.remote.mapper + +import com.zzang.chongdae.data.remote.dto.request.OfferingModifyRequest +import com.zzang.chongdae.data.remote.dto.response.offering.OfferingModifyResponse +import com.zzang.chongdae.domain.model.OfferingModifyDomainRequest +import com.zzang.chongdae.domain.model.OfferingModifyDomainResponse + +fun OfferingModifyDomainRequest.toRequest(): OfferingModifyRequest { + return OfferingModifyRequest( + title = this.title, + productUrl = this.productUrl, + thumbnailUrl = this.thumbnailUrl, + totalCount = this.totalCount, + totalPrice = this.totalPrice, + originPrice = this.originPrice, + meetingAddress = this.meetingAddress, + meetingAddressDong = this.meetingAddressDong, + meetingAddressDetail = this.meetingAddressDetail, + meetingDate = this.meetingDate, + description = this.description, + ) +} + +fun OfferingModifyResponse.toDomain(): OfferingModifyDomainResponse { + return OfferingModifyDomainResponse( + id = this.id, + title = this.title, + productUrl = this.productUrl, + meetingAddress = this.meetingAddress, + meetingAddressDetail = this.meetingAddressDetail, + description = this.description, + meetingDate = this.meetingDate.toLocalDateTime(), + currentCount = this.currentCount.toCurrentCount(), + totalCount = this.totalCount, + thumbnailUrl = this.thumbnailUrl, + dividedPrice = this.dividedPrice, + totalPrice = this.totalPrice, + condition = this.condition.toDomain(), + nickname = this.nickname, + ) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingWriteMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingWriteMapper.kt new file mode 100644 index 000000000..e8a4c7821 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/OfferingWriteMapper.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.data.remote.mapper + +import com.zzang.chongdae.data.remote.dto.request.OfferingWriteRequest +import com.zzang.chongdae.domain.model.OfferingWrite + +fun OfferingWrite.toRequest(): OfferingWriteRequest { + return OfferingWriteRequest( + title = this.title, + productUrl = this.productUrl, + thumbnailUrl = this.thumbnailUrl, + totalCount = this.totalCount, + totalPrice = this.totalPrice, + originPrice = this.originPrice, + meetingAddress = this.meetingAddress, + meetingAddressDong = this.meetingAddressDong, + meetingAddressDetail = this.meetingAddressDetail, + meetingDate = this.meetingDate, + description = this.description, + ) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/ParticipationsResponseMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ParticipationsResponseMapper.kt similarity index 87% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/ParticipationsResponseMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ParticipationsResponseMapper.kt index a4bc9365a..4dd506e95 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/ParticipationsResponseMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ParticipationsResponseMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.response.offering.ParticipationResponse import com.zzang.chongdae.domain.model.Participation diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/ProductUrlMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ProductUrlMapper.kt similarity index 90% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/ProductUrlMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ProductUrlMapper.kt index 655e20aaf..df8b53ae1 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/ProductUrlMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/ProductUrlMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper +package com.zzang.chongdae.data.remote.mapper import com.zzang.chongdae.data.remote.dto.request.ProductUrlRequest import com.zzang.chongdae.data.remote.dto.response.offering.ProductUrlResponse diff --git a/android/app/src/main/java/com/zzang/chongdae/data/mapper/participant/ParticipantsMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/participant/ParticipantsMapper.kt similarity index 95% rename from android/app/src/main/java/com/zzang/chongdae/data/mapper/participant/ParticipantsMapper.kt rename to android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/participant/ParticipantsMapper.kt index 45901377b..dfac3fa6f 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/mapper/participant/ParticipantsMapper.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/mapper/participant/ParticipantsMapper.kt @@ -1,4 +1,4 @@ -package com.zzang.chongdae.data.mapper.participant +package com.zzang.chongdae.data.remote.mapper.participant import com.zzang.chongdae.data.remote.dto.response.participants.ParticipantsResponse import com.zzang.chongdae.data.remote.dto.response.participants.RemoteCount diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/AuthRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/AuthRemoteDataSourceImpl.kt deleted file mode 100644 index fa01a5385..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/AuthRemoteDataSourceImpl.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zzang.chongdae.data.remote.source - -import com.zzang.chongdae.data.remote.api.AuthApiService -import com.zzang.chongdae.data.remote.dto.request.AccessTokenRequest -import com.zzang.chongdae.data.remote.dto.response.auth.MemberResponse -import com.zzang.chongdae.data.remote.util.safeApiCall -import com.zzang.chongdae.data.source.AuthRemoteDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result - -class AuthRemoteDataSourceImpl( - private val service: AuthApiService, -) : AuthRemoteDataSource { - override suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result { - return safeApiCall { service.postLogin(accessTokenRequest) } - } - - override suspend fun saveRefresh(): Result { - return safeApiCall { service.postRefresh() } - } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRemoteDataSourceImpl.kt index 7977fb2fd..88df30413 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRemoteDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRemoteDataSourceImpl.kt @@ -1,5 +1,7 @@ package com.zzang.chongdae.data.remote.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.api.CommentApiService import com.zzang.chongdae.data.remote.dto.request.CommentRequest import com.zzang.chongdae.data.remote.dto.response.comment.CommentOfferingInfoResponse @@ -7,29 +9,31 @@ import com.zzang.chongdae.data.remote.dto.response.comment.CommentsResponse import com.zzang.chongdae.data.remote.dto.response.comment.UpdatedStatusResponse import com.zzang.chongdae.data.remote.util.safeApiCall import com.zzang.chongdae.data.source.comment.CommentRemoteDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.di.annotations.CommentDetailApiServiceQualifier +import javax.inject.Inject -class CommentRemoteDataSourceImpl( - private val service: CommentApiService, -) : CommentRemoteDataSource { - override suspend fun saveComment(commentRequest: CommentRequest): Result = - safeApiCall { - service.postComment(commentRequest) - } +class CommentRemoteDataSourceImpl + @Inject + constructor( + @CommentDetailApiServiceQualifier private val service: CommentApiService, + ) : CommentRemoteDataSource { + override suspend fun saveComment(commentRequest: CommentRequest): Result = + safeApiCall { + service.postComment(commentRequest) + } - override suspend fun fetchComments(offeringId: Long): Result = - safeApiCall { - service.getComments(offeringId) - } + override suspend fun fetchComments(offeringId: Long): Result = + safeApiCall { + service.getComments(offeringId) + } - override suspend fun fetchCommentOfferingInfo(offeringId: Long): Result = - safeApiCall { - service.getCommentOfferingInfo(offeringId) - } + override suspend fun fetchCommentOfferingInfo(offeringId: Long): Result = + safeApiCall { + service.getCommentOfferingInfo(offeringId) + } - override suspend fun updateOfferingStatus(offeringId: Long): Result = - safeApiCall { - service.patchOfferingStatus(offeringId) - } -} + override suspend fun updateOfferingStatus(offeringId: Long): Result = + safeApiCall { + service.patchOfferingStatus(offeringId) + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRoomsDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRoomsDataSourceImpl.kt index 87c72925c..2d5002e6f 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRoomsDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRoomsDataSourceImpl.kt @@ -1,16 +1,20 @@ package com.zzang.chongdae.data.remote.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.api.CommentApiService import com.zzang.chongdae.data.remote.dto.response.commentroom.CommentRoomsResponse import com.zzang.chongdae.data.remote.util.safeApiCall import com.zzang.chongdae.data.source.CommentRoomsDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.di.annotations.CommentRoomsApiServiceQualifier +import javax.inject.Inject -class CommentRoomsDataSourceImpl( - private val commentApiService: CommentApiService, -) : CommentRoomsDataSource { - override suspend fun fetchCommentRooms(): Result { - return safeApiCall { commentApiService.getCommentRooms() } +class CommentRoomsDataSourceImpl + @Inject + constructor( + @CommentRoomsApiServiceQualifier private val commentApiService: CommentApiService, + ) : CommentRoomsDataSource { + override suspend fun fetchCommentRooms(): Result { + return safeApiCall { commentApiService.getCommentRooms() } + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingDetailDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingDetailDataSourceImpl.kt index 38f79e7d1..2c66f5e8d 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingDetailDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingDetailDataSourceImpl.kt @@ -1,21 +1,30 @@ package com.zzang.chongdae.data.remote.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.api.OfferingApiService import com.zzang.chongdae.data.remote.api.ParticipationApiService import com.zzang.chongdae.data.remote.dto.request.ParticipationRequest import com.zzang.chongdae.data.remote.dto.response.offering.OfferingDetailResponse import com.zzang.chongdae.data.remote.util.safeApiCall import com.zzang.chongdae.data.source.OfferingDetailDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.di.annotations.OfferingApiServiceQualifier +import com.zzang.chongdae.di.annotations.ParticipantApiServiceQualifier +import javax.inject.Inject -class OfferingDetailDataSourceImpl( - private val offeringApiService: OfferingApiService, - private val participationApiService: ParticipationApiService, -) : OfferingDetailDataSource { - override suspend fun fetchOfferingDetail(offeringId: Long): Result = - safeApiCall { offeringApiService.getOfferingDetail(offeringId) } +class OfferingDetailDataSourceImpl + @Inject + constructor( + @OfferingApiServiceQualifier private val offeringApiService: OfferingApiService, + @ParticipantApiServiceQualifier private val participationApiService: ParticipationApiService, + ) : OfferingDetailDataSource { + override suspend fun fetchOfferingDetail(offeringId: Long): Result = + safeApiCall { offeringApiService.getOfferingDetail(offeringId) } - override suspend fun saveParticipation(participationRequest: ParticipationRequest): Result = - safeApiCall { participationApiService.postParticipations(participationRequest) } -} + override suspend fun saveParticipation(participationRequest: ParticipationRequest): Result = + safeApiCall { participationApiService.postParticipations(participationRequest) } + + override suspend fun deleteOffering(offeringId: Long): Result { + return safeApiCall { offeringApiService.deleteOffering(offeringId) } + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingRemoteDataSourceImpl.kt index f3994d1a2..df2dc3782 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingRemoteDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingRemoteDataSourceImpl.kt @@ -1,48 +1,60 @@ package com.zzang.chongdae.data.remote.source -import com.zzang.chongdae.data.mapper.toProductUrlRequest +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.api.OfferingApiService +import com.zzang.chongdae.data.remote.dto.request.OfferingModifyRequest import com.zzang.chongdae.data.remote.dto.request.OfferingWriteRequest import com.zzang.chongdae.data.remote.dto.response.offering.FiltersResponse import com.zzang.chongdae.data.remote.dto.response.offering.MeetingsResponse import com.zzang.chongdae.data.remote.dto.response.offering.OfferingsResponse import com.zzang.chongdae.data.remote.dto.response.offering.ProductUrlResponse import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOffering +import com.zzang.chongdae.data.remote.mapper.toProductUrlRequest import com.zzang.chongdae.data.remote.util.safeApiCall import com.zzang.chongdae.data.source.offering.OfferingRemoteDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.di.annotations.OfferingApiServiceQualifier import okhttp3.MultipartBody - -class OfferingRemoteDataSourceImpl( - private val service: OfferingApiService, -) : OfferingRemoteDataSource { - override suspend fun fetchOffering(offeringId: Long): Result = - safeApiCall { service.getOffering(offeringId) } - - override suspend fun fetchOfferings( - filter: String?, - search: String?, - lastOfferingId: Long?, - pageSize: Int?, - ): Result = safeApiCall { service.getOfferings(filter, search, lastOfferingId, pageSize) } - - override suspend fun saveOffering(offeringWriteRequest: OfferingWriteRequest): Result = - safeApiCall { service.postOfferingWrite((offeringWriteRequest)) } - - override suspend fun saveProductImageOg(productUrl: String): Result = - safeApiCall { service.postProductImageOg((productUrl.toProductUrlRequest())) } - - override suspend fun saveProductImageS3(image: MultipartBody.Part): Result = - safeApiCall { service.postProductImageS3(image) } - - override suspend fun fetchFilters(): Result = safeApiCall { service.getFilters() } - - override suspend fun fetchMeetings(offeringId: Long): Result = - safeApiCall { service.getMeetings(offeringId) } - - companion object { - private const val ERROR_PREFIX = "에러 발생: " - private const val ERROR_NULL_MESSAGE = "null" +import javax.inject.Inject + +class OfferingRemoteDataSourceImpl + @Inject + constructor( + @OfferingApiServiceQualifier private val service: OfferingApiService, + ) : OfferingRemoteDataSource { + override suspend fun fetchOffering(offeringId: Long): Result = + safeApiCall { service.getOffering(offeringId) } + + override suspend fun fetchOfferings( + filter: String?, + search: String?, + lastOfferingId: Long?, + pageSize: Int?, + ): Result = safeApiCall { service.getOfferings(filter, search, lastOfferingId, pageSize) } + + override suspend fun saveOffering(offeringWriteRequest: OfferingWriteRequest): Result = + safeApiCall { service.postOfferingWrite((offeringWriteRequest)) } + + override suspend fun saveProductImageOg(productUrl: String): Result = + safeApiCall { service.postProductImageOg((productUrl.toProductUrlRequest())) } + + override suspend fun saveProductImageS3(image: MultipartBody.Part): Result = + safeApiCall { service.postProductImageS3(image) } + + override suspend fun fetchFilters(): Result = safeApiCall { service.getFilters() } + + override suspend fun fetchMeetings(offeringId: Long): Result = + safeApiCall { service.getMeetings(offeringId) } + + override suspend fun patchOffering( + offeringId: Long, + offeringModifyRequest: OfferingModifyRequest, + ): Result { + return safeApiCall { service.patchOffering(offeringId, offeringModifyRequest) } + } + + companion object { + private const val ERROR_PREFIX = "에러 발생: " + private const val ERROR_NULL_MESSAGE = "null" + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/ParticipantRemoteDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/ParticipantRemoteDataSourceImpl.kt index a847e76df..250222a5c 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/ParticipantRemoteDataSourceImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/ParticipantRemoteDataSourceImpl.kt @@ -1,22 +1,26 @@ package com.zzang.chongdae.data.remote.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.api.ParticipationApiService import com.zzang.chongdae.data.remote.dto.response.participants.ParticipantsResponse import com.zzang.chongdae.data.remote.util.safeApiCall import com.zzang.chongdae.data.source.ParticipantRemoteDataSource -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.di.annotations.ParticipantApiServiceQualifier +import javax.inject.Inject -class ParticipantRemoteDataSourceImpl( - private val service: ParticipationApiService, -) : ParticipantRemoteDataSource { - override suspend fun fetchParticipants(offeringId: Long): Result = - safeApiCall { - service.getParticipants(offeringId) - } +class ParticipantRemoteDataSourceImpl + @Inject + constructor( + @ParticipantApiServiceQualifier private val service: ParticipationApiService, + ) : ParticipantRemoteDataSource { + override suspend fun fetchParticipants(offeringId: Long): Result = + safeApiCall { + service.getParticipants(offeringId) + } - override suspend fun deleteParticipations(offeringId: Long): Result = - safeApiCall { - service.deleteParticipations(offeringId) - } -} + override suspend fun deleteParticipations(offeringId: Long): Result = + safeApiCall { + service.deleteParticipations(offeringId) + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/CallApiHandler.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/CallApiHandler.kt index aabc7932f..148630f71 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/CallApiHandler.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/CallApiHandler.kt @@ -1,7 +1,7 @@ package com.zzang.chongdae.data.remote.util -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import retrofit2.HttpException import retrofit2.Response import java.io.IOException diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt index 07b839fbb..4c94fb81d 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt @@ -1,7 +1,7 @@ package com.zzang.chongdae.data.remote.util import com.zzang.chongdae.BuildConfig -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/AuthRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/AuthRepositoryImpl.kt deleted file mode 100644 index cdf833974..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/AuthRepositoryImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.zzang.chongdae.data.repository - -import com.zzang.chongdae.data.mapper.toDomain -import com.zzang.chongdae.data.remote.dto.request.AccessTokenRequest -import com.zzang.chongdae.data.source.AuthRemoteDataSource -import com.zzang.chongdae.domain.model.Member -import com.zzang.chongdae.domain.repository.AuthRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result - -class AuthRepositoryImpl( - private val authRemoteDataSource: AuthRemoteDataSource, -) : AuthRepository { - override suspend fun saveLogin(accessToken: String): Result { - return authRemoteDataSource.saveLogin( - accessTokenRequest = AccessTokenRequest(accessToken), - ).map { it.toDomain() } - } - - override suspend fun saveRefresh(): Result { - return when (val result = authRemoteDataSource.saveRefresh()) { - is Result.Error -> { - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - return Result.Error(result.msg, DataError.Network.FAIL_REFRESH) - } - - else -> { - return Result.Error(result.msg, DataError.Network.UNAUTHORIZED) - } - } - } - - is Result.Success -> result - } - } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentDetailRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentDetailRepositoryImpl.kt index bb4f8d5a2..6d01ae361 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentDetailRepositoryImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentDetailRepositoryImpl.kt @@ -1,40 +1,46 @@ package com.zzang.chongdae.data.repository -import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.request.CommentRequest +import com.zzang.chongdae.data.remote.mapper.toDomain import com.zzang.chongdae.data.source.comment.CommentRemoteDataSource +import com.zzang.chongdae.di.annotations.CommentDetailDataSourceQualifier import com.zzang.chongdae.domain.model.Comment import com.zzang.chongdae.domain.model.CommentOfferingInfo import com.zzang.chongdae.domain.repository.CommentDetailRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import javax.inject.Inject -class CommentDetailRepositoryImpl( - private val commentRemoteDataSource: CommentRemoteDataSource, -) : CommentDetailRepository { - override suspend fun saveComment( - offeringId: Long, - comment: String, - ): Result { - return commentRemoteDataSource.saveComment( - CommentRequest(offeringId, comment), - ).map { Unit } - } +class CommentDetailRepositoryImpl + @Inject + constructor( + @CommentDetailDataSourceQualifier private val commentRemoteDataSource: CommentRemoteDataSource, + ) : CommentDetailRepository { + override suspend fun saveComment( + offeringId: Long, + comment: String, + ): Result { + return commentRemoteDataSource.saveComment( + CommentRequest(offeringId, comment), + ).map { Unit } + } - override suspend fun fetchComments(offeringId: Long): Result, DataError.Network> { - return commentRemoteDataSource.fetchComments(offeringId) - .map { response -> - response.commentsResponse.map { it.toDomain() } - } - } + override suspend fun fetchComments(offeringId: Long): Result, DataError.Network> { + return commentRemoteDataSource.fetchComments(offeringId) + .map { response -> + response.commentsResponse.map { it.toDomain() } + } + } - override suspend fun fetchCommentOfferingInfo(offeringId: Long): Result { - return commentRemoteDataSource.fetchCommentOfferingInfo(offeringId) - .map { it.toDomain() } - } + override suspend fun fetchCommentOfferingInfo(offeringId: Long): Result { + return commentRemoteDataSource.fetchCommentOfferingInfo(offeringId) + .map { response -> + response.toDomain() + } + } - override suspend fun updateOfferingStatus(offeringId: Long): Result { - return commentRemoteDataSource.updateOfferingStatus(offeringId) - .map { Unit } + override suspend fun updateOfferingStatus(offeringId: Long): Result { + return commentRemoteDataSource.updateOfferingStatus(offeringId) + .map { Unit } + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentRoomsRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentRoomsRepositoryImpl.kt index 924cf8544..d8ef5b867 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentRoomsRepositoryImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentRoomsRepositoryImpl.kt @@ -1,18 +1,22 @@ package com.zzang.chongdae.data.repository -import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.data.remote.mapper.toDomain import com.zzang.chongdae.data.source.CommentRoomsDataSource +import com.zzang.chongdae.di.annotations.CommentRoomsDataSourceQualifier import com.zzang.chongdae.domain.model.CommentRoom import com.zzang.chongdae.domain.repository.CommentRoomsRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import javax.inject.Inject -class CommentRoomsRepositoryImpl( - private val commentRoomsDataSource: CommentRoomsDataSource, -) : CommentRoomsRepository { - override suspend fun fetchCommentRooms(): Result, DataError.Network> { - return commentRoomsDataSource.fetchCommentRooms().map { - it.commentRoom.map { it.toDomain() } +class CommentRoomsRepositoryImpl + @Inject + constructor( + @CommentRoomsDataSourceQualifier private val commentRoomsDataSource: CommentRoomsDataSource, + ) : CommentRoomsRepository { + override suspend fun fetchCommentRooms(): Result, DataError.Network> { + return commentRoomsDataSource.fetchCommentRooms().map { + it.commentRoom.map { it.toDomain() } + } } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingDetailRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingDetailRepositoryImpl.kt index b206f444c..b1ababab5 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingDetailRepositoryImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingDetailRepositoryImpl.kt @@ -1,25 +1,33 @@ package com.zzang.chongdae.data.repository -import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.request.ParticipationRequest +import com.zzang.chongdae.data.remote.mapper.toDomain import com.zzang.chongdae.data.source.OfferingDetailDataSource +import com.zzang.chongdae.di.annotations.OfferingDetailDataSourceQualifier import com.zzang.chongdae.domain.model.OfferingDetail import com.zzang.chongdae.domain.repository.OfferingDetailRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import javax.inject.Inject -class OfferingDetailRepositoryImpl( - private val offeringDetailDataSource: OfferingDetailDataSource, -) : OfferingDetailRepository { - override suspend fun fetchOfferingDetail(offeringId: Long): Result = - offeringDetailDataSource.fetchOfferingDetail( - offeringId = offeringId, - ).map { - it.toDomain() - } +class OfferingDetailRepositoryImpl + @Inject + constructor( + @OfferingDetailDataSourceQualifier private val offeringDetailDataSource: OfferingDetailDataSource, + ) : OfferingDetailRepository { + override suspend fun fetchOfferingDetail(offeringId: Long): Result = + offeringDetailDataSource.fetchOfferingDetail( + offeringId = offeringId, + ).map { + it.toDomain() + } + + override suspend fun saveParticipation(offeringId: Long): Result = + offeringDetailDataSource.saveParticipation( + participationRequest = ParticipationRequest(offeringId), + ) - override suspend fun saveParticipation(offeringId: Long): Result = - offeringDetailDataSource.saveParticipation( - participationRequest = ParticipationRequest(offeringId), - ) -} + override suspend fun deleteOffering(offeringId: Long): Result { + return offeringDetailDataSource.deleteOffering(offeringId) + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingRepositoryImpl.kt index 20793d043..bd0f2d3bf 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingRepositoryImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingRepositoryImpl.kt @@ -1,80 +1,84 @@ package com.zzang.chongdae.data.repository -import com.zzang.chongdae.data.mapper.toDomain -import com.zzang.chongdae.data.remote.dto.request.OfferingWriteRequest +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.data.remote.mapper.toDomain +import com.zzang.chongdae.data.remote.mapper.toRequest import com.zzang.chongdae.data.source.offering.OfferingLocalDataSource import com.zzang.chongdae.data.source.offering.OfferingRemoteDataSource +import com.zzang.chongdae.di.annotations.OfferingLocalDataSourceQualifier +import com.zzang.chongdae.di.annotations.OfferingRemoteDataSourceQualifier import com.zzang.chongdae.domain.model.Filter import com.zzang.chongdae.domain.model.Meetings import com.zzang.chongdae.domain.model.Offering +import com.zzang.chongdae.domain.model.OfferingModifyDomainRequest +import com.zzang.chongdae.domain.model.OfferingWrite import com.zzang.chongdae.domain.model.ProductUrl import com.zzang.chongdae.domain.repository.OfferingRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result -import com.zzang.chongdae.presentation.view.write.OfferingWriteUiModel import okhttp3.MultipartBody +import javax.inject.Inject -class OfferingRepositoryImpl( - private val offeringLocalDataSource: OfferingLocalDataSource, - private val offeringRemoteDataSource: OfferingRemoteDataSource, -) : OfferingRepository { - override suspend fun fetchOffering(offeringId: Long): Result = - offeringRemoteDataSource.fetchOffering(offeringId = offeringId).map { - it.toDomain() - } - - override suspend fun fetchOfferings( - filter: String?, - search: String?, - lastOfferingId: Long?, - pageSize: Int?, - ): Result, DataError.Network> { - return offeringRemoteDataSource.fetchOfferings(filter, search, lastOfferingId, pageSize) - .map { - it.offerings.map { it.toDomain() } +class OfferingRepositoryImpl + @Inject + constructor( + @OfferingLocalDataSourceQualifier private val offeringLocalDataSource: OfferingLocalDataSource, + @OfferingRemoteDataSourceQualifier private val offeringRemoteDataSource: OfferingRemoteDataSource, + ) : OfferingRepository { + override suspend fun fetchOffering(offeringId: Long): Result = + offeringRemoteDataSource.fetchOffering(offeringId = offeringId).map { + it.toDomain() } - } - override suspend fun saveOffering(uiModel: OfferingWriteUiModel): Result { - return offeringRemoteDataSource.saveOffering( - offeringWriteRequest = - OfferingWriteRequest( - title = uiModel.title, - productUrl = uiModel.productUrl, - thumbnailUrl = uiModel.thumbnailUrl, - totalCount = uiModel.totalCount, - totalPrice = uiModel.totalPrice, - originPrice = uiModel.originPrice, - meetingAddress = uiModel.meetingAddress, - meetingAddressDong = uiModel.meetingAddressDong, - meetingAddressDetail = uiModel.meetingAddressDetail, - meetingDate = uiModel.meetingDate, - description = uiModel.description, - ), - ) - } + override suspend fun fetchOfferings( + filter: String?, + search: String?, + lastOfferingId: Long?, + pageSize: Int?, + ): Result, DataError.Network> { + return offeringRemoteDataSource.fetchOfferings(filter, search, lastOfferingId, pageSize) + .map { + it.offerings.map { it.toDomain() } + } + } - override suspend fun saveProductImageOg(productUrl: String): Result { - return offeringRemoteDataSource.saveProductImageOg(productUrl).map { - it.toDomain() + override suspend fun saveOffering(offeringWrite: OfferingWrite): Result { + return offeringRemoteDataSource.saveOffering( + offeringWriteRequest = offeringWrite.toRequest(), + ) } - } - override suspend fun saveProductImageS3(image: MultipartBody.Part): Result { - return offeringRemoteDataSource.saveProductImageS3(image).map { - it.toDomain() + override suspend fun saveProductImageOg(productUrl: String): Result { + return offeringRemoteDataSource.saveProductImageOg(productUrl).map { + it.toDomain() + } } - } - override suspend fun fetchFilters(): Result, DataError.Network> { - return offeringRemoteDataSource.fetchFilters().map { - it.filters.map { it.toDomain() } + override suspend fun saveProductImageS3(image: MultipartBody.Part): Result { + return offeringRemoteDataSource.saveProductImageS3(image).map { + it.toDomain() + } + } + + override suspend fun fetchFilters(): Result, DataError.Network> { + return offeringRemoteDataSource.fetchFilters().map { + it.filters.map { it.toDomain() } + } } - } - override suspend fun fetchMeetings(offeringId: Long): Result { - return offeringRemoteDataSource.fetchMeetings(offeringId).map { - it.toDomain() + override suspend fun fetchMeetings(offeringId: Long): Result { + return offeringRemoteDataSource.fetchMeetings(offeringId).map { + it.toDomain() + } } + + override suspend fun patchOffering( + offeringId: Long, + offeringModifyDomainRequest: OfferingModifyDomainRequest, + ): Result = + offeringRemoteDataSource.patchOffering( + offeringId, + offeringModifyDomainRequest.toRequest(), + ).map { + it // .toDomain() + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/ParticipantRepositoryImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/repository/ParticipantRepositoryImpl.kt index 6abae202b..7f4d48b57 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/repository/ParticipantRepositoryImpl.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/ParticipantRepositoryImpl.kt @@ -1,22 +1,26 @@ package com.zzang.chongdae.data.repository -import com.zzang.chongdae.data.mapper.participant.toDomain +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.data.remote.mapper.participant.toDomain import com.zzang.chongdae.data.source.ParticipantRemoteDataSource +import com.zzang.chongdae.di.annotations.ParticipantDataSourceQualifier import com.zzang.chongdae.domain.model.participant.Participants import com.zzang.chongdae.domain.repository.ParticipantRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import javax.inject.Inject -class ParticipantRepositoryImpl( - private val participantRemoteDataSource: ParticipantRemoteDataSource, -) : ParticipantRepository { - override suspend fun fetchParticipants(offeringId: Long): Result = - participantRemoteDataSource.fetchParticipants( - offeringId, - ).map { response -> - response.toDomain() - } +class ParticipantRepositoryImpl + @Inject + constructor( + @ParticipantDataSourceQualifier private val participantRemoteDataSource: ParticipantRemoteDataSource, + ) : ParticipantRepository { + override suspend fun fetchParticipants(offeringId: Long): Result = + participantRemoteDataSource.fetchParticipants( + offeringId, + ).map { response -> + response.toDomain() + } - override suspend fun deleteParticipations(offeringId: Long): Result = - participantRemoteDataSource.deleteParticipations(offeringId) -} + override suspend fun deleteParticipations(offeringId: Long): Result = + participantRemoteDataSource.deleteParticipations(offeringId) + } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/AuthRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/AuthRemoteDataSource.kt deleted file mode 100644 index d101edff6..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/AuthRemoteDataSource.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zzang.chongdae.data.source - -import com.zzang.chongdae.data.remote.dto.request.AccessTokenRequest -import com.zzang.chongdae.data.remote.dto.response.auth.MemberResponse -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result - -interface AuthRemoteDataSource { - suspend fun saveLogin(accessTokenRequest: AccessTokenRequest): Result - - suspend fun saveRefresh(): Result -} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/CommentRoomsDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/CommentRoomsDataSource.kt index f133264ed..c48283cd7 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/CommentRoomsDataSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/CommentRoomsDataSource.kt @@ -1,8 +1,8 @@ package com.zzang.chongdae.data.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.response.commentroom.CommentRoomsResponse -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface CommentRoomsDataSource { suspend fun fetchCommentRooms(): Result diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/OfferingDetailDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/OfferingDetailDataSource.kt index b32806232..e6cdb92ed 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/OfferingDetailDataSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/OfferingDetailDataSource.kt @@ -1,12 +1,14 @@ package com.zzang.chongdae.data.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.request.ParticipationRequest import com.zzang.chongdae.data.remote.dto.response.offering.OfferingDetailResponse -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface OfferingDetailDataSource { suspend fun fetchOfferingDetail(offeringId: Long): Result suspend fun saveParticipation(participationRequest: ParticipationRequest): Result + + suspend fun deleteOffering(offeringId: Long): Result } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/ParticipantRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/ParticipantRemoteDataSource.kt index b53e3c663..588a4a7c8 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/ParticipantRemoteDataSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/ParticipantRemoteDataSource.kt @@ -1,8 +1,8 @@ package com.zzang.chongdae.data.source +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.response.participants.ParticipantsResponse -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface ParticipantRemoteDataSource { suspend fun fetchParticipants(offeringId: Long): Result diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentRemoteDataSource.kt index 3ce476747..f3b96a0ae 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentRemoteDataSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentRemoteDataSource.kt @@ -1,11 +1,11 @@ package com.zzang.chongdae.data.source.comment +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.data.remote.dto.request.CommentRequest import com.zzang.chongdae.data.remote.dto.response.comment.CommentOfferingInfoResponse import com.zzang.chongdae.data.remote.dto.response.comment.CommentsResponse import com.zzang.chongdae.data.remote.dto.response.comment.UpdatedStatusResponse -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface CommentRemoteDataSource { suspend fun saveComment(commentRequest: CommentRequest): Result diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingRemoteDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingRemoteDataSource.kt index 4f290c056..7524554c7 100644 --- a/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingRemoteDataSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingRemoteDataSource.kt @@ -1,13 +1,14 @@ package com.zzang.chongdae.data.source.offering +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.data.remote.dto.request.OfferingModifyRequest import com.zzang.chongdae.data.remote.dto.request.OfferingWriteRequest import com.zzang.chongdae.data.remote.dto.response.offering.FiltersResponse import com.zzang.chongdae.data.remote.dto.response.offering.MeetingsResponse import com.zzang.chongdae.data.remote.dto.response.offering.OfferingsResponse import com.zzang.chongdae.data.remote.dto.response.offering.ProductUrlResponse import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOffering -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result import okhttp3.MultipartBody interface OfferingRemoteDataSource { @@ -29,4 +30,9 @@ interface OfferingRemoteDataSource { suspend fun fetchFilters(): Result suspend fun fetchMeetings(offeringId: Long): Result + + suspend fun patchOffering( + offeringId: Long, + offeringModifyRequest: OfferingModifyRequest, + ): Result } diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/AuthQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/AuthQualifier.kt new file mode 100644 index 000000000..fdef1fd5f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/AuthQualifier.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthApiServiceQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentDetailQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentDetailQualifier.kt new file mode 100644 index 000000000..9e995d308 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentDetailQualifier.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentDetailRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentDetailDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentDetailApiServiceQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentRoomsQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentRoomsQualifier.kt new file mode 100644 index 000000000..50e7c7a5e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/CommentRoomsQualifier.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentRoomsRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentRoomsDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CommentRoomsApiServiceQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/DataStoreQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/DataStoreQualifier.kt new file mode 100644 index 000000000..1cb2af513 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/DataStoreQualifier.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DataStoreQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingDetailQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingDetailQualifier.kt new file mode 100644 index 000000000..ec3dba7e3 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingDetailQualifier.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingDetailRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingDetailDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingDetailApiServiceQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingQualifier.kt new file mode 100644 index 000000000..f34b23d55 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/OfferingQualifier.kt @@ -0,0 +1,23 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingRemoteDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingLocalDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingApiServiceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OfferingDaoQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/annotations/ParticipantQualifier.kt b/android/app/src/main/java/com/zzang/chongdae/di/annotations/ParticipantQualifier.kt new file mode 100644 index 000000000..4dd2dd494 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/annotations/ParticipantQualifier.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ParticipantRepositoryQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ParticipantDataSourceQualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ParticipantApiServiceQualifier diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/AuthDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/AuthDependencyModule.kt new file mode 100644 index 000000000..497ca1f17 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/AuthDependencyModule.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.auth.api.AuthApiService +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.auth.repository.AuthRepositoryImpl +import com.zzang.chongdae.auth.source.AuthRemoteDataSource +import com.zzang.chongdae.auth.source.AuthRemoteDataSourceImpl +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.di.annotations.AuthApiServiceQualifier +import com.zzang.chongdae.di.annotations.AuthDataSourceQualifier +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class AuthDependencyModule { + @Binds + @Singleton + @AuthRepositoryQualifier + abstract fun provideAuthRepository(impl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + @AuthDataSourceQualifier + abstract fun provideAuthDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource + + companion object { + @Provides + @Singleton + @AuthApiServiceQualifier + fun provideAuthApiService(): AuthApiService { + return NetworkManager.authService() + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/CommentDetailDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/CommentDetailDependencyModule.kt new file mode 100644 index 000000000..785a57b8c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/CommentDetailDependencyModule.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.data.remote.api.CommentApiService +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.source.CommentRemoteDataSourceImpl +import com.zzang.chongdae.data.repository.CommentDetailRepositoryImpl +import com.zzang.chongdae.data.source.comment.CommentRemoteDataSource +import com.zzang.chongdae.di.annotations.CommentDetailApiServiceQualifier +import com.zzang.chongdae.di.annotations.CommentDetailDataSourceQualifier +import com.zzang.chongdae.di.annotations.CommentDetailRepositoryQualifier +import com.zzang.chongdae.domain.repository.CommentDetailRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class CommentDetailDependencyModule { + @Binds + @Singleton + @CommentDetailRepositoryQualifier + abstract fun provideCommentDetailRepository(impl: CommentDetailRepositoryImpl): CommentDetailRepository + + @Binds + @Singleton + @CommentDetailDataSourceQualifier + abstract fun provideCommentDetailDataSource(impl: CommentRemoteDataSourceImpl): CommentRemoteDataSource + + companion object { + @Provides + @Singleton + @CommentDetailApiServiceQualifier + fun provideCommentDetailApiService(): CommentApiService { + return NetworkManager.commentService() + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/CommentRoomsDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/CommentRoomsDependencyModule.kt new file mode 100644 index 000000000..56133633d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/CommentRoomsDependencyModule.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.data.remote.api.CommentApiService +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.source.CommentRoomsDataSourceImpl +import com.zzang.chongdae.data.repository.CommentRoomsRepositoryImpl +import com.zzang.chongdae.data.source.CommentRoomsDataSource +import com.zzang.chongdae.di.annotations.CommentRoomsApiServiceQualifier +import com.zzang.chongdae.di.annotations.CommentRoomsDataSourceQualifier +import com.zzang.chongdae.di.annotations.CommentRoomsRepositoryQualifier +import com.zzang.chongdae.domain.repository.CommentRoomsRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class CommentRoomsDependencyModule { + @Binds + @Singleton + @CommentRoomsRepositoryQualifier + abstract fun provideCommentRoomsRepository(impl: CommentRoomsRepositoryImpl): CommentRoomsRepository + + @Binds + @Singleton + @CommentRoomsDataSourceQualifier + abstract fun provideCommentRoomsDataSource(impl: CommentRoomsDataSourceImpl): CommentRoomsDataSource + + companion object { + @Provides + @Singleton + @CommentRoomsApiServiceQualifier + fun provideCommentRoomsService(): CommentApiService { + return NetworkManager.commentService() + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/DataStoreDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/DataStoreDependencyModule.kt new file mode 100644 index 000000000..eb05a65f0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/DataStoreDependencyModule.kt @@ -0,0 +1,23 @@ +package com.zzang.chongdae.di.module + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import com.zzang.chongdae.ChongdaeApp +import com.zzang.chongdae.ChongdaeApp.Companion.dataStore +import com.zzang.chongdae.di.annotations.DataStoreQualifier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +object DataStoreDependencyModule { + @Provides + @Singleton + @DataStoreQualifier + fun provideDataStore(): DataStore { + return ChongdaeApp.chongdaeAppContext.dataStore + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDependencyModule.kt new file mode 100644 index 000000000..0a68002a1 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDependencyModule.kt @@ -0,0 +1,58 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.ChongdaeApp +import com.zzang.chongdae.data.local.dao.OfferingDao +import com.zzang.chongdae.data.local.source.OfferingLocalDataSourceImpl +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.api.OfferingApiService +import com.zzang.chongdae.data.remote.source.OfferingRemoteDataSourceImpl +import com.zzang.chongdae.data.repository.OfferingRepositoryImpl +import com.zzang.chongdae.data.source.offering.OfferingLocalDataSource +import com.zzang.chongdae.data.source.offering.OfferingRemoteDataSource +import com.zzang.chongdae.di.annotations.OfferingApiServiceQualifier +import com.zzang.chongdae.di.annotations.OfferingDaoQualifier +import com.zzang.chongdae.di.annotations.OfferingLocalDataSourceQualifier +import com.zzang.chongdae.di.annotations.OfferingRemoteDataSourceQualifier +import com.zzang.chongdae.di.annotations.OfferingRepositoryQualifier +import com.zzang.chongdae.domain.repository.OfferingRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class OfferingDependencyModule { + @Binds + @Singleton + @OfferingRepositoryQualifier + abstract fun provideOfferingRepository(impl: OfferingRepositoryImpl): OfferingRepository + + @Binds + @Singleton + @OfferingRemoteDataSourceQualifier + abstract fun provideOfferingRemoteDataSource(impl: OfferingRemoteDataSourceImpl): OfferingRemoteDataSource + + @Binds + @Singleton + @OfferingLocalDataSourceQualifier + abstract fun provideOfferingLocalDataSource(impl: OfferingLocalDataSourceImpl): OfferingLocalDataSource + + companion object { + @Provides + @Singleton + @OfferingApiServiceQualifier + fun provideOfferingService(): OfferingApiService { + return NetworkManager.offeringService() + } + + @Provides + @Singleton + @OfferingDaoQualifier + fun provideOfferingDao(): OfferingDao { + return ChongdaeApp.offeringDao + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDetailDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDetailDependencyModule.kt new file mode 100644 index 000000000..bb07a1747 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/OfferingDetailDependencyModule.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.api.OfferingApiService +import com.zzang.chongdae.data.remote.source.OfferingDetailDataSourceImpl +import com.zzang.chongdae.data.repository.OfferingDetailRepositoryImpl +import com.zzang.chongdae.data.source.OfferingDetailDataSource +import com.zzang.chongdae.di.annotations.OfferingDetailApiServiceQualifier +import com.zzang.chongdae.di.annotations.OfferingDetailDataSourceQualifier +import com.zzang.chongdae.di.annotations.OfferingDetailRepositoryQualifier +import com.zzang.chongdae.domain.repository.OfferingDetailRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class OfferingDetailDependencyModule { + @Binds + @Singleton + @OfferingDetailRepositoryQualifier + abstract fun provideOfferingDetailRepository(impl: OfferingDetailRepositoryImpl): OfferingDetailRepository + + @Binds + @Singleton + @OfferingDetailDataSourceQualifier + abstract fun provideOfferingDetailDataSource(impl: OfferingDetailDataSourceImpl): OfferingDetailDataSource + + companion object { + @Provides + @Singleton + @OfferingDetailApiServiceQualifier + fun provideOfferingDetailService(): OfferingApiService { + return NetworkManager.offeringService() + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/di/module/ParticipantDependencyModule.kt b/android/app/src/main/java/com/zzang/chongdae/di/module/ParticipantDependencyModule.kt new file mode 100644 index 000000000..0bb581b0d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/di/module/ParticipantDependencyModule.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.di.module + +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.api.ParticipationApiService +import com.zzang.chongdae.data.remote.source.ParticipantRemoteDataSourceImpl +import com.zzang.chongdae.data.repository.ParticipantRepositoryImpl +import com.zzang.chongdae.data.source.ParticipantRemoteDataSource +import com.zzang.chongdae.di.annotations.ParticipantApiServiceQualifier +import com.zzang.chongdae.di.annotations.ParticipantDataSourceQualifier +import com.zzang.chongdae.di.annotations.ParticipantRepositoryQualifier +import com.zzang.chongdae.domain.repository.ParticipantRepository +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +abstract class ParticipantDependencyModule { + @Binds + @Singleton + @ParticipantRepositoryQualifier + abstract fun provideParticipantRepository(impl: ParticipantRepositoryImpl): ParticipantRepository + + @Binds + @Singleton + @ParticipantDataSourceQualifier + abstract fun provideParticipantDataSource(impl: ParticipantRemoteDataSourceImpl): ParticipantRemoteDataSource + + companion object { + @Provides + @Singleton + @ParticipantApiServiceQualifier + fun provideParticipantApiService(): ParticipationApiService { + return NetworkManager.participationService() + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingDetail.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingDetail.kt index 262d50601..04b1c5779 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingDetail.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingDetail.kt @@ -11,6 +11,7 @@ data class OfferingDetail( val thumbnailUrl: String?, val dividedPrice: Int, val totalPrice: Int, + val originPrice: Int?, val meetingDate: LocalDateTime, val currentCount: CurrentCount, val totalCount: Int, diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainRequest.kt similarity index 79% rename from android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteUiModel.kt rename to android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainRequest.kt index 808096314..1767efc73 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteUiModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainRequest.kt @@ -1,6 +1,6 @@ -package com.zzang.chongdae.presentation.view.write +package com.zzang.chongdae.domain.model -data class OfferingWriteUiModel( +data class OfferingModifyDomainRequest( val title: String, val productUrl: String?, val thumbnailUrl: String?, diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainResponse.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainResponse.kt new file mode 100644 index 000000000..20b5e5447 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingModifyDomainResponse.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.domain.model + +import java.time.LocalDateTime + +data class OfferingModifyDomainResponse( + val id: Long, + val title: String, + val productUrl: String?, + val meetingAddress: String, + val meetingAddressDetail: String, + val description: String, + val meetingDate: LocalDateTime, + val currentCount: CurrentCount, + val totalCount: Int, + val thumbnailUrl: String?, + val dividedPrice: Int, + val totalPrice: Int, + val condition: OfferingCondition, + val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingWrite.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingWrite.kt new file mode 100644 index 000000000..3bab29f02 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingWrite.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.domain.model + +data class OfferingWrite( + val title: String, + val productUrl: String?, + val thumbnailUrl: String?, + val totalCount: Int, + val totalPrice: Int, + val originPrice: Int?, + val meetingAddress: String, + val meetingAddressDong: String?, + val meetingAddressDetail: String, + val meetingDate: String, + val description: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/paging/OfferingPagingSource.kt b/android/app/src/main/java/com/zzang/chongdae/domain/paging/OfferingPagingSource.kt index b07e7afd0..be354f405 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/paging/OfferingPagingSource.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/paging/OfferingPagingSource.kt @@ -2,11 +2,11 @@ package com.zzang.chongdae.domain.paging import androidx.paging.PagingSource import androidx.paging.PagingState +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.Offering -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.OfferingRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result class OfferingPagingSource( private val offeringsRepository: OfferingRepository, diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/AuthRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/AuthRepository.kt deleted file mode 100644 index 30b6d0db6..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/AuthRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.zzang.chongdae.domain.repository - -import com.zzang.chongdae.domain.model.Member -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result - -interface AuthRepository { - suspend fun saveLogin(accessToken: String): Result - - suspend fun saveRefresh(): Result -} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentDetailRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentDetailRepository.kt index 0b0daa896..4dc188623 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentDetailRepository.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentDetailRepository.kt @@ -1,9 +1,9 @@ package com.zzang.chongdae.domain.repository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.Comment import com.zzang.chongdae.domain.model.CommentOfferingInfo -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface CommentDetailRepository { suspend fun saveComment( diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentRoomsRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentRoomsRepository.kt index e5d883694..091c7ae87 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentRoomsRepository.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentRoomsRepository.kt @@ -1,8 +1,8 @@ package com.zzang.chongdae.domain.repository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.CommentRoom -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface CommentRoomsRepository { suspend fun fetchCommentRooms(): Result, DataError.Network> diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingDetailRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingDetailRepository.kt index 28e682f16..d9bb30449 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingDetailRepository.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingDetailRepository.kt @@ -1,11 +1,13 @@ package com.zzang.chongdae.domain.repository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.OfferingDetail -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface OfferingDetailRepository { suspend fun fetchOfferingDetail(offeringId: Long): Result suspend fun saveParticipation(offeringId: Long): Result + + suspend fun deleteOffering(offeringId: Long): Result } diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingRepository.kt index b0ba9e717..f828fde38 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingRepository.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingRepository.kt @@ -1,12 +1,13 @@ package com.zzang.chongdae.domain.repository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.Filter import com.zzang.chongdae.domain.model.Meetings import com.zzang.chongdae.domain.model.Offering +import com.zzang.chongdae.domain.model.OfferingModifyDomainRequest +import com.zzang.chongdae.domain.model.OfferingWrite import com.zzang.chongdae.domain.model.ProductUrl -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result -import com.zzang.chongdae.presentation.view.write.OfferingWriteUiModel import okhttp3.MultipartBody interface OfferingRepository { @@ -19,7 +20,7 @@ interface OfferingRepository { pageSize: Int?, ): Result, DataError.Network> - suspend fun saveOffering(uiModel: OfferingWriteUiModel): Result + suspend fun saveOffering(offeringWrite: OfferingWrite): Result suspend fun saveProductImageOg(productUrl: String): Result @@ -28,4 +29,9 @@ interface OfferingRepository { suspend fun fetchFilters(): Result, DataError.Network> suspend fun fetchMeetings(offeringId: Long): Result + + suspend fun patchOffering( + offeringId: Long, + offeringModifyDomainRequest: OfferingModifyDomainRequest, + ): Result } diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/ParticipantRepository.kt b/android/app/src/main/java/com/zzang/chongdae/domain/repository/ParticipantRepository.kt index 07e502fd0..88c086054 100644 --- a/android/app/src/main/java/com/zzang/chongdae/domain/repository/ParticipantRepository.kt +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/ParticipantRepository.kt @@ -1,8 +1,8 @@ package com.zzang.chongdae.domain.repository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result import com.zzang.chongdae.domain.model.participant.Participants -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result interface ParticipantRepository { suspend fun fetchParticipants(offeringId: Long): Result diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/util/Error.kt b/android/app/src/main/java/com/zzang/chongdae/domain/util/Error.kt deleted file mode 100644 index cb8861cc6..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/domain/util/Error.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.zzang.chongdae.domain.util - -sealed interface Error diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/AccessTokenExpirationHandler.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/AccessTokenExpirationHandler.kt deleted file mode 100644 index 1f2bcbccd..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/util/AccessTokenExpirationHandler.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zzang.chongdae.presentation.util - -import android.util.Log -import com.zzang.chongdae.domain.model.HttpStatusCode -import com.zzang.chongdae.domain.repository.AuthRepository - -suspend fun handleAccessTokenExpiration( - authRepository: AuthRepository, - it: Throwable, - retryFunction: () -> Unit, -) { - when (it.message) { - HttpStatusCode.UNAUTHORIZED_401.code -> { - Log.e("error", "Access Token 만료") - authRepository.saveRefresh() - retryFunction() - } - } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt index 35fcd013a..4bf4eec18 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt @@ -336,3 +336,14 @@ private fun TextView.setDiscountRate( fun EditText.setOriginPriceHint(originPrice: Int) { this.hint = context.getString(R.string.write_current_split_price).format(originPrice) } + +@BindingAdapter(value = ["debouncedOnClick", "debounceTime"], requireAll = false) +fun View.setDebouncedOnClick( + clickListener: View.OnClickListener?, + debounceTime: Long?, +) { + val safeDebounceTime = debounceTime ?: DEFAULT_DEBOUNCE_TIME + setDebouncedOnClickListener(safeDebounceTime) { + clickListener?.onClick(this) + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/DebouncedClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/DebouncedClickListener.kt new file mode 100644 index 000000000..fde368c31 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/DebouncedClickListener.kt @@ -0,0 +1,21 @@ +package com.zzang.chongdae.presentation.util + +import android.os.SystemClock +import android.view.View + +fun View.setDebouncedOnClickListener( + debounceTime: Long = DEFAULT_DEBOUNCE_TIME, + action: (View) -> Unit, +) { + var lastClickTime = 0L + + this.setOnClickListener { + val currentTime = SystemClock.elapsedRealtime() + if (currentTime - lastClickTime >= debounceTime) { + lastClickTime = currentTime + action(it) + } + } +} + +const val DEFAULT_DEBOUNCE_TIME = 200L diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt index 9be6d959d..04eba11eb 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/MainActivity.kt @@ -2,18 +2,24 @@ package com.zzang.chongdae.presentation.view import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.MotionEvent import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.navigation.ui.setupWithNavController import com.zzang.chongdae.R import com.zzang.chongdae.databinding.ActivityMainBinding +import com.zzang.chongdae.presentation.view.offeringdetail.OfferingDetailFragment +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : AppCompatActivity() { private var _binding: ActivityMainBinding? = null private val binding get() = _binding!! @@ -25,6 +31,7 @@ class MainActivity : AppCompatActivity() { initBinding() initNavController() setupBottomNavigation() + handleDeepLink(intent) } private fun initBinding() { @@ -57,12 +64,45 @@ class MainActivity : AppCompatActivity() { return super.dispatchTouchEvent(motionEvent) } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleDeepLink(intent) + } + + private fun handleDeepLink(intent: Intent) { + val data: Uri? = intent.data + data?.let { uri -> + if (uri.scheme == SCHEME && uri.host == HOST) { + val offeringIdStr = uri.lastPathSegment + + val offeringId = offeringIdStr?.toLongOrNull() + if (offeringId != null) { + openOfferingDetailFragment(offeringId) + } else { + Toast.makeText(this, "공모 ID가 올바르지 않습니다.", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(this, "Deeplink가 올바르지 않습니다.", Toast.LENGTH_SHORT).show() + } + } + } + + private fun openOfferingDetailFragment(offeringId: Long) { + val navController = navHostFragment.navController + val bundle = bundleOf(OfferingDetailFragment.OFFERING_ID_KEY to offeringId) + + navController.navigate(R.id.action_home_fragment_to_offering_detail_fragment, bundle) + } + override fun onDestroy() { super.onDestroy() _binding = null } companion object { + private const val SCHEME = "chongdaeapp" + private const val HOST = "offerings" + fun startActivity(context: Context) = Intent(context, MainActivity::class.java).run { context.startActivity(this) diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsFragment.kt index b804f5a6c..8d5b35bf2 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsFragment.kt @@ -8,13 +8,14 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.FragmentCommentRoomsBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager import com.zzang.chongdae.presentation.view.comment.adapter.CommentRoomsAdapter import com.zzang.chongdae.presentation.view.comment.adapter.OnCommentRoomClickListener import com.zzang.chongdae.presentation.view.commentdetail.CommentDetailActivity +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class CommentRoomsFragment : Fragment(), OnCommentRoomClickListener { private var _binding: FragmentCommentRoomsBinding? = null private val binding get() = _binding!! @@ -30,12 +31,7 @@ class CommentRoomsFragment : Fragment(), OnCommentRoomClickListener { CommentRoomsAdapter(this) } - private val viewModel by viewModels { - CommentRoomsViewModel.getFactory( - authRepository = (requireActivity().application as ChongdaeApp).authRepository, - commentRoomsRepository = (requireActivity().application as ChongdaeApp).commentRoomsRepository, - ) - } + private val viewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsViewModel.kt index ebaf03186..e67720024 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsViewModel.kt @@ -4,56 +4,52 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.map import androidx.lifecycle.viewModelScope +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.CommentRoomsRepositoryQualifier import com.zzang.chongdae.domain.model.CommentRoom -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.CommentRoomsRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import javax.inject.Inject -class CommentRoomsViewModel( - private val authRepository: AuthRepository, - private val commentRoomsRepository: CommentRoomsRepository, -) : ViewModel() { - private val _commentRooms: MutableLiveData> = MutableLiveData() - val commentRooms: LiveData> get() = _commentRooms +@HiltViewModel +class CommentRoomsViewModel + @Inject + constructor( + @AuthRepositoryQualifier private val authRepository: AuthRepository, + @CommentRoomsRepositoryQualifier private val commentRoomsRepository: CommentRoomsRepository, + ) : ViewModel() { + private val _commentRooms: MutableLiveData> = MutableLiveData() + val commentRooms: LiveData> get() = _commentRooms - val isCommentRoomsEmpty: LiveData - get() = - commentRooms.map { - it.isEmpty() - } + val isCommentRoomsEmpty: LiveData + get() = + commentRooms.map { + it.isEmpty() + } - fun updateCommentRooms() { - viewModelScope.launch { - when (val result = commentRoomsRepository.fetchCommentRooms()) { - is Result.Error -> { - Log.e("error", "${result.error}") - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - updateCommentRooms() + fun updateCommentRooms() { + viewModelScope.launch { + when (val result = commentRoomsRepository.fetchCommentRooms()) { + is Result.Error -> { + Log.e("error", "updateCommentRooms: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> updateCommentRooms() + is Result.Error -> return@launch + } + } + else -> {} } - else -> {} } + is Result.Success -> _commentRooms.value = result.data } - is Result.Success -> _commentRooms.value = result.data - } - } - } - - companion object { - @Suppress("UNCHECKED_CAST") - fun getFactory( - authRepository: AuthRepository, - commentRoomsRepository: CommentRoomsRepository, - ) = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return CommentRoomsViewModel(authRepository, commentRoomsRepository) as T } } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt index 33d45c565..f3ab4ed57 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.os.Bundle import android.view.MotionEvent import android.view.inputmethod.InputMethodManager +import android.widget.EditText import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -15,14 +16,18 @@ import androidx.core.view.doOnPreDraw import androidx.databinding.DataBindingUtil import androidx.recyclerview.widget.LinearLayoutManager import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.ActivityCommentDetailBinding +import com.zzang.chongdae.databinding.DialogAlertBinding import com.zzang.chongdae.databinding.DialogUpdateStatusBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener import com.zzang.chongdae.presentation.view.commentdetail.adapter.comment.CommentAdapter import com.zzang.chongdae.presentation.view.commentdetail.adapter.participant.ParticipantAdapter +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { private var _binding: ActivityCommentDetailBinding? = null private val binding get() = _binding!! @@ -31,13 +36,13 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { private val participantAdapter: ParticipantAdapter by lazy { ParticipantAdapter() } private val dialog: Dialog by lazy { Dialog(this) } + @Inject + lateinit var commentDetailAssistedFactory: CommentDetailViewModel.CommentDetailAssistedFactory + private val viewModel: CommentDetailViewModel by viewModels { CommentDetailViewModel.getFactory( + assistedFactory = commentDetailAssistedFactory, offeringId = offeringId, - authRepository = (application as ChongdaeApp).authRepository, - offeringRepository = (application as ChongdaeApp).offeringRepository, - participantRepository = (application as ChongdaeApp).participantRepository, - commentDetailRepository = (application as ChongdaeApp).commentDetailRepository, ) } @@ -70,10 +75,10 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { } private fun setupDrawerToggle() { - binding.ivMoreOptions.setOnClickListener { + binding.ivMoreOptions.setDebouncedOnClickListener { if (binding.drawerLayout.isDrawerOpen(GravityCompat.END)) { binding.drawerLayout.closeDrawer(GravityCompat.END) - return@setOnClickListener + return@setDebouncedOnClickListener } binding.drawerLayout.openDrawer(GravityCompat.END) firebaseAnalyticsManager.logSelectContentEvent( @@ -151,6 +156,18 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { contentType = "button", ) finish() + dialog.dismiss() + } + viewModel.showAlertEvent.observe(this) { + val alertBinding = DialogAlertBinding.inflate(layoutInflater, null, false) + alertBinding.tvDialogMessage.text = getString(R.string.comment_detail_exit_alert) + alertBinding.listener = viewModel + + dialog.setContentView(alertBinding.root) + dialog.show() + } + viewModel.alertCancelEvent.observe(this) { + dialog.dismiss() } } @@ -214,8 +231,19 @@ class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { } override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).apply { - this.hideSoftInputFromWindow(currentFocus?.windowToken, 0) + val view = currentFocus + if (view != null && (view is EditText || view.id == R.id.iv_send_comment)) { + val screenCoords = IntArray(2) + view.getLocationOnScreen(screenCoords) + val x = motionEvent.rawX + view.left - screenCoords[0] + val y = motionEvent.rawY + view.top - screenCoords[1] + + if (motionEvent.action == MotionEvent.ACTION_UP && + (x < view.left || x >= view.right || y < view.top || y > view.bottom) + ) { + val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } } return super.dispatchTouchEvent(motionEvent) } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt index 7902f70be..d67785fb6 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt @@ -5,15 +5,18 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import com.zzang.chongdae.R +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.CommentDetailRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingRepositoryQualifier +import com.zzang.chongdae.di.annotations.ParticipantRepositoryQualifier import com.zzang.chongdae.domain.model.Comment -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.CommentDetailRepository import com.zzang.chongdae.domain.repository.OfferingRepository import com.zzang.chongdae.domain.repository.ParticipantRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel @@ -22,269 +25,308 @@ import com.zzang.chongdae.presentation.view.commentdetail.model.meeting.Meetings import com.zzang.chongdae.presentation.view.commentdetail.model.meeting.MeetingsUiModel.Companion.toUiModel import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ParticipantsUiModel import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ParticipantsUiModel.Companion.toUiModel +import com.zzang.chongdae.presentation.view.common.OnAlertClickListener +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -class CommentDetailViewModel( - private val offeringId: Long, - private val authRepository: AuthRepository, - private val offeringRepository: OfferingRepository, - private val participantRepository: ParticipantRepository, - private val commentDetailRepository: CommentDetailRepository, -) : ViewModel() { - private var cachedComments: List = emptyList() - private var pollJob: Job? = null - val commentContent = MutableLiveData("") +class CommentDetailViewModel + @AssistedInject + constructor( + @Assisted private val offeringId: Long, + @AuthRepositoryQualifier private val authRepository: AuthRepository, + @OfferingRepositoryQualifier private val offeringRepository: OfferingRepository, + @ParticipantRepositoryQualifier private val participantRepository: ParticipantRepository, + @CommentDetailRepositoryQualifier private val commentDetailRepository: CommentDetailRepository, + ) : ViewModel(), + OnAlertClickListener { + @AssistedFactory + interface CommentDetailAssistedFactory { + fun create(offeringId: Long): CommentDetailViewModel + } - private val _comments: MutableLiveData> = MutableLiveData() - val comments: LiveData> get() = _comments + private var cachedComments: List = emptyList() + private var pollJob: Job? = null + val commentContent = MutableLiveData("") - private val _commentOfferingInfo = MutableLiveData() - val commentOfferingInfo: LiveData get() = _commentOfferingInfo + private val _comments: MutableLiveData> = MutableLiveData() + val comments: LiveData> get() = _comments - private val _meetings = MutableLiveData() - val meetings: LiveData get() = _meetings + private val _commentOfferingInfo = MutableLiveData() + val commentOfferingInfo: LiveData get() = _commentOfferingInfo - private val _isCollapsibleViewVisible = MutableLiveData(false) - val isCollapsibleViewVisible: LiveData get() = _isCollapsibleViewVisible + private val _meetings = MutableLiveData() + val meetings: LiveData get() = _meetings - private val _participants = MutableLiveData() - val participants: LiveData get() = _participants + private val _isCollapsibleViewVisible = MutableLiveData(false) + val isCollapsibleViewVisible: LiveData get() = _isCollapsibleViewVisible - private val _showStatusDialogEvent = MutableLiveData() - val showStatusDialogEvent: LiveData get() = _showStatusDialogEvent + private val _participants = MutableLiveData() + val participants: LiveData get() = _participants - private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() - val reportEvent: SingleLiveData get() = _reportEvent + private val _showStatusDialogEvent = MutableLiveData() + val showStatusDialogEvent: LiveData get() = _showStatusDialogEvent - private val _onExitOfferingEvent = MutableSingleLiveData() - val onExitOfferingEvent: SingleLiveData get() = _onExitOfferingEvent + private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() + val reportEvent: SingleLiveData get() = _reportEvent - private val _onBackPressedEvent = MutableSingleLiveData() - val onBackPressedEvent: SingleLiveData get() = _onBackPressedEvent + private val _onExitOfferingEvent = MutableSingleLiveData() + val onExitOfferingEvent: SingleLiveData get() = _onExitOfferingEvent - private val _errorEvent = MutableLiveData() - val errorEvent: MutableLiveData get() = _errorEvent + private val _onBackPressedEvent = MutableSingleLiveData() + val onBackPressedEvent: SingleLiveData get() = _onBackPressedEvent - init { - startPolling() - updateCommentInfo() - loadMeetings() - loadParticipants() - } + private val _errorEvent = MutableLiveData() + val errorEvent: MutableLiveData get() = _errorEvent - private fun startPolling() { - pollJob?.cancel() - pollJob = - viewModelScope.launch { - while (this.isActive) { - loadComments() - delay(1000) + private val _showAlertEvent = MutableSingleLiveData() + val showAlertEvent: SingleLiveData get() = _showAlertEvent + + private val _alertCancelEvent = MutableSingleLiveData() + val alertCancelEvent: SingleLiveData get() = _alertCancelEvent + + init { + startPolling() + updateCommentInfo() + loadMeetings() + loadParticipants() + } + + private fun startPolling() { + pollJob?.cancel() + pollJob = + viewModelScope.launch { + while (this.isActive) { + loadComments() + delay(1000) + } } - } - } + } - private fun updateCommentInfo() { - viewModelScope.launch { - when (val result = commentDetailRepository.fetchCommentOfferingInfo(offeringId)) { - is Result.Success -> _commentOfferingInfo.value = result.data.toUiModel() - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - updateCommentInfo() - } - else -> { - errorEvent.value = result.error.name + private fun updateCommentInfo() { + viewModelScope.launch { + when (val result = commentDetailRepository.fetchCommentOfferingInfo(offeringId)) { + is Result.Success -> _commentOfferingInfo.value = result.data.toUiModel() + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> updateCommentInfo() + is Result.Error -> return@launch + } + } + + else -> { + errorEvent.value = result.error.name + } } - } + } } } - } - fun updateOfferingEvent() { - _showStatusDialogEvent.value = Unit - } + fun updateOfferingEvent() { + _showStatusDialogEvent.value = Unit + } - fun updateOfferingStatus() { - viewModelScope.launch { - when (val result = commentDetailRepository.updateOfferingStatus(offeringId)) { - is Result.Success -> updateCommentInfo() - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - updateOfferingStatus() + fun updateOfferingStatus() { + viewModelScope.launch { + when (val result = commentDetailRepository.updateOfferingStatus(offeringId)) { + is Result.Success -> updateCommentInfo() + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> updateOfferingStatus() + is Result.Error -> return@launch + } + } + + else -> { + errorEvent.value = result.error.name + } } + } + } + } - else -> { - errorEvent.value = result.error.name + fun loadComments() { + viewModelScope.launch { + when (val result = commentDetailRepository.fetchComments(offeringId)) { + is Result.Success -> { + val newComments = result.data + if (cachedComments != newComments) { + _comments.value = newComments + cachedComments = newComments } } + + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> loadComments() + is Result.Error -> return@launch + } + } + + else -> { + pollJob?.cancel() + errorEvent.value = result.error.name + } + } + } } } - } - fun loadComments() { - viewModelScope.launch { - when (val result = commentDetailRepository.fetchComments(offeringId)) { - is Result.Success -> { - val newComments = result.data - if (cachedComments != newComments) { - _comments.value = newComments - cachedComments = newComments + fun postComment() { + val content = commentContent.value?.trim() + if (content.isNullOrEmpty()) { + return + } + viewModelScope.launch { + when (val result = commentDetailRepository.saveComment(offeringId, content)) { + is Result.Success -> { + commentContent.value = "" } - } - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - loadComments() - } - else -> { - pollJob?.cancel() - errorEvent.value = result.error.name + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> postComment() + is Result.Error -> return@launch + } + } + + else -> { + errorEvent.value = result.error.name + } } - } + } } } - } - fun postComment() { - val content = commentContent.value?.trim() - if (content.isNullOrEmpty()) { - return + fun toggleCollapsibleView() { + _isCollapsibleViewVisible.value = _isCollapsibleViewVisible.value?.not() + if (_isCollapsibleViewVisible.value == true) { + loadMeetings() + } } - viewModelScope.launch { - when (val result = commentDetailRepository.saveComment(offeringId, content)) { - is Result.Success -> { - commentContent.value = "" + private fun loadParticipants() { + viewModelScope.launch { + when (val result = participantRepository.fetchParticipants(offeringId)) { + is Result.Success -> _participants.value = result.data.toUiModel() + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> loadParticipants() + is Result.Error -> return@launch + } + } + + else -> { + errorEvent.value = result.error.name + } + } } + } + } - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - postComment() - } - else -> { - errorEvent.value = result.error.name + private fun loadMeetings() { + viewModelScope.launch { + when (val result = offeringRepository.fetchMeetings(offeringId)) { + is Result.Success -> _meetings.value = result.data.toUiModel() + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> loadMeetings() + is Result.Error -> return@launch + } + } + + else -> { + errorEvent.value = result.error.name + } } - } + } } } - } - fun toggleCollapsibleView() { - _isCollapsibleViewVisible.value = _isCollapsibleViewVisible.value?.not() - if (_isCollapsibleViewVisible.value == true) { - loadMeetings() + fun onClickReport() { + _reportEvent.setValue(R.string.report_url) } - } - private fun loadParticipants() { - viewModelScope.launch { - when (val result = participantRepository.fetchParticipants(offeringId)) { - is Result.Success -> _participants.value = result.data.toUiModel() - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - loadParticipants() - } - else -> { - errorEvent.value = result.error.name - } + fun exitOffering() { + viewModelScope.launch { + when (val result = participantRepository.deleteParticipations(offeringId)) { + is Result.Success -> { + _onExitOfferingEvent.setValue(Unit) + pollJob?.cancel() } - } - } - } - private fun loadMeetings() { - viewModelScope.launch { - when (val result = offeringRepository.fetchMeetings(offeringId)) { - is Result.Success -> _meetings.value = result.data.toUiModel() - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - loadMeetings() + is Result.Error -> + when (result.error) { + DataError.Network.NULL -> { + _onExitOfferingEvent.setValue(Unit) + pollJob?.cancel() + } + + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> exitOffering() + is Result.Error -> return@launch + } + } + + else -> { + _errorEvent.value = result.error.name + } } - else -> { - errorEvent.value = result.error.name - } - } + } } } - } - fun onClickReport() { - _reportEvent.setValue(R.string.report_url) - } + fun onBackClick() { + _onBackPressedEvent.setValue(Unit) + } - fun exitOffering() { - viewModelScope.launch { - when (val result = participantRepository.deleteParticipations(offeringId)) { - is Result.Success -> { - _onExitOfferingEvent.setValue(Unit) - pollJob?.cancel() + override fun onCleared() { + super.onCleared() + stopPolling() + } + + private fun stopPolling() { + pollJob?.cancel() + } + + companion object { + @Suppress("UNCHECKED_CAST") + fun getFactory( + assistedFactory: CommentDetailAssistedFactory, + offeringId: Long, + ) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return assistedFactory.create(offeringId) as T } - is Result.Error -> - when (result.error) { - DataError.Network.NULL -> { - _onExitOfferingEvent.setValue(Unit) - pollJob?.cancel() - } - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - exitOffering() - } - else -> { - _errorEvent.value = result.error.name - } - } } } - } - fun onBackClick() { - _onBackPressedEvent.setValue(Unit) - } - - override fun onCleared() { - super.onCleared() - stopPolling() - } + fun onExitClick() { + _showAlertEvent.setValue(Unit) + } - private fun stopPolling() { - pollJob?.cancel() - } + override fun onClickConfirm() { + exitOffering() + } - companion object { - @Suppress("UNCHECKED_CAST") - fun getFactory( - offeringId: Long, - authRepository: AuthRepository, - offeringRepository: OfferingRepository, - participantRepository: ParticipantRepository, - commentDetailRepository: CommentDetailRepository, - ) = object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return CommentDetailViewModel( - offeringId = offeringId, - authRepository = authRepository, - offeringRepository = offeringRepository, - participantRepository = participantRepository, - commentDetailRepository = commentDetailRepository, - ) as T - } + override fun onClickCancel() { + _alertCancelEvent.setValue(Unit) } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/common/OnAlertClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/common/OnAlertClickListener.kt new file mode 100644 index 000000000..183af25ec --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/common/OnAlertClickListener.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.presentation.view.common + +interface OnAlertClickListener { + fun onClickConfirm() + + fun onClickCancel() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt index 81fcd2dd5..ae52cdfd0 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt @@ -22,33 +22,27 @@ import androidx.paging.PagingData import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp -import com.zzang.chongdae.ChongdaeApp.Companion.dataStore import com.zzang.chongdae.R -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.FragmentHomeBinding import com.zzang.chongdae.domain.model.FilterName -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener import com.zzang.chongdae.presentation.view.MainActivity import com.zzang.chongdae.presentation.view.home.adapter.OfferingAdapter import com.zzang.chongdae.presentation.view.login.LoginActivity import com.zzang.chongdae.presentation.view.offeringdetail.OfferingDetailFragment import com.zzang.chongdae.presentation.view.write.OfferingWriteOptionalFragment +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +@AndroidEntryPoint class HomeFragment : Fragment(), OnOfferingClickListener { private var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! private var toast: Toast? = null private lateinit var offeringAdapter: OfferingAdapter - private val viewModel: OfferingViewModel by viewModels { - OfferingViewModel.getFactory( - offeringRepository = (requireActivity().application as ChongdaeApp).offeringRepository, - authRepository = (requireActivity().applicationContext as ChongdaeApp).authRepository, - userPreferencesDataStore = UserPreferencesDataStore(requireActivity().applicationContext.dataStore), - ) - } + private val viewModel: OfferingViewModel by viewModels() private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(requireContext()) @@ -77,6 +71,14 @@ class HomeFragment : Fragment(), OnOfferingClickListener { navigateToOfferingWriteFragment() initFragmentResultListener() setOnCheckboxListener() + setOnSwipeRefreshListener() + } + + private fun setOnSwipeRefreshListener() { + binding.swipeLayout.setOnRefreshListener { + binding.swipeLayout.isRefreshing = false + viewModel.swipeRefresh() + } } private fun setOnCheckboxListener() { @@ -119,8 +121,12 @@ class HomeFragment : Fragment(), OnOfferingClickListener { viewModel.fetchUpdatedOffering(bundle.getLong(OfferingDetailFragment.UPDATED_OFFERING_ID_KEY)) } + setFragmentResultListener(OfferingDetailFragment.OFFERING_DETAIL_BUNDLE_KEY) { _, bundle -> + viewModel.refreshOfferings(bundle.getBoolean(OfferingDetailFragment.DELETED_OFFERING_ID_KEY)) + } + setFragmentResultListener(OfferingWriteOptionalFragment.OFFERING_WRITE_BUNDLE_KEY) { _, bundle -> - viewModel.refreshOfferingsByOfferingWriteEvent( + viewModel.refreshOfferings( bundle.getBoolean( OfferingWriteOptionalFragment.NEW_OFFERING_EVENT_KEY, ), @@ -164,6 +170,13 @@ class HomeFragment : Fragment(), OnOfferingClickListener { private fun initAdapter() { offeringAdapter = OfferingAdapter(this) + offeringAdapter.addLoadStateListener { + if (it.append.endOfPaginationReached) { + binding.tvEmptyItem.isVisible = isItemEmpty() + } else { + binding.tvEmptyItem.isVisible = false + } + } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { offeringAdapter.loadStateFlow.collect { loadState -> @@ -180,6 +193,8 @@ class HomeFragment : Fragment(), OnOfferingClickListener { ) } + private fun isItemEmpty() = offeringAdapter.itemCount == 0 + private fun setUpOfferingsObserve() { viewModel.offeringsRefreshEvent.observe(viewLifecycleOwner) { offeringAdapter.submitData(viewLifecycleOwner.lifecycle, PagingData.empty()) @@ -229,7 +244,7 @@ class HomeFragment : Fragment(), OnOfferingClickListener { } private fun navigateToOfferingWriteFragment() { - binding.fabCreateOffering.setOnClickListener { + binding.fabCreateOffering.setDebouncedOnClickListener { findNavController().navigate(R.id.action_home_fragment_to_offering_write_fragment) } } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt index c593cd553..d8ec6d13d 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt @@ -4,239 +4,234 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.map import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import com.zzang.chongdae.R -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingRepositoryQualifier import com.zzang.chongdae.domain.model.Filter import com.zzang.chongdae.domain.model.FilterName import com.zzang.chongdae.domain.model.Offering import com.zzang.chongdae.domain.paging.OfferingPagingSource -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.OfferingRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OfferingViewModel + @Inject + constructor( + @OfferingRepositoryQualifier private val offeringRepository: OfferingRepository, + @AuthRepositoryQualifier private val authRepository: AuthRepository, + private val userPreferencesDataStore: UserPreferencesDataStore, + ) : ViewModel(), OnFilterClickListener, OnSearchClickListener { + private val _offerings = MutableLiveData>() + val offerings: LiveData> get() = _offerings + + val search: MutableLiveData = MutableLiveData(null) + + private val _filters: MutableLiveData> = MutableLiveData() + val filters: LiveData> get() = _filters + + val joinableFilter: LiveData = + _filters.map { + it.first { it.name == FilterName.JOINABLE } + } -class OfferingViewModel( - private val offeringRepository: OfferingRepository, - private val authRepository: AuthRepository, - private val userPreferencesDataStore: UserPreferencesDataStore, -) : ViewModel(), OnFilterClickListener, OnSearchClickListener { - private val _offerings = MutableLiveData>() - val offerings: LiveData> get() = _offerings - - val search: MutableLiveData = MutableLiveData(null) - - private val _filters: MutableLiveData> = MutableLiveData() - val filters: LiveData> get() = _filters - - val joinableFilter: LiveData = - _filters.map { - it.first { it.name == FilterName.JOINABLE } - } - - val imminentFilter: LiveData = - _filters.map { - it.first { it.name == FilterName.IMMINENT } - } + val imminentFilter: LiveData = + _filters.map { + it.first { it.name == FilterName.IMMINENT } + } - val highDiscountFilter: LiveData = - _filters.map { - it.first { it.name == FilterName.HIGH_DISCOUNT } - } + val highDiscountFilter: LiveData = + _filters.map { + it.first { it.name == FilterName.HIGH_DISCOUNT } + } - private val _selectedFilter: MutableLiveData = MutableLiveData() - val selectedFilter: LiveData get() = _selectedFilter + private val _selectedFilter: MutableLiveData = MutableLiveData() + val selectedFilter: LiveData get() = _selectedFilter - private val _searchEvent: MutableSingleLiveData = MutableSingleLiveData(null) - val searchEvent: SingleLiveData get() = _searchEvent + private val _searchEvent: MutableSingleLiveData = MutableSingleLiveData(null) + val searchEvent: SingleLiveData get() = _searchEvent - private val _filterOfferingsEvent: MutableSingleLiveData = MutableSingleLiveData() - val filterOfferingsEvent: SingleLiveData get() = _filterOfferingsEvent + private val _filterOfferingsEvent: MutableSingleLiveData = MutableSingleLiveData() + val filterOfferingsEvent: SingleLiveData get() = _filterOfferingsEvent - private val _updatedOffering: MutableSingleLiveData> = - MutableSingleLiveData(mutableListOf()) - val updatedOffering: SingleLiveData> get() = _updatedOffering + private val _updatedOffering: MutableSingleLiveData> = + MutableSingleLiveData(mutableListOf()) + val updatedOffering: SingleLiveData> get() = _updatedOffering - private val _offeringsRefreshEvent: MutableSingleLiveData = MutableSingleLiveData() - val offeringsRefreshEvent: SingleLiveData get() = _offeringsRefreshEvent + private val _offeringsRefreshEvent: MutableSingleLiveData = MutableSingleLiveData() + val offeringsRefreshEvent: SingleLiveData get() = _offeringsRefreshEvent - private val _error: MutableSingleLiveData = MutableSingleLiveData() - val error: SingleLiveData get() = _error + private val _error: MutableSingleLiveData = MutableSingleLiveData() + val error: SingleLiveData get() = _error - private val _refreshTokenExpiredEvent: MutableSingleLiveData = MutableSingleLiveData() - val refreshTokenExpiredEvent: SingleLiveData get() = _refreshTokenExpiredEvent + private val _refreshTokenExpiredEvent: MutableSingleLiveData = MutableSingleLiveData() + val refreshTokenExpiredEvent: SingleLiveData get() = _refreshTokenExpiredEvent - init { - fetchFilters() - fetchOfferings() - } + init { + fetchFilters() + fetchOfferings() + } - private fun fetchOfferings() { - viewModelScope.launch { - Pager( - config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false), - pagingSourceFactory = { - OfferingPagingSource( - offeringRepository, - authRepository, - search.value, - _selectedFilter.value, - ) { fetchOfferings() } - }, - ).flow.cachedIn(viewModelScope).collectLatest { pagingData -> - _offerings.value = - pagingData.map { - if (isSearchKeywordExist() && isTitleContainSearchKeyword(it)) { - return@map it.copy( - title = - highlightSearchKeyword( - it.title, - search.value!!, - ), - ) + private fun fetchOfferings() { + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false), + pagingSourceFactory = { + OfferingPagingSource( + offeringRepository, + authRepository, + search.value, + _selectedFilter.value, + ) { fetchOfferings() } + }, + ).flow.cachedIn(viewModelScope).collectLatest { pagingData -> + _offerings.value = + pagingData.map { + if (isSearchKeywordExist() && isTitleContainSearchKeyword(it)) { + return@map it.copy( + title = + highlightSearchKeyword( + it.title, + search.value!!, + ), + ) + } + it.copy(title = removeAsterisks(it.title)) } - it.copy(title = removeAsterisks(it.title)) - } + } } } - } - - private fun removeAsterisks(title: String): String { - return title.replace("*", "") - } - - private fun highlightSearchKeyword( - title: String, - keyword: String, - ): String { - return title.replace(keyword, "*$keyword*") - } - - private fun isTitleContainSearchKeyword(it: Offering) = (search.value as String) in it.title - - private fun isSearchKeywordExist() = (search.value != null) && (search.value != "") - private fun fetchFilters() { - viewModelScope.launch { - when (val result = offeringRepository.fetchFilters()) { - is Result.Error -> { - Log.d("error", "fetchFilters: ${result.error}") - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - fetchFilters() - } - - DataError.Network.FORBIDDEN -> { - userPreferencesDataStore.removeAllData() - _refreshTokenExpiredEvent.setValue(Unit) - } + private fun removeAsterisks(title: String): String { + return title.replace("*", "") + } - DataError.Network.BAD_REQUEST -> { - _error.setValue(R.string.home_filter_error_message) - } + private fun highlightSearchKeyword( + title: String, + keyword: String, + ): String { + return title.replace(keyword, "*$keyword*") + } - else -> { - Log.e("error", "fetchFilters Error: ${result.error.name}") + private fun isTitleContainSearchKeyword(it: Offering) = (search.value as String) in it.title + + private fun isSearchKeywordExist() = (search.value != null) && (search.value != "") + + private fun fetchFilters() { + viewModelScope.launch { + when (val result = offeringRepository.fetchFilters()) { + is Result.Error -> { + Log.d("error", "fetchFilters: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> fetchFilters() + is Result.Error -> { + userPreferencesDataStore.removeAllData() + _refreshTokenExpiredEvent.setValue(Unit) + return@launch + } + } + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.home_filter_error_message) + } + + else -> { + Log.e("error", "fetchFilters Error: ${result.error.name}") + } } } - } - is Result.Success -> { - _filters.value = result.data + is Result.Success -> { + _filters.value = result.data + } } } } - } - - override fun onClickFilter( - filterName: FilterName, - isChecked: Boolean, - ) { - if (isChecked) { - _selectedFilter.value = filterName.toString() - } else { - _selectedFilter.value = null - } - _filterOfferingsEvent.setValue(Unit) - fetchOfferings() - } + override fun onClickFilter( + filterName: FilterName, + isChecked: Boolean, + ) { + if (isChecked) { + _selectedFilter.value = filterName.toString() + } else { + _selectedFilter.value = null + } - override fun onClickSearchButton() { - _searchEvent.setValue(search.value) - fetchOfferings() - } + _filterOfferingsEvent.setValue(Unit) + fetchOfferings() + } - fun fetchUpdatedOffering(offeringId: Long) { - viewModelScope.launch { - when (val result = offeringRepository.fetchOffering(offeringId)) { - is Result.Error -> { - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - fetchUpdatedOffering(offeringId) - } + override fun onClickSearchButton() { + _searchEvent.setValue(search.value) + fetchOfferings() + } - DataError.Network.BAD_REQUEST -> { - _error.setValue(R.string.home_updated_offering_error_mesasge) + fun fetchUpdatedOffering(offeringId: Long) { + viewModelScope.launch { + when (val result = offeringRepository.fetchOffering(offeringId)) { + is Result.Error -> { + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> fetchUpdatedOffering(offeringId) + is Result.Error -> return@launch + } + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.home_updated_offering_error_mesasge) + } + + else -> { + Log.e("error", "fetchUpdatedOffering Error: ${result.error.name}") + } } + } - else -> { - Log.e("error", "fetchUpdatedOffering Error: ${result.error.name}") - } + is Result.Success -> { + val updatedOfferings = _updatedOffering.getValue() ?: mutableListOf() + updatedOfferings.add(result.data) + _updatedOffering.setValue(updatedOfferings) } } + } + } - is Result.Success -> { - val updatedOfferings = _updatedOffering.getValue() ?: mutableListOf() - updatedOfferings.add(result.data) - _updatedOffering.setValue(updatedOfferings) - } + fun refreshOfferings(isSuccess: Boolean) { + if (isSuccess) { + search.value = null + _selectedFilter.value = null + _offeringsRefreshEvent.setValue(Unit) + fetchOfferings() } } - } - fun refreshOfferingsByOfferingWriteEvent(isSuccess: Boolean) { - if (isSuccess) { - search.value = null - _selectedFilter.value = null + fun swipeRefresh() { _offeringsRefreshEvent.setValue(Unit) fetchOfferings() } - } - companion object { - private const val PAGE_SIZE = 10 - - @Suppress("UNCHECKED_CAST") - fun getFactory( - offeringRepository: OfferingRepository, - authRepository: AuthRepository, - userPreferencesDataStore: UserPreferencesDataStore, - ) = object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return OfferingViewModel( - offeringRepository, - authRepository, - userPreferencesDataStore, - ) as T - } + companion object { + private const val PAGE_SIZE = 10 } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt index 450fff06e..a4e33ec8f 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt @@ -11,23 +11,17 @@ import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient -import com.zzang.chongdae.ChongdaeApp -import com.zzang.chongdae.ChongdaeApp.Companion.dataStore -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.ActivityLoginBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager import com.zzang.chongdae.presentation.view.MainActivity +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class LoginActivity : AppCompatActivity(), OnAuthClickListener { private var _binding: ActivityLoginBinding? = null private val binding get() = _binding!! - private val viewModel: LoginViewModel by viewModels { - LoginViewModel.getFactory( - authRepository = (application as ChongdaeApp).authRepository, - userPreferencesDataStore = UserPreferencesDataStore(applicationContext.dataStore), - ) - } + private val viewModel: LoginViewModel by viewModels() private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(this) diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt index c3726d2c3..00815f981 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt @@ -2,67 +2,56 @@ package com.zzang.chongdae.presentation.view.login import android.util.Log import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore -import com.zzang.chongdae.domain.repository.AuthRepository -import com.zzang.chongdae.domain.util.Result +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch - -class LoginViewModel( - private val authRepository: AuthRepository, - private val userPreferencesDataStore: UserPreferencesDataStore, -) : ViewModel() { - private val _loginSuccessEvent: MutableSingleLiveData = MutableSingleLiveData() - val loginSuccessEvent: SingleLiveData get() = _loginSuccessEvent - - private val _alreadyLoggedInEvent: MutableSingleLiveData = MutableSingleLiveData() - val alreadyLoggedInEvent: SingleLiveData get() = _alreadyLoggedInEvent - - init { - makeAlreadyLoggedInEvent() - } - - private fun makeAlreadyLoggedInEvent() { - viewModelScope.launch { - val accessToken = userPreferencesDataStore.accessTokenFlow.first() - if (accessToken != null) { - _alreadyLoggedInEvent.setValue(Unit) - } +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel + @Inject + constructor( + @AuthRepositoryQualifier private val authRepository: AuthRepository, + private val userPreferencesDataStore: UserPreferencesDataStore, + ) : ViewModel() { + private val _loginSuccessEvent: MutableSingleLiveData = MutableSingleLiveData() + val loginSuccessEvent: SingleLiveData get() = _loginSuccessEvent + + private val _alreadyLoggedInEvent: MutableSingleLiveData = MutableSingleLiveData() + val alreadyLoggedInEvent: SingleLiveData get() = _alreadyLoggedInEvent + + init { + makeAlreadyLoggedInEvent() } - } - - fun postLogin(accessToken: String) { - viewModelScope.launch { - when (val result = authRepository.saveLogin(accessToken = accessToken)) { - is Result.Success -> { - userPreferencesDataStore.saveMember(result.data.memberId, result.data.nickName) - _loginSuccessEvent.setValue(Unit) - } - is Result.Error -> { - Log.e("error", "postLogin: ${result.error}") + private fun makeAlreadyLoggedInEvent() { + viewModelScope.launch { + val accessToken = userPreferencesDataStore.accessTokenFlow.first() + if (accessToken != null) { + _alreadyLoggedInEvent.setValue(Unit) } } } - } - companion object { - @Suppress("UNCHECKED_CAST") - fun getFactory( - authRepository: AuthRepository, - userPreferencesDataStore: UserPreferencesDataStore, - ) = object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return LoginViewModel(authRepository, userPreferencesDataStore) as T + fun postLogin(accessToken: String) { + viewModelScope.launch { + when (val result = authRepository.saveLogin(accessToken = accessToken)) { + is Result.Success -> { + userPreferencesDataStore.saveMember(result.data.memberId, result.data.nickName) + _loginSuccessEvent.setValue(Unit) + } + + is Result.Error -> { + Log.e("error", "postLogin: ${result.error}") + } + } } } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageFragment.kt index c00226a20..fd6a3ac10 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageFragment.kt @@ -1,5 +1,6 @@ package com.zzang.chongdae.presentation.view.mypage +import android.app.Dialog import android.content.Intent import android.net.Uri import android.os.Bundle @@ -9,19 +10,24 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp.Companion.dataStore -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager +import com.zzang.chongdae.databinding.DialogAlertBinding import com.zzang.chongdae.databinding.FragmentMyPageBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager import com.zzang.chongdae.presentation.view.login.LoginActivity +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MyPageFragment : Fragment() { private var _binding: FragmentMyPageBinding? = null private val binding get() = _binding!! - private val viewModel: MyPageViewModel by viewModels { - MyPageViewModel.getFactory(UserPreferencesDataStore(requireContext().dataStore)) - } + private var _alertBinding: DialogAlertBinding? = null + private val alertBinding get() = _alertBinding!! + + private val alert: Dialog by lazy { Dialog(requireContext()) } + + private val viewModel: MyPageViewModel by viewModels() private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(requireContext()) @@ -46,6 +52,10 @@ class MyPageFragment : Fragment() { _binding = FragmentMyPageBinding.inflate(inflater, container, false) binding.vm = viewModel binding.lifecycleOwner = viewLifecycleOwner + + _alertBinding = DialogAlertBinding.inflate(inflater, container, false) + alertBinding.listener = viewModel + alertBinding.tvDialogMessage.text = getString(R.string.my_page_logout_dialog_description) } override fun onViewCreated( @@ -60,6 +70,13 @@ class MyPageFragment : Fragment() { viewModel.openUrlInBrowserEvent.observe(viewLifecycleOwner) { openUrlInBrowser(it) } + viewModel.showAlertEvent.observe(viewLifecycleOwner) { + alert.setContentView(alertBinding.root) + alert.show() + } + viewModel.alertCancelEvent.observe(viewLifecycleOwner) { + alert.dismiss() + } viewModel.logoutEvent.observe(viewLifecycleOwner) { clearDataAndLogout() } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt index 8a996a133..a00cb7664 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt @@ -2,59 +2,67 @@ package com.zzang.chongdae.presentation.view.mypage import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras -import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData +import com.zzang.chongdae.presentation.view.common.OnAlertClickListener +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import javax.inject.Inject -class MyPageViewModel(private val userPreferencesDataStore: UserPreferencesDataStore) : ViewModel() { - val nickName: LiveData = userPreferencesDataStore.nickNameFlow.asLiveData() +@HiltViewModel +class MyPageViewModel + @Inject + constructor( + private val userPreferencesDataStore: UserPreferencesDataStore, + ) : ViewModel(), + OnAlertClickListener { + val nickName: LiveData = userPreferencesDataStore.nickNameFlow.asLiveData() - private val _openUrlInBrowserEvent = MutableSingleLiveData() - val openUrlInBrowserEvent: SingleLiveData get() = _openUrlInBrowserEvent + private val _openUrlInBrowserEvent = MutableSingleLiveData() + val openUrlInBrowserEvent: SingleLiveData get() = _openUrlInBrowserEvent - private val _logoutEvent = MutableSingleLiveData() - val logoutEvent: SingleLiveData get() = _logoutEvent + private val _logoutEvent = MutableSingleLiveData() + val logoutEvent: SingleLiveData get() = _logoutEvent - private val termsOfUseUrl = - "https://silent-apparatus-578.notion.site/f1f5cd1609d4469dba3ab7d0f95c183c?pvs=4" - private val privacyUrl = - "https://silent-apparatus-578.notion.site/f1f5cd1609d4469dba3ab7d0f95c183c?pvs=4" - private val withdrawalUrl = "https://forms.gle/z5MUzVTUoyunfqEu8" + private val _showAlertEvent = MutableSingleLiveData() + val showAlertEvent: SingleLiveData get() = _showAlertEvent - fun onClickTermsOfUse() { - _openUrlInBrowserEvent.setValue(termsOfUseUrl) - } + private val _alertCancelEvent = MutableSingleLiveData() + val alertCancelEvent: SingleLiveData get() = _alertCancelEvent - fun onClickPrivacy() { - _openUrlInBrowserEvent.setValue(privacyUrl) - } + private val termsOfUseUrl = + "https://silent-apparatus-578.notion.site/f1f5cd1609d4469dba3ab7d0f95c183c?pvs=4" + private val privacyUrl = + "https://silent-apparatus-578.notion.site/f1f5cd1609d4469dba3ab7d0f95c183c?pvs=4" + private val withdrawalUrl = "https://forms.gle/z5MUzVTUoyunfqEu8" - fun onClickLogout() { - viewModelScope.launch { - userPreferencesDataStore.removeAllData() + fun onClickTermsOfUse() { + _openUrlInBrowserEvent.setValue(termsOfUseUrl) } - _logoutEvent.setValue(Unit) - } - fun onClickWithdrawal() { - _openUrlInBrowserEvent.setValue(withdrawalUrl) - } + fun onClickPrivacy() { + _openUrlInBrowserEvent.setValue(privacyUrl) + } + + fun onClickLogout() { + _showAlertEvent.setValue(Unit) + } + + fun onClickWithdrawal() { + _openUrlInBrowserEvent.setValue(withdrawalUrl) + } - companion object { - @Suppress("UNCHECKED_CAST") - fun getFactory(userPreferencesDataStore: UserPreferencesDataStore) = - object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return MyPageViewModel(userPreferencesDataStore) as T - } + override fun onClickConfirm() { + viewModelScope.launch { + userPreferencesDataStore.removeAllData() } + _logoutEvent.setValue(Unit) + } + + override fun onClickCancel() { + _alertCancelEvent.setValue(Unit) + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt index 1801288be..30d0cbaaf 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt @@ -1,5 +1,6 @@ package com.zzang.chongdae.presentation.view.offeringdetail +import android.app.Dialog import android.content.Intent import android.net.Uri import android.os.Bundle @@ -14,26 +15,35 @@ import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp +import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager +import com.zzang.chongdae.databinding.DialogAlertBinding +import com.zzang.chongdae.databinding.DialogDeleteOfferingBinding import com.zzang.chongdae.databinding.FragmentOfferingDetailBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager import com.zzang.chongdae.presentation.view.MainActivity import com.zzang.chongdae.presentation.view.commentdetail.CommentDetailActivity import com.zzang.chongdae.presentation.view.home.HomeFragment +import com.zzang.chongdae.presentation.view.login.LoginActivity +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject -class OfferingDetailFragment : Fragment() { +@AndroidEntryPoint +class OfferingDetailFragment : Fragment(), OnOfferingDeleteAlertClickListener { private var _binding: FragmentOfferingDetailBinding? = null private val binding get() = _binding!! private var toast: Toast? = null private val offeringId by lazy { - arguments?.getLong(HomeFragment.OFFERING_ID) - ?: throw IllegalArgumentException() + arguments?.getLong(HomeFragment.OFFERING_ID) ?: throw IllegalArgumentException() } + private val dialog: Dialog by lazy { Dialog(requireContext()) } + + @Inject + lateinit var offeringDetailAssistedFactory: OfferingDetailViewModel.OfferingDetailAssistedFactory + private val viewModel: OfferingDetailViewModel by viewModels { OfferingDetailViewModel.getFactory( + assistedFactory = offeringDetailAssistedFactory, offeringId = offeringId, - offeringDetailRepository = (requireActivity().application as ChongdaeApp).offeringDetailRepository, - authRepository = (requireActivity().applicationContext as ChongdaeApp).authRepository, ) } @@ -64,6 +74,11 @@ class OfferingDetailFragment : Fragment() { setUpObserve() } + override fun onResume() { + super.onResume() + viewModel.loadOffering() + } + private fun setUpObserve() { viewModel.updatedOfferingId.observe(viewLifecycleOwner) { setFragmentResult(OFFERING_DETAIL_BUNDLE_KEY, bundleOf(UPDATED_OFFERING_ID_KEY to it)) @@ -80,6 +95,64 @@ class OfferingDetailFragment : Fragment() { viewModel.productLinkRedirectEvent.observe(viewLifecycleOwner) { productURL -> openUrlInBrowser(productURL) } + + viewModel.modifyOfferingEvent.observe(viewLifecycleOwner) { + findNavController().navigate( + R.id.action_offering_detail_fragment_to_offering_modify_essential_fragment, + bundleOf(HomeFragment.OFFERING_ID to offeringId), + ) + } + + viewModel.deleteOfferingEvent.observe(viewLifecycleOwner) { + showUpdateStatusDialog() + } + + viewModel.deleteOfferingSuccessEvent.observe(viewLifecycleOwner) { + dialog.dismiss() + findNavController().popBackStack() + setFragmentResult(OFFERING_DETAIL_BUNDLE_KEY, bundleOf(DELETED_OFFERING_ID_KEY to true)) + showToast(R.string.offering_detail_delete_complete_message) + } + + viewModel.showAlertEvent.observe(viewLifecycleOwner) { + val alertBinding = DialogAlertBinding.inflate(layoutInflater, null, false) + alertBinding.tvDialogMessage.text = getString(R.string.offering_detail_participate_alert) + alertBinding.listener = viewModel + + dialog.setContentView(alertBinding.root) + dialog.show() + } + + viewModel.alertCancelEvent.observe(viewLifecycleOwner) { + dialog.dismiss() + } + } + + override fun onClickConfirm() { + viewModel.deleteOffering(offeringId) + firebaseAnalyticsManager.logSelectContentEvent( + id = "delete_offering_event", + name = "delete_offering_event", + contentType = "button", + ) + } + + override fun onClickCancel() { + firebaseAnalyticsManager.logSelectContentEvent( + id = "cancel_delete_offering_event", + name = "cancel_delete_offering_event", + contentType = "button", + ) + dialog.dismiss() + } + + private fun showUpdateStatusDialog() { + val dialogBinding = DialogDeleteOfferingBinding.inflate(layoutInflater, null, false) + + dialogBinding.listener = this + + dialog.setContentView(dialogBinding.root) + dialog.show() } private fun openUrlInBrowser(url: String) { @@ -105,7 +178,7 @@ class OfferingDetailFragment : Fragment() { } private fun setUpMoveCommentDetailEventObserve() { - viewModel.commentDetailEvent.observe(this) { + viewModel.commentDetailEvent.observe(viewLifecycleOwner) { firebaseAnalyticsManager.logSelectContentEvent( id = "Offering_Item_ID: $offeringId", name = "participate_offering_event", @@ -113,6 +186,11 @@ class OfferingDetailFragment : Fragment() { ) findNavController().popBackStack() CommentDetailActivity.startActivity(requireContext(), offeringId) + dialog.dismiss() + } + + viewModel.refreshTokenExpiredEvent.observe(viewLifecycleOwner) { + LoginActivity.startActivity(requireContext()) } } @@ -132,5 +210,7 @@ class OfferingDetailFragment : Fragment() { companion object { const val OFFERING_DETAIL_BUNDLE_KEY = "offering_detail_bundle_key" const val UPDATED_OFFERING_ID_KEY = "updated_offering_id" + const val DELETED_OFFERING_ID_KEY = "deleted_offering_id" + const val OFFERING_ID_KEY = "offering_id" } } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt index cf65a7a1a..21455ec1e 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt @@ -6,161 +6,255 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import com.zzang.chongdae.R +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.datastore.UserPreferencesDataStore +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingDetailRepositoryQualifier import com.zzang.chongdae.domain.model.OfferingCondition import com.zzang.chongdae.domain.model.OfferingCondition.Companion.isAvailable import com.zzang.chongdae.domain.model.OfferingDetail -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.OfferingDetailRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData +import com.zzang.chongdae.presentation.view.common.OnAlertClickListener +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.launch -class OfferingDetailViewModel( - private val offeringId: Long, - private val offeringDetailRepository: OfferingDetailRepository, - private val authRepository: AuthRepository, -) : ViewModel(), - OnParticipationClickListener, - OnOfferingReportClickListener, - OnMoveCommentDetailClickListener, - OnProductLinkClickListener { - private val _offeringDetail: MutableLiveData = MutableLiveData() - val offeringDetail: LiveData get() = _offeringDetail +class OfferingDetailViewModel + @AssistedInject + constructor( + @Assisted private val offeringId: Long, + @OfferingDetailRepositoryQualifier val offeringDetailRepository: OfferingDetailRepository, + @AuthRepositoryQualifier private val authRepository: AuthRepository, + private val userPreferencesDataStore: UserPreferencesDataStore, + ) : ViewModel(), + OnParticipationClickListener, + OnOfferingReportClickListener, + OnMoveCommentDetailClickListener, + OnProductLinkClickListener, + OnOfferingModifyClickListener, + OnAlertClickListener { + @AssistedFactory + interface OfferingDetailAssistedFactory { + fun create(offeringId: Long): OfferingDetailViewModel + } - private val _currentCount: MutableLiveData = MutableLiveData() - val currentCount: LiveData get() = _currentCount + private val _offeringDetail: MutableLiveData = MutableLiveData() + val offeringDetail: LiveData get() = _offeringDetail - private val _offeringCondition: MutableLiveData = MutableLiveData() - val offeringCondition: LiveData get() = _offeringCondition + private val _currentCount: MutableLiveData = MutableLiveData() + val currentCount: LiveData get() = _currentCount - private val _isParticipated: MutableLiveData = MutableLiveData(false) - val isParticipated: LiveData get() = _isParticipated + private val _offeringCondition: MutableLiveData = MutableLiveData() + val offeringCondition: LiveData get() = _offeringCondition - private val _isParticipationAvailable: MutableLiveData = MutableLiveData(true) - val isParticipationAvailable: LiveData get() = _isParticipationAvailable + private val _isParticipated: MutableLiveData = MutableLiveData(false) + val isParticipated: LiveData get() = _isParticipated - private val _isRepresentative: MutableLiveData = MutableLiveData(true) - val isRepresentative: LiveData get() = _isRepresentative + private val _isParticipationAvailable: MutableLiveData = MutableLiveData(true) + val isParticipationAvailable: LiveData get() = _isParticipationAvailable - private val _commentDetailEvent: MutableSingleLiveData = MutableSingleLiveData() - val commentDetailEvent: SingleLiveData get() = _commentDetailEvent + private val _isRepresentative: MutableLiveData = MutableLiveData(true) + val isRepresentative: LiveData get() = _isRepresentative - private val _updatedOfferingId: MutableLiveData = MutableLiveData() - val updatedOfferingId: LiveData get() = _updatedOfferingId + private val _commentDetailEvent: MutableSingleLiveData = MutableSingleLiveData() + val commentDetailEvent: SingleLiveData get() = _commentDetailEvent - private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() - val reportEvent: SingleLiveData get() = _reportEvent + private val _updatedOfferingId: MutableLiveData = MutableLiveData() + val updatedOfferingId: LiveData get() = _updatedOfferingId - private val _productLinkRedirectEvent: MutableSingleLiveData = MutableSingleLiveData() - val productLinkRedirectEvent: SingleLiveData get() = _productLinkRedirectEvent + private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() + val reportEvent: SingleLiveData get() = _reportEvent - private val _error: MutableSingleLiveData = MutableSingleLiveData() - val error: SingleLiveData get() = _error + private val _productLinkRedirectEvent: MutableSingleLiveData = MutableSingleLiveData() + val productLinkRedirectEvent: SingleLiveData get() = _productLinkRedirectEvent - init { - loadOffering() - } + private val _error: MutableSingleLiveData = MutableSingleLiveData() + val error: SingleLiveData get() = _error - private fun loadOffering() { - viewModelScope.launch { - when (val result = offeringDetailRepository.fetchOfferingDetail(offeringId)) { - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - loadOffering() - } + private val _modifyOfferingEvent: MutableSingleLiveData = MutableSingleLiveData() + val modifyOfferingEvent: SingleLiveData get() = _modifyOfferingEvent - DataError.Network.BAD_REQUEST -> { - _error.setValue(R.string.offering_detail_load_error_mesage) - } + private val _deleteOfferingEvent: MutableSingleLiveData = MutableSingleLiveData() + val deleteOfferingEvent: SingleLiveData get() = _deleteOfferingEvent + + private val _deleteOfferingSuccessEvent: MutableSingleLiveData = MutableSingleLiveData() + val deleteOfferingSuccessEvent: SingleLiveData get() = _deleteOfferingSuccessEvent + + private val _refreshTokenExpiredEvent: MutableSingleLiveData = MutableSingleLiveData() + val refreshTokenExpiredEvent: SingleLiveData get() = _refreshTokenExpiredEvent + + private val _showAlertEvent = MutableSingleLiveData() + val showAlertEvent: SingleLiveData get() = _showAlertEvent - else -> { - Log.e("error", "loadOffering Error: ${result.error.name}") + private val _alertCancelEvent = MutableSingleLiveData() + val alertCancelEvent: SingleLiveData get() = _alertCancelEvent + + init { + loadOffering() + } + + fun loadOffering() { + viewModelScope.launch { + when (val result = offeringDetailRepository.fetchOfferingDetail(offeringId)) { + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> loadOffering() + is Result.Error -> { + userPreferencesDataStore.removeAllData() + _refreshTokenExpiredEvent.setValue(Unit) + return@launch + } + } + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.offering_detail_load_error_mesage) + } + + else -> { + Log.e("error", "loadOffering Error: ${result.error.name}") + } } - } - is Result.Success -> { - _offeringDetail.value = result.data - _currentCount.value = result.data.currentCount.value - _offeringCondition.value = result.data.condition - _isParticipated.value = result.data.isParticipated - _isParticipationAvailable.value = - isParticipationEnabled(result.data.condition, result.data.isParticipated) - _isRepresentative.value = result.data.isProposer + is Result.Success -> { + _offeringDetail.value = result.data + _currentCount.value = result.data.currentCount.value + _offeringCondition.value = result.data.condition + _isParticipated.value = result.data.isParticipated + _isParticipationAvailable.value = + isParticipationEnabled(result.data.condition, result.data.isParticipated) + _isRepresentative.value = result.data.isProposer + } } } } - } - override fun onClickParticipation() { - viewModelScope.launch { - when (val result = offeringDetailRepository.saveParticipation(offeringId)) { - is Result.Error -> - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - onClickParticipation() - } + override fun participate() { + viewModelScope.launch { + when (val result = offeringDetailRepository.saveParticipation(offeringId)) { + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> participate() + is Result.Error -> { + userPreferencesDataStore.removeAllData() + _refreshTokenExpiredEvent.setValue(Unit) + return@launch + } + } + } - DataError.Network.BAD_REQUEST -> { - _error.setValue(R.string.offering_detail_participation_error) - } + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.offering_detail_participation_error) + } - else -> { - Log.e("error", "onClickParticipation Error: ${result.error.name}") + else -> { + Log.e("error", "onClickParticipation Error: ${result.error.name}") + } } - } - is Result.Success -> { - _isParticipated.value = true - _commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE) - _updatedOfferingId.value = offeringId + is Result.Success -> { + _isParticipated.value = true + _commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE) + _updatedOfferingId.value = offeringId + } } } } - } - override fun onClickMoveCommentDetail() { - _commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE) - } + override fun onClickMoveCommentDetail() { + _commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE) + } - override fun onClickReport() { - _reportEvent.setValue(R.string.report_url) - } + override fun onClickReport() { + _reportEvent.setValue(R.string.report_url) + } - override fun onClickProductRedirectText(productUrl: String) { - _productLinkRedirectEvent.setValue(productUrl) - } + override fun onClickProductRedirectText(productUrl: String) { + _productLinkRedirectEvent.setValue(productUrl) + } + + override fun onClickOfferingModify() { + if (_offeringCondition.value == OfferingCondition.CONFIRMED) { + _error.setValue(R.string.error_modify_invalid) + return + } + _modifyOfferingEvent.setValue(offeringId) + } + + fun onClickDeleteButton() { + _deleteOfferingEvent.setValue(Unit) + } + + fun deleteOffering(offeringId: Long) { + viewModelScope.launch { + when (val result = offeringDetailRepository.deleteOffering(offeringId)) { + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> deleteOffering(offeringId) + is Result.Error -> return@launch + } + } - private fun isParticipationEnabled( - offeringCondition: OfferingCondition, - isParticipated: Boolean, - ) = !isParticipated && offeringCondition.isAvailable() - - companion object { - private const val DEFAULT_TITLE = "" - - @Suppress("UNCHECKED_CAST") - fun getFactory( - offeringId: Long, - offeringDetailRepository: OfferingDetailRepository, - authRepository: AuthRepository, - ) = object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return OfferingDetailViewModel( - offeringId, - offeringDetailRepository, - authRepository, - ) as T + DataError.Network.NULL -> { + _deleteOfferingSuccessEvent.setValue(Unit) + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.offering_detail_delete_error) + } + + else -> { + Log.e("error", "onClickOfferingDelete Error: ${result.error.name}") + } + } + + is Result.Success -> { + _deleteOfferingSuccessEvent.setValue(Unit) + } + } } } + + private fun isParticipationEnabled( + offeringCondition: OfferingCondition, + isParticipated: Boolean, + ) = !isParticipated && offeringCondition.isAvailable() + + companion object { + private const val DEFAULT_TITLE = "" + + @Suppress("UNCHECKED_CAST") + fun getFactory( + assistedFactory: OfferingDetailAssistedFactory, + offeringId: Long, + ) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return assistedFactory.create(offeringId) as T + } + } + } + + fun onParticipateClick() { + _showAlertEvent.setValue(Unit) + } + + override fun onClickConfirm() { + participate() + } + + override fun onClickCancel() { + _alertCancelEvent.setValue(Unit) + } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingDeleteAlertClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingDeleteAlertClickListener.kt new file mode 100644 index 000000000..7c3be57b2 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingDeleteAlertClickListener.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +interface OnOfferingDeleteAlertClickListener { + fun onClickConfirm() + + fun onClickCancel() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingModifyClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingModifyClickListener.kt new file mode 100644 index 000000000..45cbd285c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingModifyClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +fun interface OnOfferingModifyClickListener { + fun onClickOfferingModify() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnParticipationClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnParticipationClickListener.kt index 9858c47fa..26302cd49 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnParticipationClickListener.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnParticipationClickListener.kt @@ -1,5 +1,5 @@ package com.zzang.chongdae.presentation.view.offeringdetail interface OnParticipationClickListener { - fun onClickParticipation() + fun participate() } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/ModifyUIState.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/ModifyUIState.kt new file mode 100644 index 000000000..3efb039d9 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/ModifyUIState.kt @@ -0,0 +1,24 @@ +package com.zzang.chongdae.presentation.view.offeringmodify + +import androidx.annotation.StringRes + +sealed class ModifyUIState { + data class Empty( + @StringRes val message: Int, + ) : ModifyUIState() + + data object Initial : ModifyUIState() + + data object Loading : ModifyUIState() + + data class InvalidInput( + @StringRes val message: Int, + ) : ModifyUIState() + + data class Success(val url: String) : ModifyUIState() + + data class Error( + @StringRes val message: Int, + val errorMessage: String, + ) : ModifyUIState() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt new file mode 100644 index 000000000..7ea58f1ac --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyEssentialFragment.kt @@ -0,0 +1,225 @@ +package com.zzang.chongdae.presentation.view.offeringmodify + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResultListener +import androidx.navigation.fragment.findNavController +import com.google.firebase.analytics.FirebaseAnalytics +import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager +import com.zzang.chongdae.databinding.DialogDatePickerBinding +import com.zzang.chongdae.databinding.FragmentOfferingModifyEssentialBinding +import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener +import com.zzang.chongdae.presentation.view.MainActivity +import com.zzang.chongdae.presentation.view.address.AddressFinderDialog +import com.zzang.chongdae.presentation.view.home.HomeFragment +import com.zzang.chongdae.presentation.view.write.OnDateTimeButtonsClickListener +import dagger.hilt.android.AndroidEntryPoint +import java.util.Calendar + +@AndroidEntryPoint +class OfferingModifyEssentialFragment : Fragment(), OnDateTimeButtonsClickListener { + private var _fragmentBinding: FragmentOfferingModifyEssentialBinding? = null + private val fragmentBinding get() = _fragmentBinding!! + + private var _dateTimePickerBinding: DialogDatePickerBinding? = null + private val dateTimePickerBinding get() = _dateTimePickerBinding!! + + private var toast: Toast? = null + private val dialog: Dialog by lazy { Dialog(requireActivity()) } + + private val offeringId by lazy { + arguments?.getLong(HomeFragment.OFFERING_ID) ?: throw IllegalArgumentException() + } + + private val viewModel: OfferingModifyViewModel by activityViewModels() + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.initOfferingId(offeringId) + viewModel.fetchOfferingDetail() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + return fragmentBinding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + (activity as MainActivity).hideBottomNavigation() + setUpObserve() + selectMeetingDate() + searchPlace() + } + + private fun setUpObserve() { + observeNavigateToOptionalEvent() + observeUIState() + } + + private fun observeUIState() { + viewModel.modifyUIState.observe(viewLifecycleOwner) { state -> + when (state) { + is ModifyUIState.Error -> { + showToast(state.message) + } + + is ModifyUIState.Empty -> { + showToast(state.message) + } + + is ModifyUIState.InvalidInput -> { + showToast(state.message) + } + + else -> {} + } + } + } + + private fun searchPlace() { + fragmentBinding.tvPlaceValue.setDebouncedOnClickListener(800L) { + AddressFinderDialog().show(parentFragmentManager, this.tag) + } + setFragmentResultListener(AddressFinderDialog.ADDRESS_KEY) { _, bundle -> + fragmentBinding.tvPlaceValue.text = + bundle.getString(AddressFinderDialog.BUNDLE_ADDRESS_KEY) + } + } + + private fun selectMeetingDate() { + viewModel.meetingDateChoiceEvent.observe(viewLifecycleOwner) { + dialog.setContentView(dateTimePickerBinding.root) + dialog.show() + setDateTimeText(dateTimePickerBinding) + } + } + + private fun setDateTimeText(dateTimeBinding: DialogDatePickerBinding) { + val calendar = Calendar.getInstance() + updateDate(calendar, dateTimeBinding) + } + + private fun updateDate( + calendar: Calendar, + dateTimeBinding: DialogDatePickerBinding, + ) { + val year = calendar.get(Calendar.YEAR) + val month = calendar.get(Calendar.MONTH) + val day = calendar.get(Calendar.DAY_OF_MONTH) + updateDateTextView(dateTimeBinding.tvDate, year, month, day) + dateTimeBinding.pickerDate.minDate = System.currentTimeMillis() + dateTimeBinding.pickerDate.setOnDateChangedListener { _, year, monthOfYear, dayOfMonth -> + updateDateTextView(dateTimeBinding.tvDate, year, monthOfYear, dayOfMonth) + } + } + + override fun onDateTimeSubmitButtonClick() { + viewModel.updateMeetingDate( + dateTimePickerBinding.tvDate.text.toString(), + ) + dialog.dismiss() + } + + override fun onDateTimeCancelButtonClick() { + dialog.dismiss() + } + + private fun updateDateTextView( + textView: TextView, + year: Int, + monthOfYear: Int, + dayOfMonth: Int, + ) { + textView.text = + getString(R.string.write_selected_date).format( + year, + monthOfYear + 1, + dayOfMonth, + ) + } + + private fun updateTimeTextView( + textView: TextView, + hourOfDay: Int, + minute: Int, + ) { + val amPm = if (hourOfDay < 12) getString(R.string.all_am) else getString(R.string.all_pm) + val hour = if (hourOfDay % 12 == 0) 12 else hourOfDay % 12 + textView.text = getString(R.string.write_selected_time, amPm, hour, minute) + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _fragmentBinding = + FragmentOfferingModifyEssentialBinding.inflate(inflater, container, false) + fragmentBinding.vm = viewModel + fragmentBinding.lifecycleOwner = viewLifecycleOwner + + _dateTimePickerBinding = DialogDatePickerBinding.inflate(inflater, container, false) + dateTimePickerBinding.onClickListener = this + } + + private fun observeNavigateToOptionalEvent() { + viewModel.navigateToOptionalEvent.observe(viewLifecycleOwner) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "submit_offering_write_essential_event", + name = "submit_offering_write_essential_event", + contentType = "button", + ) + findNavController().navigate(R.id.action_offering_modify_essential_fragment_to_offering_modify_optional_fragment) + } + } + + private fun showToast( + @StringRes messageId: Int, + ) { + toast?.cancel() + toast = + Toast.makeText( + requireActivity(), + getString(messageId), + Toast.LENGTH_SHORT, + ) + toast?.show() + } + + override fun onResume() { + super.onResume() + firebaseAnalyticsManager.logScreenView( + screenName = "OfferingWriteEssentialFragment", + screenClass = this::class.java.simpleName, + ) + } + + override fun onDestroy() { + super.onDestroy() + _fragmentBinding = null + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyOptionalFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyOptionalFragment.kt new file mode 100644 index 000000000..c8f026384 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyOptionalFragment.kt @@ -0,0 +1,192 @@ +package com.zzang.chongdae.presentation.view.offeringmodify + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.findNavController +import com.google.firebase.analytics.FirebaseAnalytics +import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager +import com.zzang.chongdae.databinding.FragmentOfferingModifyOptionalBinding +import com.zzang.chongdae.presentation.util.FileUtils +import com.zzang.chongdae.presentation.util.PermissionManager + +class OfferingModifyOptionalFragment : Fragment() { + private var _fragmentBinding: FragmentOfferingModifyOptionalBinding? = null + private val fragmentBinding get() = _fragmentBinding!! + + private var toast: Toast? = null + + private lateinit var permissionManager: PermissionManager + private lateinit var pickMediaLauncher: ActivityResultLauncher + + private val viewModel: OfferingModifyViewModel by activityViewModels() + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setUpPermissionManager() + initializePhotoPicker() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + return fragmentBinding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + setUpObserve() + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _fragmentBinding = FragmentOfferingModifyOptionalBinding.inflate(inflater, container, false) + fragmentBinding.vm = viewModel + fragmentBinding.lifecycleOwner = viewLifecycleOwner + } + + private fun observeSubmitOfferingEvent() { + viewModel.submitOfferingModifyEvent.observe(viewLifecycleOwner) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "submit_offering_event", + name = "submit_offering_event", + contentType = "button", + ) + showToast(R.string.modify_success_modifing) + findNavController().popBackStack(R.id.offering_modify_essential_fragment, true) + viewModel.initOfferingModifyInputs() + + setFragmentResult( + OFFERING_WRITE_BUNDLE_KEY, + bundleOf(NEW_OFFERING_EVENT_KEY to true), + ) + } + } + + private fun setUpObserve() { + observeUIState() + observeSubmitOfferingEvent() + observeImageUploadEvent() + } + + private fun showToast( + @StringRes messageId: Int, + ) { + toast?.cancel() + toast = + Toast.makeText( + requireActivity(), + getString(messageId), + Toast.LENGTH_SHORT, + ) + toast?.show() + } + + private fun initializePhotoPicker() { + pickMediaLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + handleMediaResult(uri) + } + } + + private fun launchPhotoPicker() { + pickMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + + private fun handleMediaResult(uri: Uri?) { + if (uri != null) { + val multipartBodyPart = FileUtils.getMultipartBodyPart(requireContext(), uri, "image") + if (multipartBodyPart != null) { + viewModel.uploadImageFile(multipartBodyPart) + } else { + showToast(R.string.all_error_file_conversion) + } + } + } + + private fun setUpPermissionManager() { + permissionManager = + PermissionManager( + fragment = this, + onPermissionGranted = { onPermissionsGranted() }, + onPermissionDenied = { onPermissionsDenied() }, + ) + } + + private fun observeImageUploadEvent() { + viewModel.imageUploadEvent.observe(viewLifecycleOwner) { + if (permissionManager.isAndroid13OrAbove()) { + launchPhotoPicker() + } else { + permissionManager.requestPermissions() + } + } + } + + private fun observeUIState() { + viewModel.modifyUIState.observe(viewLifecycleOwner) { state -> + when (state) { + is ModifyUIState.Error -> { + showToast(state.message) + } + + is ModifyUIState.Empty -> { + showToast(state.message) + } + + is ModifyUIState.InvalidInput -> { + showToast(state.message) + } + + else -> {} + } + } + } + + private fun onPermissionsGranted() { + showToast(R.string.all_permission_granted) + launchPhotoPicker() + } + + private fun onPermissionsDenied() { + showToast(R.string.all_permission_denied) + } + + override fun onDestroy() { + super.onDestroy() + _fragmentBinding = null + } + + companion object { + const val OFFERING_WRITE_BUNDLE_KEY = "offering_write_bundle_key" + const val NEW_OFFERING_EVENT_KEY = "new_offering_event_key" + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyViewModel.kt new file mode 100644 index 000000000..62465d018 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringmodify/OfferingModifyViewModel.kt @@ -0,0 +1,489 @@ +package com.zzang.chongdae.presentation.view.offeringmodify + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import com.zzang.chongdae.R +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingDetailRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingRepositoryQualifier +import com.zzang.chongdae.domain.model.Count +import com.zzang.chongdae.domain.model.DiscountPrice +import com.zzang.chongdae.domain.model.OfferingDetail +import com.zzang.chongdae.domain.model.OfferingModifyDomainRequest +import com.zzang.chongdae.domain.model.Price +import com.zzang.chongdae.domain.repository.OfferingDetailRepository +import com.zzang.chongdae.domain.repository.OfferingRepository +import com.zzang.chongdae.presentation.util.MutableSingleLiveData +import com.zzang.chongdae.presentation.util.SingleLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import okhttp3.MultipartBody +import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class OfferingModifyViewModel + @Inject + constructor( + @OfferingRepositoryQualifier private val offeringRepository: OfferingRepository, + @OfferingDetailRepositoryQualifier private val offeringDetailRepository: OfferingDetailRepository, + @AuthRepositoryQualifier private val authRepository: AuthRepository, + ) : ViewModel() { + private var offeringId: Long = DEFAULT_OFFERING_ID + + val title: MutableLiveData = MutableLiveData("") + + val productUrl: MutableLiveData = MutableLiveData(null) + + val thumbnailUrl: MutableLiveData = MutableLiveData("") + + val deleteImageVisible: LiveData = thumbnailUrl.map { !it.isNullOrBlank() } + + val totalCount: MutableLiveData = MutableLiveData("$MINIMUM_TOTAL_COUNT") + + val totalPrice: MutableLiveData = MutableLiveData("") + + val originPrice: MutableLiveData = MutableLiveData("") + + val meetingAddress: MutableLiveData = MutableLiveData("") + + val meetingAddressDetail: MutableLiveData = MutableLiveData("") + + val meetingDate: MutableLiveData = MutableLiveData("") + + private val meetingDateValue: MutableLiveData = MutableLiveData("") + + val description: MutableLiveData = MutableLiveData("") + + val descriptionLength: LiveData + get() = description.map { it.length } + + private val _essentialSubmitButtonEnabled: MediatorLiveData = MediatorLiveData(false) + val essentialSubmitButtonEnabled: LiveData get() = _essentialSubmitButtonEnabled + + private val _extractButtonEnabled: MediatorLiveData = MediatorLiveData(false) + val extractButtonEnabled: LiveData get() = _extractButtonEnabled + + private val _splitPrice: MediatorLiveData = MediatorLiveData(ERROR_INTEGER_FORMAT) + val splitPrice: LiveData get() = _splitPrice + + private val _discountRate: MediatorLiveData = MediatorLiveData(ERROR_FLOAT_FORMAT) + val splitPriceValidity: LiveData + get() = _splitPrice.map { it >= 0 } + + val discountRateValidity: LiveData + get() = _discountRate.map { it >= 0 } + + val discountRate: LiveData get() = _discountRate + + private val _meetingDateChoiceEvent: MutableSingleLiveData = MutableSingleLiveData() + val meetingDateChoiceEvent: SingleLiveData get() = _meetingDateChoiceEvent + + private val _navigateToOptionalEvent: MutableSingleLiveData = MutableSingleLiveData() + val navigateToOptionalEvent: SingleLiveData get() = _navigateToOptionalEvent + + private val _submitOfferingModifyEvent: MutableSingleLiveData = MutableSingleLiveData() + val submitOfferingModifyEvent: SingleLiveData get() = _submitOfferingModifyEvent + + private val _imageUploadEvent = MutableLiveData() + val imageUploadEvent: LiveData get() = _imageUploadEvent + + private val _modifyUIState = MutableLiveData(ModifyUIState.Initial) + val modifyUIState: LiveData get() = _modifyUIState + + val isLoading: LiveData = _modifyUIState.map { it is ModifyUIState.Loading } + + init { + + _essentialSubmitButtonEnabled.apply { + addSource(title) { updateSubmitButtonEnabled() } + addSource(totalCount) { updateSubmitButtonEnabled() } + addSource(totalPrice) { updateSubmitButtonEnabled() } + addSource(meetingAddress) { updateSubmitButtonEnabled() } + addSource(meetingDate) { updateSubmitButtonEnabled() } + } + + _splitPrice.apply { + addSource(totalCount) { safeUpdateSplitPrice() } + addSource(totalPrice) { safeUpdateSplitPrice() } + } + + _discountRate.apply { + addSource(_splitPrice) { safeUpdateDiscountRate() } + addSource(originPrice) { safeUpdateDiscountRate() } + } + + _extractButtonEnabled.apply { + addSource(productUrl) { value = !productUrl.value.isNullOrBlank() } + } + } + + fun initOfferingId(id: Long) { + offeringId = id + } + + private fun safeUpdateSplitPrice() { + runCatching { + updateSplitPrice() + }.onFailure { + _splitPrice.value = ERROR_INTEGER_FORMAT + } + } + + fun clearProductUrl() { + productUrl.value = null + } + + fun onUploadPhotoClick() { + _imageUploadEvent.value = Unit + } + + fun uploadImageFile(multipartBody: MultipartBody.Part) { + viewModelScope.launch { + _modifyUIState.value = ModifyUIState.Loading + when (val result = offeringRepository.saveProductImageS3(multipartBody)) { + is Result.Success -> { + _modifyUIState.value = ModifyUIState.Success(result.data.imageUrl) + thumbnailUrl.value = result.data.imageUrl + } + + is Result.Error -> { + Log.e("error", "uploadImageFile: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> uploadImageFile(multipartBody) + is Result.Error -> return@launch + } + } + + else -> { + _modifyUIState.value = + ModifyUIState.Error( + R.string.all_error_image_upload, + "${result.error}", + ) + } + } + } + } + } + } + + fun postProductImageOg() { + viewModelScope.launch { + _modifyUIState.value = ModifyUIState.Loading + when (val result = offeringRepository.saveProductImageOg(productUrl.value ?: "")) { + is Result.Success -> { + if (result.data.imageUrl.isBlank()) { + _modifyUIState.value = ModifyUIState.Empty(R.string.error_empty_product_url) + return@launch + } + _modifyUIState.value = ModifyUIState.Success(result.data.imageUrl) + thumbnailUrl.value = HTTPS + result.data.imageUrl + } + + is Result.Error -> { + Log.e("error", "postProductImageOg: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> postProductImageOg() + is Result.Error -> return@launch + } + } + + else -> { + _modifyUIState.value = + ModifyUIState.Error( + R.string.error_invalid_product_url, + "${result.error}", + ) + } + } + } + } + } + } + + fun clearProductImage() { + thumbnailUrl.value = null + } + + private fun safeUpdateDiscountRate() { + runCatching { + updateDiscountRate() + }.onFailure { + _discountRate.value = ERROR_FLOAT_FORMAT + } + } + + private fun updateSubmitButtonEnabled() { + _essentialSubmitButtonEnabled.value = !title.value.isNullOrBlank() && + !totalCount.value.isNullOrBlank() && + !totalPrice.value.isNullOrBlank() && + !meetingAddress.value.isNullOrBlank() && + !meetingDate.value.isNullOrBlank() + } + + private fun updateSplitPrice() { + val totalPrice = Price.fromString(totalPrice.value) + val totalCount = Count.fromString(totalCount.value) + _splitPrice.value = totalPrice.amount / totalCount.number + } + + private fun updateDiscountRate() { + val originPrice = Price.fromString(originPrice.value) + val splitPrice = Price.fromInteger(_splitPrice.value) + val discountPriceValue = originPrice.amount - splitPrice.amount + val discountPrice = DiscountPrice.fromFloat(discountPriceValue.toFloat()) + _discountRate.value = (discountPrice.amount / originPrice.amount) * 100 + } + + fun increaseTotalCount() { + val totalCount = Count.fromString(totalCount.value).increase() + this.totalCount.value = totalCount.number.toString() + } + + fun decreaseTotalCount() { + if (Count.fromString(totalCount.value).number < 0) { + this.totalCount.value = MINIMUM_TOTAL_COUNT.toString() + return + } + val totalCount = Count.fromString(totalCount.value).decrease() + this.totalCount.value = totalCount.number.toString() + } + + fun makeMeetingDateChoiceEvent() { + _meetingDateChoiceEvent.setValue(true) + } + + fun updateMeetingDate(date: String) { + val dateTime = "$date" + val inputFormat = SimpleDateFormat(DATE_FORMAT_DOMAIN, Locale.KOREAN) + val outputFormat = SimpleDateFormat(DATE_TIME_FORMAT_REMOTE_WITH_SEC, Locale.getDefault()) + + val parsedDateTime = inputFormat.parse(dateTime) + meetingDateValue.value = parsedDateTime?.let { outputFormat.format(it) } + meetingDate.value = dateTime + } + + private fun initDateTimeWhenModify(dateTimeString: String) { + val inputFormatter = DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_REMOTE) + val outputFormatter = DateTimeFormatter.ofPattern(DATE_FORMAT_DOMAIN) + val inputFormatterWithSec = DateTimeFormatter.ofPattern(DATE_TIME_FORMAT_REMOTE_WITH_SEC) + val dateTime = LocalDateTime.parse(dateTimeString, inputFormatter) + meetingDate.value = dateTime.format(outputFormatter) + meetingDateValue.value = dateTime.format(inputFormatterWithSec) + } + + fun fetchOfferingDetail() { + viewModelScope.launch { + when (val result = offeringDetailRepository.fetchOfferingDetail(offeringId)) { + is Result.Success -> { + initExistingOffering(result.data) + } + + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> fetchOfferingDetail() + is Result.Error -> return@launch + } + } + + else -> { + Log.e("error", "loadOffering Error: ${result.error.name}") + } + } + } + } + } + + private fun initExistingOffering(offeringDetail: OfferingDetail) { + title.value = offeringDetail.title + productUrl.value = offeringDetail.productUrl + thumbnailUrl.value = offeringDetail.thumbnailUrl + totalCount.value = offeringDetail.totalCount.toString() + totalPrice.value = offeringDetail.totalPrice.toString() + originPrice.value = offeringDetail.originPrice?.toString() ?: "" + meetingAddress.value = offeringDetail.meetingAddress + meetingAddressDetail.value = offeringDetail.meetingAddressDetail + initDateTimeWhenModify(offeringDetail.meetingDate.toString()) + description.value = offeringDetail.description + } + + fun postOfferingModify() { + val title = title.value ?: return + val totalCount = totalCount.value ?: return + val totalPrice = totalPrice.value ?: return + val meetingAddress = meetingAddress.value ?: return + val meetingAddressDetail = meetingAddressDetail.value ?: return + val meetingDate = meetingDateValue.value ?: return + val description = description.value ?: return + + val totalCountConverted = makeTotalCountInvalidEvent(totalCount) ?: return + val totalPriceConverted = makeTotalPriceInvalidEvent(totalPrice) ?: return + val meetingAddressDong = extractDong(meetingAddress) + + var originPriceNotBlank: Int? = 0 + runCatching { + originPriceNotBlank = originPriceToPositiveIntOrNull(originPrice.value) + }.onFailure { + makeOriginPriceInvalidEvent() + return + } + if (isOriginPriceCheaperThanSplitPriceEvent()) return + + viewModelScope.launch { + when ( + val result = + offeringRepository.patchOffering( + offeringId = offeringId, + offeringModifyDomainRequest = + OfferingModifyDomainRequest( + title = title, + productUrl = productUrlOrNull(), + thumbnailUrl = thumbnailUrl.value, + totalCount = totalCountConverted, + totalPrice = totalPriceConverted, + originPrice = originPriceNotBlank, + meetingAddress = meetingAddress, + meetingAddressDong = meetingAddressDong, + meetingAddressDetail = meetingAddressDetail, + meetingDate = meetingDate, + description = description, + ), + ) + ) { + is Result.Success -> { + makeSubmitOfferingModifyEvent() + } + + is Result.Error -> { + Log.e("error", "postOffering: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> postOfferingModify() + is Result.Error -> return@launch + } + } + + else -> { + _modifyUIState.value = + ModifyUIState.Error( + R.string.modify_error_modifing, + "${result.error}", + ) + } + } + } + } + } + } + + private fun productUrlOrNull(): String? { + val productUrl = productUrl.value + if (productUrl == "") return null + return productUrl + } + + private fun originPriceToPositiveIntOrNull(input: String?): Int? { + val originPriceInputTrim = input?.trim() + if (originPriceInputTrim.isNullOrBlank()) { + return null + } + if (originPriceInputTrim.toInt() < 0) { + throw NumberFormatException() + } + return originPriceInputTrim.toInt() + } + + private fun extractDong(address: String): String? { + val regex = """\((.*?)\)""".toRegex() + val matchResult = regex.find(address) + val content = matchResult?.groups?.get(1)?.value + return content?.split(",")?.get(0)?.trim() + } + + private fun makeTotalCountInvalidEvent(totalCount: String): Int? { + val totalCountValue = totalCount.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (totalCountValue < MINIMUM_TOTAL_COUNT || totalCountValue > MAXIMUM_TOTAL_COUNT) { + _modifyUIState.value = ModifyUIState.InvalidInput(R.string.write_invalid_total_count) + return null + } + return totalCountValue + } + + private fun makeTotalPriceInvalidEvent(totalPrice: String): Int? { + val totalPriceConverted = totalPrice.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (totalPriceConverted < 0) { + _modifyUIState.value = ModifyUIState.InvalidInput(R.string.write_invalid_total_price) + return null + } + return totalPriceConverted + } + + private fun makeOriginPriceInvalidEvent() { + _modifyUIState.value = ModifyUIState.InvalidInput(R.string.write_invalid_origin_price) + } + + private fun isOriginPriceCheaperThanSplitPriceEvent(): Boolean { + if (originPrice.value.isNullOrBlank()) return false + val discountRateValue = discountRate.value ?: ERROR_FLOAT_FORMAT + if (discountRateValue <= 0f) { + _modifyUIState.value = + ModifyUIState.InvalidInput(R.string.write_origin_price_cheaper_than_total_price) + return true + } + return false + } + + fun makeNavigateToOptionalEvent() { + _navigateToOptionalEvent.setValue(true) + } + + private fun makeSubmitOfferingModifyEvent() { + _submitOfferingModifyEvent.setValue(Unit) + } + + fun initOfferingModifyInputs() { + title.value = "" + productUrl.value = "" + thumbnailUrl.value = "" + totalCount.value = "$MINIMUM_TOTAL_COUNT" + totalPrice.value = "" + originPrice.value = "" + meetingAddress.value = "" + meetingAddressDetail.value = "" + meetingDate.value = "" + meetingDateValue.value = "" + description.value = "" + } + + companion object { + private const val DEFAULT_OFFERING_ID = 0L + private const val ERROR_INTEGER_FORMAT = -1 + private const val ERROR_FLOAT_FORMAT = -1f + private const val MINIMUM_TOTAL_COUNT = 2 + private const val MAXIMUM_TOTAL_COUNT = 10_000 + private const val INPUT_DATE_TIME_FORMAT = "yyyy년 M월 d일 a h시 m분" + private const val DATE_FORMAT_DOMAIN = "yyyy년 M월 d일" + private const val DATE_TIME_FORMAT_REMOTE = "yyyy-MM-dd'T'HH:mm" + private const val DATE_TIME_FORMAT_REMOTE_WITH_SEC = "yyyy-MM-dd'T'HH:mm:ss" + const val HTTPS = "https:" + } + } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt index cf5f17c68..803fa9603 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt @@ -13,16 +13,18 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResultListener import androidx.navigation.fragment.findNavController import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.DialogDatePickerBinding import com.zzang.chongdae.databinding.FragmentOfferingWriteEssentialBinding -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.util.setDebouncedOnClickListener import com.zzang.chongdae.presentation.view.MainActivity import com.zzang.chongdae.presentation.view.address.AddressFinderDialog +import dagger.hilt.android.AndroidEntryPoint import java.util.Calendar -class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener { +@AndroidEntryPoint +class OfferingWriteEssentialFragment : Fragment(), OnDateTimeButtonsClickListener { private var _fragmentBinding: FragmentOfferingWriteEssentialBinding? = null private val fragmentBinding get() = _fragmentBinding!! @@ -32,12 +34,7 @@ class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener private var toast: Toast? = null private val dialog: Dialog by lazy { Dialog(requireActivity()) } - private val viewModel: OfferingWriteViewModel by activityViewModels { - OfferingWriteViewModel.getFactory( - offeringRepository = (requireActivity().application as ChongdaeApp).offeringRepository, - authRepository = (requireActivity().application as ChongdaeApp).authRepository, - ) - } + private val viewModel: OfferingWriteViewModel by activityViewModels() private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(requireContext()) @@ -90,7 +87,7 @@ class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener } private fun searchPlace() { - fragmentBinding.tvPlaceValue.setOnClickListener { + fragmentBinding.tvPlaceValue.setDebouncedOnClickListener(800L) { AddressFinderDialog().show(parentFragmentManager, this.tag) } setFragmentResultListener(AddressFinderDialog.ADDRESS_KEY) { _, bundle -> @@ -120,6 +117,7 @@ class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener val month = calendar.get(Calendar.MONTH) val day = calendar.get(Calendar.DAY_OF_MONTH) updateDateTextView(dateTimeBinding.tvDate, year, month, day) + dateTimeBinding.pickerDate.minDate = System.currentTimeMillis() dateTimeBinding.pickerDate.setOnDateChangedListener { _, year, monthOfYear, dayOfMonth -> updateDateTextView(dateTimeBinding.tvDate, year, monthOfYear, dayOfMonth) } @@ -169,7 +167,6 @@ class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener fragmentBinding.lifecycleOwner = viewLifecycleOwner _dateTimePickerBinding = DialogDatePickerBinding.inflate(inflater, container, false) - dateTimePickerBinding.vm = viewModel dateTimePickerBinding.onClickListener = this } @@ -208,5 +205,6 @@ class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener override fun onDestroy() { super.onDestroy() _fragmentBinding = null + viewModel.initOfferingWriteInputs() } } diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt index a9b3a67dc..241bcac57 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt @@ -16,13 +16,14 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.setFragmentResult import androidx.navigation.fragment.findNavController import com.google.firebase.analytics.FirebaseAnalytics -import com.zzang.chongdae.ChongdaeApp import com.zzang.chongdae.R +import com.zzang.chongdae.common.firebase.FirebaseAnalyticsManager import com.zzang.chongdae.databinding.FragmentOfferingWriteOptionalBinding import com.zzang.chongdae.presentation.util.FileUtils -import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager import com.zzang.chongdae.presentation.util.PermissionManager +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class OfferingWriteOptionalFragment : Fragment() { private var _fragmentBinding: FragmentOfferingWriteOptionalBinding? = null private val fragmentBinding get() = _fragmentBinding!! @@ -32,12 +33,7 @@ class OfferingWriteOptionalFragment : Fragment() { private lateinit var permissionManager: PermissionManager private lateinit var pickMediaLauncher: ActivityResultLauncher - private val viewModel: OfferingWriteViewModel by activityViewModels { - OfferingWriteViewModel.getFactory( - offeringRepository = (requireActivity().application as ChongdaeApp).offeringRepository, - authRepository = (requireActivity().application as ChongdaeApp).authRepository, - ) - } + private val viewModel: OfferingWriteViewModel by activityViewModels() private val firebaseAnalytics: FirebaseAnalytics by lazy { FirebaseAnalytics.getInstance(requireContext()) @@ -87,9 +83,7 @@ class OfferingWriteOptionalFragment : Fragment() { contentType = "button", ) showToast(R.string.write_success_writing) - findNavController().popBackStack(R.id.offering_write_fragment_essential, true) - viewModel.initOfferingWriteInputs() - + findNavController().popBackStack(R.id.offering_write_essential_fragment, true) setFragmentResult( OFFERING_WRITE_BUNDLE_KEY, bundleOf(NEW_OFFERING_EVENT_KEY to true), diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt index 304f2f8d9..c8b337b7b 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt @@ -5,402 +5,418 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.map import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import com.zzang.chongdae.R +import com.zzang.chongdae.auth.repository.AuthRepository +import com.zzang.chongdae.common.handler.DataError +import com.zzang.chongdae.common.handler.Result +import com.zzang.chongdae.di.annotations.AuthRepositoryQualifier +import com.zzang.chongdae.di.annotations.OfferingRepositoryQualifier import com.zzang.chongdae.domain.model.Count import com.zzang.chongdae.domain.model.DiscountPrice +import com.zzang.chongdae.domain.model.OfferingWrite import com.zzang.chongdae.domain.model.Price -import com.zzang.chongdae.domain.repository.AuthRepository import com.zzang.chongdae.domain.repository.OfferingRepository -import com.zzang.chongdae.domain.util.DataError -import com.zzang.chongdae.domain.util.Result import com.zzang.chongdae.presentation.util.MutableSingleLiveData import com.zzang.chongdae.presentation.util.SingleLiveData +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import okhttp3.MultipartBody import java.text.SimpleDateFormat import java.util.Locale +import javax.inject.Inject -class OfferingWriteViewModel( - private val offeringRepository: OfferingRepository, - private val authRepository: AuthRepository, -) : ViewModel() { - val title: MutableLiveData = MutableLiveData("") +@HiltViewModel +class OfferingWriteViewModel + @Inject + constructor( + @OfferingRepositoryQualifier private val offeringRepository: OfferingRepository, + @AuthRepositoryQualifier private val authRepository: AuthRepository, + ) : ViewModel() { + val title: MutableLiveData = MutableLiveData("") - val productUrl: MutableLiveData = MutableLiveData(null) + val productUrl: MutableLiveData = MutableLiveData(null) - val thumbnailUrl: MutableLiveData = MutableLiveData("") + val thumbnailUrl: MutableLiveData = MutableLiveData("") - val deleteImageVisible: LiveData = thumbnailUrl.map { !it.isNullOrBlank() } + val deleteImageVisible: LiveData = thumbnailUrl.map { !it.isNullOrBlank() } - val totalCount: MutableLiveData = MutableLiveData("$MINIMUM_TOTAL_COUNT") + val totalCount: MutableLiveData = MutableLiveData("$MINIMUM_TOTAL_COUNT") - val totalPrice: MutableLiveData = MutableLiveData("") + val totalPrice: MutableLiveData = MutableLiveData("") - val originPrice: MutableLiveData = MutableLiveData("") + val originPrice: MutableLiveData = MutableLiveData("") - val meetingAddress: MutableLiveData = MutableLiveData("") + val meetingAddress: MutableLiveData = MutableLiveData("") - val meetingAddressDetail: MutableLiveData = MutableLiveData("") + val meetingAddressDetail: MutableLiveData = MutableLiveData("") - val meetingDate: MutableLiveData = MutableLiveData("") + val meetingDate: MutableLiveData = MutableLiveData("") - private val meetingDateValue: MutableLiveData = MutableLiveData("") + private val meetingDateValue: MutableLiveData = MutableLiveData("") - val description: MutableLiveData = MutableLiveData("") + val description: MutableLiveData = MutableLiveData("") - val descriptionLength: LiveData - get() = description.map { it.length } + val descriptionLength: LiveData + get() = description.map { it.length } - private val _essentialSubmitButtonEnabled: MediatorLiveData = MediatorLiveData(false) - val essentialSubmitButtonEnabled: LiveData get() = _essentialSubmitButtonEnabled + private val _essentialSubmitButtonEnabled: MediatorLiveData = MediatorLiveData(false) + val essentialSubmitButtonEnabled: LiveData get() = _essentialSubmitButtonEnabled - private val _extractButtonEnabled: MediatorLiveData = MediatorLiveData(false) - val extractButtonEnabled: LiveData get() = _extractButtonEnabled + private val _extractButtonEnabled: MediatorLiveData = MediatorLiveData(false) + val extractButtonEnabled: LiveData get() = _extractButtonEnabled - private val _splitPrice: MediatorLiveData = MediatorLiveData(ERROR_INTEGER_FORMAT) - val splitPrice: LiveData get() = _splitPrice + private val _splitPrice: MediatorLiveData = MediatorLiveData(ERROR_INTEGER_FORMAT) + val splitPrice: LiveData get() = _splitPrice - private val _discountRate: MediatorLiveData = MediatorLiveData(ERROR_FLOAT_FORMAT) - val splitPriceValidity: LiveData - get() = _splitPrice.map { it >= 0 } + private val _discountRate: MediatorLiveData = MediatorLiveData(ERROR_FLOAT_FORMAT) + val splitPriceValidity: LiveData + get() = _splitPrice.map { it >= 0 } - val discountRateValidity: LiveData - get() = _discountRate.map { it >= 0 } + val discountRateValidity: LiveData + get() = _discountRate.map { it >= 0 } - val discountRate: LiveData get() = _discountRate + val discountRate: LiveData get() = _discountRate - private val _meetingDateChoiceEvent: MutableSingleLiveData = MutableSingleLiveData() - val meetingDateChoiceEvent: SingleLiveData get() = _meetingDateChoiceEvent + private val _meetingDateChoiceEvent: MutableSingleLiveData = MutableSingleLiveData() + val meetingDateChoiceEvent: SingleLiveData get() = _meetingDateChoiceEvent - private val _navigateToOptionalEvent: MutableSingleLiveData = MutableSingleLiveData() - val navigateToOptionalEvent: SingleLiveData get() = _navigateToOptionalEvent + private val _navigateToOptionalEvent: MutableSingleLiveData = MutableSingleLiveData() + val navigateToOptionalEvent: SingleLiveData get() = _navigateToOptionalEvent - private val _submitOfferingEvent: MutableSingleLiveData = MutableSingleLiveData() - val submitOfferingEvent: SingleLiveData get() = _submitOfferingEvent + private val _submitOfferingEvent: MutableSingleLiveData = MutableSingleLiveData() + val submitOfferingEvent: SingleLiveData get() = _submitOfferingEvent - private val _imageUploadEvent = MutableLiveData() - val imageUploadEvent: LiveData get() = _imageUploadEvent + private val _imageUploadEvent = MutableLiveData() + val imageUploadEvent: LiveData get() = _imageUploadEvent - private val _writeUIState = MutableLiveData(WriteUIState.Initial) - val writeUIState: LiveData get() = _writeUIState + private val _writeUIState = MutableLiveData(WriteUIState.Initial) + val writeUIState: LiveData get() = _writeUIState - val isLoading: LiveData = _writeUIState.map { it is WriteUIState.Loading } + val isLoading: LiveData = _writeUIState.map { it is WriteUIState.Loading } - init { - _essentialSubmitButtonEnabled.apply { - addSource(title) { updateSubmitButtonEnabled() } - addSource(totalCount) { updateSubmitButtonEnabled() } - addSource(totalPrice) { updateSubmitButtonEnabled() } - addSource(meetingAddress) { updateSubmitButtonEnabled() } - addSource(meetingDate) { updateSubmitButtonEnabled() } - } + init { + _essentialSubmitButtonEnabled.apply { + addSource(title) { updateSubmitButtonEnabled() } + addSource(totalCount) { updateSubmitButtonEnabled() } + addSource(totalPrice) { updateSubmitButtonEnabled() } + addSource(meetingAddress) { updateSubmitButtonEnabled() } + addSource(meetingDate) { updateSubmitButtonEnabled() } + } - _splitPrice.apply { - addSource(totalCount) { safeUpdateSplitPrice() } - addSource(totalPrice) { safeUpdateSplitPrice() } - } + _splitPrice.apply { + addSource(totalCount) { safeUpdateSplitPrice() } + addSource(totalPrice) { safeUpdateSplitPrice() } + } - _discountRate.apply { - addSource(_splitPrice) { safeUpdateDiscountRate() } - addSource(originPrice) { safeUpdateDiscountRate() } - } + _discountRate.apply { + addSource(_splitPrice) { safeUpdateDiscountRate() } + addSource(originPrice) { safeUpdateDiscountRate() } + } - _extractButtonEnabled.apply { - addSource(productUrl) { value = !productUrl.value.isNullOrBlank() } + _extractButtonEnabled.apply { + addSource(productUrl) { value = !productUrl.value.isNullOrBlank() } + } } - } - private fun safeUpdateSplitPrice() { - runCatching { - updateSplitPrice() - }.onFailure { - _splitPrice.value = ERROR_INTEGER_FORMAT + private fun safeUpdateSplitPrice() { + runCatching { + updateSplitPrice() + }.onFailure { + _splitPrice.value = ERROR_INTEGER_FORMAT + } } - } - fun clearProductUrl() { - productUrl.value = null - } + fun clearProductUrl() { + productUrl.value = null + } - fun onUploadPhotoClick() { - _imageUploadEvent.value = Unit - } + fun onUploadPhotoClick() { + _imageUploadEvent.value = Unit + } - fun uploadImageFile(multipartBody: MultipartBody.Part) { - viewModelScope.launch { - when (val result = offeringRepository.saveProductImageS3(multipartBody)) { - is Result.Success -> { - _writeUIState.value = WriteUIState.Success(result.data.imageUrl) - thumbnailUrl.value = result.data.imageUrl - } + fun uploadImageFile(multipartBody: MultipartBody.Part) { + viewModelScope.launch { + _writeUIState.value = WriteUIState.Loading + when (val result = offeringRepository.saveProductImageS3(multipartBody)) { + is Result.Success -> { + _writeUIState.value = WriteUIState.Success(result.data.imageUrl) + thumbnailUrl.value = result.data.imageUrl + } - is Result.Error -> { - Log.e("error", "uploadImageFile: ${result.error}") - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - uploadImageFile(multipartBody) + is Result.Error -> { + Log.e("error", "uploadImageFile: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> uploadImageFile(multipartBody) + is Result.Error -> return@launch + } + } + + else -> { + _writeUIState.value = + WriteUIState.Error( + R.string.all_error_image_upload, + "${result.error}", + ) + } } - else -> {} } } } } - } - fun postProductImageOg() { - viewModelScope.launch { - when (val result = offeringRepository.saveProductImageOg(productUrl.value ?: "")) { - is Result.Success -> { - if (result.data.imageUrl.isBlank()) { - _writeUIState.value = WriteUIState.Empty(R.string.error_empty_product_url) - return@launch - } - thumbnailUrl.value = HTTPS + result.data.imageUrl - } - - is Result.Error -> { - Log.e("error", "postProductImageOg: ${result.error}") - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - postProductImageOg() + fun postProductImageOg() { + viewModelScope.launch { + _writeUIState.value = WriteUIState.Loading + when (val result = offeringRepository.saveProductImageOg(productUrl.value ?: "")) { + is Result.Success -> { + if (result.data.imageUrl.isBlank()) { + _writeUIState.value = WriteUIState.Empty(R.string.error_empty_product_url) + return@launch } + _writeUIState.value = WriteUIState.Success(result.data.imageUrl) + thumbnailUrl.value = HTTPS + result.data.imageUrl + } - else -> { - _writeUIState.value = - WriteUIState.Error(R.string.error_invalid_product_url, "${result.error}") + is Result.Error -> { + Log.e("error", "postProductImageOg: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> postProductImageOg() + is Result.Error -> return@launch + } + } + + else -> { + _writeUIState.value = + WriteUIState.Error( + R.string.error_invalid_product_url, + "${result.error}", + ) + } } } } } } - } - - fun clearProductImage() { - thumbnailUrl.value = null - } - private fun safeUpdateDiscountRate() { - runCatching { - updateDiscountRate() - }.onFailure { - _discountRate.value = ERROR_FLOAT_FORMAT + fun clearProductImage() { + thumbnailUrl.value = null } - } - private fun updateSubmitButtonEnabled() { - _essentialSubmitButtonEnabled.value = !title.value.isNullOrBlank() && - !totalCount.value.isNullOrBlank() && - !totalPrice.value.isNullOrBlank() && - !meetingAddress.value.isNullOrBlank() && - !meetingDate.value.isNullOrBlank() - } + private fun safeUpdateDiscountRate() { + runCatching { + updateDiscountRate() + }.onFailure { + _discountRate.value = ERROR_FLOAT_FORMAT + } + } - private fun updateSplitPrice() { - val totalPrice = Price.fromString(totalPrice.value) - val totalCount = Count.fromString(totalCount.value) - _splitPrice.value = totalPrice.amount / totalCount.number - } + private fun updateSubmitButtonEnabled() { + _essentialSubmitButtonEnabled.value = !title.value.isNullOrBlank() && + !totalCount.value.isNullOrBlank() && + !totalPrice.value.isNullOrBlank() && + !meetingAddress.value.isNullOrBlank() && + !meetingDate.value.isNullOrBlank() + } - private fun updateDiscountRate() { - val originPrice = Price.fromString(originPrice.value) - val splitPrice = Price.fromInteger(_splitPrice.value) - val discountPriceValue = originPrice.amount - splitPrice.amount - val discountPrice = DiscountPrice.fromFloat(discountPriceValue.toFloat()) - _discountRate.value = (discountPrice.amount / originPrice.amount) * 100 - } + private fun updateSplitPrice() { + val totalPrice = Price.fromString(totalPrice.value) + val totalCount = Count.fromString(totalCount.value) + _splitPrice.value = totalPrice.amount / totalCount.number + } - fun increaseTotalCount() { - val totalCount = Count.fromString(totalCount.value).increase() - this.totalCount.value = totalCount.number.toString() - } + private fun updateDiscountRate() { + val originPrice = Price.fromString(originPrice.value) + val splitPrice = Price.fromInteger(_splitPrice.value) + val discountPriceValue = originPrice.amount - splitPrice.amount + val discountPrice = DiscountPrice.fromFloat(discountPriceValue.toFloat()) + _discountRate.value = (discountPrice.amount / originPrice.amount) * 100 + } - fun decreaseTotalCount() { - if (Count.fromString(totalCount.value).number < 0) { - this.totalCount.value = MINIMUM_TOTAL_COUNT.toString() - return + fun increaseTotalCount() { + val totalCount = Count.fromString(totalCount.value).increase() + this.totalCount.value = totalCount.number.toString() } - val totalCount = Count.fromString(totalCount.value).decrease() - this.totalCount.value = totalCount.number.toString() - } - fun makeMeetingDateChoiceEvent() { - _meetingDateChoiceEvent.setValue(true) - } + fun decreaseTotalCount() { + if (Count.fromString(totalCount.value).number < 0) { + this.totalCount.value = MINIMUM_TOTAL_COUNT.toString() + return + } + val totalCount = Count.fromString(totalCount.value).decrease() + this.totalCount.value = totalCount.number.toString() + } - fun updateMeetingDate(date: String) { - val dateTime = "$date" - val inputFormat = SimpleDateFormat(INPUT_DATE_FORMAT, Locale.KOREAN) - val outputFormat = SimpleDateFormat(OUTPUT_DATE_TIME_FORMAT, Locale.getDefault()) + fun makeMeetingDateChoiceEvent() { + _meetingDateChoiceEvent.setValue(true) + } - val parsedDateTime = inputFormat.parse(dateTime) - meetingDateValue.value = parsedDateTime?.let { outputFormat.format(it) } - meetingDate.value = dateTime - } + fun updateMeetingDate(date: String) { + val dateTime = "$date" + val inputFormat = SimpleDateFormat(INPUT_DATE_FORMAT, Locale.KOREAN) + val outputFormat = SimpleDateFormat(OUTPUT_DATE_TIME_FORMAT, Locale.getDefault()) - fun postOffering() { - val title = title.value ?: return - val totalCount = totalCount.value ?: return - val totalPrice = totalPrice.value ?: return - val meetingAddress = meetingAddress.value ?: return - val meetingAddressDetail = meetingAddressDetail.value ?: return - val meetingDate = meetingDateValue.value ?: return - val description = description.value ?: return - - val totalCountConverted = makeTotalCountInvalidEvent(totalCount) ?: return - val totalPriceConverted = makeTotalPriceInvalidEvent(totalPrice) ?: return - val meetingAddressDong = extractDong(meetingAddress) - - var originPriceNotBlank: Int? = 0 - runCatching { - originPriceNotBlank = originPriceToPositiveIntOrNull(originPrice.value) - }.onFailure { - makeOriginPriceInvalidEvent() - return + val parsedDateTime = inputFormat.parse(dateTime) + meetingDateValue.value = parsedDateTime?.let { outputFormat.format(it) } + meetingDate.value = dateTime } - if (isOriginPriceCheaperThanSplitPriceEvent()) return - - viewModelScope.launch { - when ( - val result = - offeringRepository.saveOffering( - uiModel = - OfferingWriteUiModel( - title = title, - productUrl = productUrl.value, - thumbnailUrl = thumbnailUrl.value, - totalCount = totalCountConverted, - totalPrice = totalPriceConverted, - originPrice = originPriceNotBlank, - meetingAddress = meetingAddress, - meetingAddressDong = meetingAddressDong, - meetingAddressDetail = meetingAddressDetail, - meetingDate = meetingDate, - description = description, - ), - ) - ) { - is Result.Success -> makeSubmitOfferingEvent() - - is Result.Error -> { - Log.e("error", "postOffering: ${result.error}") - when (result.error) { - DataError.Network.UNAUTHORIZED -> { - authRepository.saveRefresh() - postOffering() - } - else -> { - _writeUIState.value = - WriteUIState.Error(R.string.write_error_writing, "${result.error}") + + fun postOffering() { + val title = title.value ?: return + val totalCount = totalCount.value ?: return + val totalPrice = totalPrice.value ?: return + val meetingAddress = meetingAddress.value ?: return + val meetingAddressDetail = meetingAddressDetail.value ?: return + val meetingDate = meetingDateValue.value ?: return + val description = description.value ?: return + + val totalCountConverted = makeTotalCountInvalidEvent(totalCount) ?: return + val totalPriceConverted = makeTotalPriceInvalidEvent(totalPrice) ?: return + val meetingAddressDong = extractDong(meetingAddress) + + var originPriceNotBlank: Int? = 0 + runCatching { + originPriceNotBlank = originPriceToPositiveIntOrNull(originPrice.value) + }.onFailure { + makeOriginPriceInvalidEvent() + return + } + if (isOriginPriceCheaperThanSplitPriceEvent()) return + + viewModelScope.launch { + when ( + val result = + offeringRepository.saveOffering( + offeringWrite = + OfferingWrite( + title = title, + productUrl = productUrlOrNull(), + thumbnailUrl = thumbnailUrl.value, + totalCount = totalCountConverted, + totalPrice = totalPriceConverted, + originPrice = originPriceNotBlank, + meetingAddress = meetingAddress, + meetingAddressDong = meetingAddressDong, + meetingAddressDetail = meetingAddressDetail, + meetingDate = meetingDate, + description = description, + ), + ) + ) { + is Result.Success -> makeSubmitOfferingEvent() + + is Result.Error -> { + Log.e("error", "postOffering: ${result.error}") + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + when (authRepository.saveRefresh()) { + is Result.Success -> postOffering() + is Result.Error -> return@launch + } + } + + else -> { + _writeUIState.value = + WriteUIState.Error(R.string.write_error_writing, "${result.error}") + } } } } } } - } - private fun originPriceToPositiveIntOrNull(input: String?): Int? { - val originPriceInputTrim = input?.trim() - if (originPriceInputTrim.isNullOrBlank()) { - return null + private fun productUrlOrNull(): String? { + val productUrl = productUrl.value + if (productUrl == "") return null + return productUrl } - if (originPriceInputTrim.toInt() < 0) { - throw NumberFormatException() + + private fun originPriceToPositiveIntOrNull(input: String?): Int? { + val originPriceInputTrim = input?.trim() + if (originPriceInputTrim.isNullOrBlank()) { + return null + } + if (originPriceInputTrim.toInt() < 0) { + throw NumberFormatException() + } + return originPriceInputTrim.toInt() } - return originPriceInputTrim.toInt() - } - private fun extractDong(address: String): String? { - val regex = """\((.*?)\)""".toRegex() - val matchResult = regex.find(address) - val content = matchResult?.groups?.get(1)?.value - return content?.split(",")?.get(0)?.trim() - } + private fun extractDong(address: String): String? { + val regex = """\((.*?)\)""".toRegex() + val matchResult = regex.find(address) + val content = matchResult?.groups?.get(1)?.value + return content?.split(",")?.get(0)?.trim() + } - private fun makeTotalCountInvalidEvent(totalCount: String): Int? { - val totalCountValue = totalCount.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT - if (totalCountValue < MINIMUM_TOTAL_COUNT || totalCountValue > MAXIMUM_TOTAL_COUNT) { - _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_total_count) - return null + private fun makeTotalCountInvalidEvent(totalCount: String): Int? { + val totalCountValue = totalCount.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (totalCountValue < MINIMUM_TOTAL_COUNT || totalCountValue > MAXIMUM_TOTAL_COUNT) { + _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_total_count) + return null + } + return totalCountValue } - return totalCountValue - } - private fun makeTotalPriceInvalidEvent(totalPrice: String): Int? { - val totalPriceConverted = totalPrice.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT - if (totalPriceConverted < 0) { - _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_total_price) - return null + private fun makeTotalPriceInvalidEvent(totalPrice: String): Int? { + val totalPriceConverted = totalPrice.trim().toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (totalPriceConverted < 0) { + _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_total_price) + return null + } + return totalPriceConverted } - return totalPriceConverted - } - private fun makeOriginPriceInvalidEvent() { - _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_origin_price) - } + private fun makeOriginPriceInvalidEvent() { + _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_origin_price) + } - private fun isOriginPriceCheaperThanSplitPriceEvent(): Boolean { - if (originPrice.value.isNullOrBlank()) return false - val discountRateValue = discountRate.value ?: ERROR_FLOAT_FORMAT - if (discountRateValue <= 0f) { - _writeUIState.value = - WriteUIState.InvalidInput(R.string.write_origin_price_cheaper_than_total_price) - return true + private fun isOriginPriceCheaperThanSplitPriceEvent(): Boolean { + if (originPrice.value.isNullOrBlank()) return false + val discountRateValue = discountRate.value ?: ERROR_FLOAT_FORMAT + if (discountRateValue <= 0f) { + _writeUIState.value = + WriteUIState.InvalidInput(R.string.write_origin_price_cheaper_than_total_price) + return true + } + return false } - return false - } - fun makeNavigateToOptionalEvent() { - _navigateToOptionalEvent.setValue(true) - } + fun makeNavigateToOptionalEvent() { + _navigateToOptionalEvent.setValue(true) + } - private fun makeSubmitOfferingEvent() { - _submitOfferingEvent.setValue(Unit) - } + private fun makeSubmitOfferingEvent() { + _submitOfferingEvent.setValue(Unit) + } - fun initOfferingWriteInputs() { - title.value = "" - productUrl.value = "" - thumbnailUrl.value = "" - totalCount.value = "$MINIMUM_TOTAL_COUNT" - totalPrice.value = "" - originPrice.value = "" - meetingAddress.value = "" - meetingAddressDetail.value = "" - meetingDate.value = "" - meetingDateValue.value = "" - description.value = "" - } + fun initOfferingWriteInputs() { + title.value = "" + productUrl.value = "" + thumbnailUrl.value = "" + totalCount.value = "$MINIMUM_TOTAL_COUNT" + totalPrice.value = "" + originPrice.value = "" + meetingAddress.value = "" + meetingAddressDetail.value = "" + meetingDate.value = "" + meetingDateValue.value = "" + description.value = "" + } - companion object { - private const val ERROR_INTEGER_FORMAT = -1 - private const val ERROR_FLOAT_FORMAT = -1f - private const val MINIMUM_TOTAL_COUNT = 2 - private const val MAXIMUM_TOTAL_COUNT = 10_000 - private const val INPUT_DATE_TIME_FORMAT = "yyyy년 M월 d일 a h시 m분" - private const val INPUT_DATE_FORMAT = "yyyy년 M월 d일" - private const val OUTPUT_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" - const val HTTPS = "https:" - - @Suppress("UNCHECKED_CAST") - fun getFactory( - offeringRepository: OfferingRepository, - authRepository: AuthRepository, - ) = object : ViewModelProvider.Factory { - override fun create( - modelClass: Class, - extras: CreationExtras, - ): T { - return OfferingWriteViewModel( - offeringRepository, - authRepository, - ) as T - } + companion object { + private const val ERROR_INTEGER_FORMAT = -1 + private const val ERROR_FLOAT_FORMAT = -1f + private const val MINIMUM_TOTAL_COUNT = 2 + private const val MAXIMUM_TOTAL_COUNT = 10_000 + private const val INPUT_DATE_TIME_FORMAT = "yyyy년 M월 d일 a h시 m분" + private const val INPUT_DATE_FORMAT = "yyyy년 M월 d일" + private const val OUTPUT_DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" + const val HTTPS = "https:" } } -} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnOfferingWriteClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt similarity index 75% rename from android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnOfferingWriteClickListener.kt rename to android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt index 56fe0e55b..477df6f5f 100644 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnOfferingWriteClickListener.kt +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnDateTimeButtonsClickListener.kt @@ -1,6 +1,6 @@ package com.zzang.chongdae.presentation.view.write -interface OnOfferingWriteClickListener { +interface OnDateTimeButtonsClickListener { fun onDateTimeSubmitButtonClick() fun onDateTimeCancelButtonClick() diff --git a/android/app/src/main/res/layout/activity_comment_detail.xml b/android/app/src/main/res/layout/activity_comment_detail.xml index 7cc1621fd..f39ec8188 100644 --- a/android/app/src/main/res/layout/activity_comment_detail.xml +++ b/android/app/src/main/res/layout/activity_comment_detail.xml @@ -38,7 +38,7 @@ android:layout_marginStart="@dimen/margin_10" android:layout_marginTop="@dimen/margin_20" android:contentDescription="@string/comment_detail" - android:onClick="@{() -> vm.onBackClick()}" + app:debouncedOnClick="@{() -> vm.onBackClick()}" android:padding="@dimen/margin_10" android:src="@drawable/btn_left_vector" app:layout_constraintStart_toStartOf="parent" @@ -112,7 +112,7 @@ android:elevation="5dp" android:fontFamily="@font/suit_semibold" android:gravity="center" - android:onClick="@{() -> vm.updateOfferingEvent()}" + app:debouncedOnClick="@{() -> vm.updateOfferingEvent()}" android:text="@{vm.commentOfferingInfo.buttonText}" android:textColor="@color/white" android:textSize="@dimen/size_15" @@ -128,7 +128,7 @@ android:layout_width="match_parent" android:layout_height="@dimen/size_36" android:background="@color/gray_100" - android:onClick="@{() -> vm.toggleCollapsibleView()}" + app:debouncedOnClick="@{() -> vm.toggleCollapsibleView()}" android:translationZ="1dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -259,7 +259,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/margin_20" - android:layout_marginEnd="@dimen/margin_20" + android:layout_marginEnd="@dimen/size_23" android:layout_marginBottom="@dimen/margin_30" android:background="@drawable/bg_gray100_radius_16dp" android:fontFamily="@font/suit_medium" @@ -279,9 +279,9 @@ @@ -391,7 +391,7 @@ android:layout_width="@dimen/icon_size_24" android:layout_height="@dimen/icon_size_24" android:layout_marginStart="@dimen/margin_20" - android:onClick="@{() -> vm.exitOffering()}" + app:debouncedOnClick="@{() -> vm.onExitClick()}" android:src="@drawable/btn_exit" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml index 2023b3e1c..eb23270e4 100644 --- a/android/app/src/main/res/layout/activity_login.xml +++ b/android/app/src/main/res/layout/activity_login.xml @@ -55,7 +55,7 @@ android:layout_marginEnd="@dimen/margin_30" android:layout_marginBottom="@dimen/size_120" android:background="@drawable/bg_yellow_radius_12dp" - android:onClick="@{() -> onAuthClickListener.onLoginButtonClick()}" + app:debouncedOnClick="@{() -> onAuthClickListener.onLoginButtonClick()}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> diff --git a/android/app/src/main/res/layout/dialog_alert.xml b/android/app/src/main/res/layout/dialog_alert.xml new file mode 100644 index 000000000..aaa16919c --- /dev/null +++ b/android/app/src/main/res/layout/dialog_alert.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_date_picker.xml b/android/app/src/main/res/layout/dialog_date_picker.xml index 78d4e866c..cadfeb816 100644 --- a/android/app/src/main/res/layout/dialog_date_picker.xml +++ b/android/app/src/main/res/layout/dialog_date_picker.xml @@ -2,14 +2,9 @@ - - - + type="com.zzang.chongdae.presentation.view.write.OnDateTimeButtonsClickListener" /> - - + app:layout_constraintTop_toTopOf="parent" />--> + app:layout_constraintTop_toTopOf="parent" /> - + tools:layout_editor_absoluteX="10dp" />--> +
+ +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java index 6f8cc7ddf..255aff60b 100644 --- a/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/auth/integration/AuthIntegrationTest.java @@ -19,6 +19,7 @@ import io.jsonwebtoken.SignatureAlgorithm; import io.restassured.http.ContentType; import java.time.Duration; +import java.util.Base64; import java.util.Date; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -88,9 +89,9 @@ void should_loginSuccess_when_givenMemberCI() { } } - @DisplayName("토큰 재발급") + @DisplayName("토큰 관리") @Nested - class Refresh { + class ManageToken { List responseHeaderDescriptors = List.of( headerWithName("Set-Cookie").description(""" @@ -111,9 +112,15 @@ class Refresh { @Value("${security.jwt.token.refresh-secret-key}") String refreshSecretKey; + @Value("${security.jwt.token.access-secret-key}") + String accessSecretKey; + @Value("${security.jwt.token.refresh-token-expired}") Duration refreshTokenExpired; + @Value("${security.jwt.token.access-token-expired}") + Duration accessTokenExpired; + MemberEntity member; Date now; @@ -123,6 +130,35 @@ void setUp() { now = Date.from(clock.instant()); } + @DisplayName("만료된 accessToken 경우 예외 발생 후 401 코드를 반환한다.") + @Test + void should_throwException_when_givenExpiredAccessToken() { + Date alreadyExpiredAt = new Date(now.getTime() - accessTokenExpired.toMillis()); + String expiredToken = Jwts.builder() + .setSubject(member.getId().toString()) + .setExpiration(alreadyExpiredAt) + .signWith(SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString(accessSecretKey.getBytes())) + .compact(); + + given(spec).log().all() + .filter(document("access-fail-expired-token", resource(failedSnippets))) + .cookie("access_token", expiredToken) + .when().get("/offerings") + .then().log().all() + .statusCode(401); + } + + @DisplayName("유효하지 않은 accessToken인 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenInvalidAccessToken() { + given(spec).log().all() + .filter(document("refresh-fail-invalid-token", resource(failedSnippets))) + .cookie("access_token", "invalidRefreshToken") + .when().post("/offerings") + .then().log().all() + .statusCode(401); + } + @DisplayName("refreshToken으로 accessToken과 refreshToken을 재발급 한다.") @Test void should_refreshSuccess_when_givenRefreshToken() { @@ -147,14 +183,14 @@ void should_throwException_when_givenInvalidRefreshToken() { .statusCode(401); } - @DisplayName("만료된 refeshToken인 경우 예외가 발생한다.") + @DisplayName("만료된 refeshToken인 경우 예외 발생 후 403 코드를 반환한다.") @Test void should_throwException_when_givenExpiredRefreshToken() { Date alreadyExpiredAt = new Date(now.getTime() - refreshTokenExpired.toMillis()); String expiredToken = Jwts.builder() .setSubject(member.getId().toString()) .setExpiration(alreadyExpiredAt) - .signWith(SignatureAlgorithm.HS256, refreshSecretKey) + .signWith(SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString(refreshSecretKey.getBytes())) .compact(); given(spec).log().all() @@ -162,7 +198,7 @@ void should_throwException_when_givenExpiredRefreshToken() { .cookie("refresh_token", expiredToken) .when().post("/auth/refresh") .then().log().all() - .statusCode(401); + .statusCode(403); } } } diff --git a/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java index 16171d61e..fc2ed916a 100644 --- a/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/comment/integration/CommentIntegrationTest.java @@ -225,9 +225,9 @@ void should_responseCommentRoomInfo_when_givenOfferingId() { .statusCode(200); } - @DisplayName("유효하지 않은 공모에 대해 댓글방 정보를 조회할 경우 예외가 발생한다.") + @DisplayName("존재하지 않는 공모 id로 댓글방 정보를 조회할 경우 예외가 발생한다.") @Test - void should_throwException_when_invalidOffering() { + void should_throwException_when_givenInvalidOffering() { given(spec).log().all() .filter(document("get-comment-room-info-fail-invalid-offering", resource(failSnippets))) .cookies(cookieProvider.createCookiesWithMember(member)) @@ -239,7 +239,7 @@ void should_throwException_when_invalidOffering() { @DisplayName("총대 혹은 참여자가 아닌 사용자가 댓글방 정보를 조회할 경우 예외가 발생한다.") @Test - void should_throwException_when_invalidMember() { + void should_throwException_when_givenInvalidMember() { given(spec).log().all() .filter(document("get-comment-room-info-fail-invalid-member", resource(failSnippets))) .cookies(cookieProvider.createCookiesWithMember(invalidMember)) diff --git a/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java b/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java new file mode 100644 index 000000000..d582fb2d5 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/comment/service/CommentServiceTest.java @@ -0,0 +1,98 @@ +package com.zzang.chongdae.comment.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.zzang.chongdae.comment.service.dto.CommentRoomAllResponse; +import com.zzang.chongdae.comment.service.dto.CommentRoomInfoResponse; +import com.zzang.chongdae.global.service.ServiceTest; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.offering.domain.CommentRoomStatus; +import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class CommentServiceTest extends ServiceTest { + + @Autowired + CommentService commentService; + + @DisplayName("댓글방 목록 조회") + @Nested + class GetAllCommentRoom { + + MemberEntity member; + OfferingEntity offering; + + @BeforeEach + void setUp() { + member = memberFixture.createMember("dora"); + offering = offeringFixture.createOffering(member); + offeringMemberFixture.createProposer(member, offering); + } + + @DisplayName("로그인한 유저가 참여한 댓글방 목록을 조회할 수 있다") + @Test + void should_getAllCommentRoom_when_givenLoginMember() { + // when + CommentRoomAllResponse response = commentService.getAllCommentRoom(member); + + // then + assertEquals(response.offerings().size(), 1); + } + + @DisplayName("댓글방 목록 조회 시 삭제된 공모에 대한 댓글방은 제목에 삭제되었다고 명시되어 있다") + @Test + void should_getAllCommentRoomWithDeletedCommentRoom_when_giveLoginMember() { + // given + offeringFixture.deleteOffering(offering); + + // when + CommentRoomAllResponse response = commentService.getAllCommentRoom(member); + + // then + assertEquals(response.offerings().get(0).offeringTitle(), "삭제된 공동구매입니다."); + } + } + + @DisplayName("댓글방 정보 조회") + @Nested + class GetCommentRoomInfo { + + MemberEntity member; + OfferingEntity offering; + + @BeforeEach + void setUp() { + member = memberFixture.createMember("dora"); + offering = offeringFixture.createOffering(member); + offeringMemberFixture.createProposer(member, offering); + } + + @DisplayName("삭제되지 않은 공모 id를 통해 댓글방 상세 조회를 할 수 있다") + @Test + void should_getExistedCommentRoomInfo_when_givenOfferingId() { + // when + CommentRoomInfoResponse response = commentService.getCommentRoomInfo(offering.getId(), member); + + // then + assertEquals(response.title(), offering.getTitle()); + } + + @DisplayName("삭제된 공모 id를 통해 삭제된 공모라고 명시된 댓글방 상세 조회를 할 수 있다") + @Test + void should_getDeletedCommentRoomInfo_when_givenDeletedOfferingId() { + // given + offeringFixture.deleteOffering(offering); + + // when + CommentRoomInfoResponse response = commentService.getCommentRoomInfo(offering.getId(), member); + + // then + assertEquals(response.status(), CommentRoomStatus.DELETED); + assertEquals(response.title(), "삭제된 공동구매입니다."); + } + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java index 432193cff..cfa82bb56 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java +++ b/backend/src/test/java/com/zzang/chongdae/global/domain/MemberFixture.java @@ -3,6 +3,9 @@ import com.zzang.chongdae.member.domain.AuthProvider; import com.zzang.chongdae.member.repository.MemberRepository; import com.zzang.chongdae.member.repository.entity.MemberEntity; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -20,4 +23,13 @@ public MemberEntity createMember(String nickname) { "1234"); return memberRepository.save(member); } + + public List createMembers(int memberCount) { + List members = new ArrayList<>(); + for (int i = 0; i < memberCount; i++) { + MemberEntity member = createMember("user_%d".formatted(i)); + members.add(member); + } + return Collections.unmodifiableList(members); + } } diff --git a/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java b/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java index 952233d36..e59872c5b 100644 --- a/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java +++ b/backend/src/test/java/com/zzang/chongdae/global/domain/OfferingFixture.java @@ -15,10 +15,14 @@ public class OfferingFixture { @Autowired private OfferingRepository offeringRepository; - public OfferingEntity createOffering(MemberEntity member, CommentRoomStatus commentRoomStatus) { + private OfferingEntity createOffering(MemberEntity member, + String title, + Double discountRate, + OfferingStatus offeringStatus, + CommentRoomStatus commentRoomStatus) { OfferingEntity offering = new OfferingEntity( member, - "title", + title, "description", "thumbnailUrl", "productUrl", @@ -30,16 +34,42 @@ public OfferingEntity createOffering(MemberEntity member, CommentRoomStatus comm 1, 5000, 1000, - 33.3, - OfferingStatus.AVAILABLE, // TODO : 데이터 정합성 맞추기 + discountRate, + offeringStatus, commentRoomStatus ); return offeringRepository.save(offering); } + public OfferingEntity createOffering(MemberEntity member, Double discountRate) { + return createOffering(member, "title", discountRate, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + } + + public OfferingEntity createOffering(MemberEntity member, CommentRoomStatus commentRoomStatus) { + return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, commentRoomStatus); + } + + public OfferingEntity createOffering(MemberEntity member, OfferingStatus offeringStatus) { + return createOffering(member, "title", 33.3, offeringStatus, CommentRoomStatus.GROUPING); + } + public OfferingEntity createOffering(MemberEntity member) { - return createOffering(member, CommentRoomStatus.GROUPING); + return createOffering(member, "title", 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); + } + + public OfferingEntity createOffering(MemberEntity member, String title) { + return createOffering(member, title, 33.3, OfferingStatus.AVAILABLE, CommentRoomStatus.GROUPING); } + public void deleteOffering(OfferingEntity offering) { + offeringRepository.delete(offering); + } + public void deleteOfferingById(Long offeringId) { + offeringRepository.deleteById(offeringId); + } + + public long countOffering() { + return offeringRepository.count(); + } } diff --git a/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java b/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java index ef75eb20b..42e9f7a23 100644 --- a/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java +++ b/backend/src/test/java/com/zzang/chongdae/member/service/NicknameGeneratorTest.java @@ -18,7 +18,7 @@ public class NicknameGeneratorTest { @Test void should_returnNickname_when_generateNickName() { // given - String expected = "춤추는도라"; + String expected = "춤추는장성"; // when String actual = nickNameGenerator.generate(); diff --git a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java index 1db7b21a8..d660abd9f 100644 --- a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingIntegrationTest.java @@ -13,6 +13,7 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.zzang.chongdae.global.integration.IntegrationTest; import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.offering.domain.CommentRoomStatus; import com.zzang.chongdae.offering.domain.OfferingFilter; import com.zzang.chongdae.offering.domain.OfferingFilterType; import com.zzang.chongdae.offering.domain.OfferingStatus; @@ -20,6 +21,7 @@ import com.zzang.chongdae.offering.service.dto.OfferingMeetingUpdateRequest; import com.zzang.chongdae.offering.service.dto.OfferingProductImageRequest; import com.zzang.chongdae.offering.service.dto.OfferingSaveRequest; +import com.zzang.chongdae.offering.service.dto.OfferingUpdateRequest; import com.zzang.chongdae.storage.service.StorageService; import io.restassured.http.ContentType; import java.io.File; @@ -48,24 +50,25 @@ class GetOfferingDetail { parameterWithName("offering-id").description("공모 id (필수)") ); List successResponseDescriptors = List.of( - fieldWithPath("id").description("공모 id"), - fieldWithPath("title").description("제목"), + fieldWithPath("id").description("공모 id (필수)"), + fieldWithPath("title").description("제목 (필수)"), fieldWithPath("productUrl").description("물품 링크"), - fieldWithPath("meetingAddress").description("모집 주소"), + fieldWithPath("meetingAddress").description("모집 주소 (필수)"), fieldWithPath("meetingAddressDetail").description("모집 상세 주소"), - fieldWithPath("description").description("내용"), - fieldWithPath("meetingDate").description("마감시간"), - fieldWithPath("currentCount").description("현재원"), - fieldWithPath("totalCount").description("총원"), + fieldWithPath("description").description("내용 (필수)"), + fieldWithPath("meetingDate").description("마감시간 (필수)"), + fieldWithPath("currentCount").description("현재원 (필수)"), + fieldWithPath("totalCount").description("총원 (필수)"), fieldWithPath("thumbnailUrl").description("사진 링크"), - fieldWithPath("dividedPrice").description("n빵 가격"), - fieldWithPath("totalPrice").description("총가격"), - fieldWithPath("status").description("공모 상태" + fieldWithPath("dividedPrice").description("n빵 가격 (필수)"), + fieldWithPath("totalPrice").description("총가격 (필수)"), + fieldWithPath("originPrice").description("원 가격"), + fieldWithPath("status").description("공모 상태 (필수)" + getEnumValuesAsString(OfferingStatus.class)), - fieldWithPath("memberId").description("공모자 회원 id"), - fieldWithPath("nickname").description("공모자 회원 닉네임"), - fieldWithPath("isProposer").description("공모자 여부"), - fieldWithPath("isParticipated").description("공모 참여 여부") + fieldWithPath("memberId").description("공모자 회원 id (필수)"), + fieldWithPath("nickname").description("공모자 회원 닉네임 (필수)"), + fieldWithPath("isProposer").description("공모자 여부 (필수)"), + fieldWithPath("isParticipated").description("공모 참여 여부 (필수)") ); ResourceSnippetParameters successSnippets = builder() .summary("공모 상세 조회") @@ -510,7 +513,7 @@ void should_createOffering_when_givenOfferingCreateRequest() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.parse("2024-10-11T10:00:00"), + LocalDateTime.now().plusDays(1), "내용입니다." ); @@ -537,7 +540,7 @@ void should_createOffering_when_givenOfferingWithoutOriginPriceCreateRequest() { "서울특별시 광진구 구의강변로 3길 11", "상세주소아파트", "구의동", - LocalDateTime.parse("2024-10-11T10:00:00"), + LocalDateTime.now().plusDays(1), "내용입니다." ); @@ -631,6 +634,33 @@ void should_throwException_when_overMaximumTotalCount() { .then().log().all() .statusCode(400); } + + @DisplayName("거래 날짜를 내일보다 과거로 설정하는 경우 예외가 발생한다.") + @Test + void should_throwException_when_meetingDateBeforeTomorrow() { + OfferingSaveRequest request = new OfferingSaveRequest( + "공모 제목", + "www.naver.com", + "www.naver.com/favicon.ico", + 10, + 10000, + 2000, + "서울특별시 광진구 구의강변로 3길 11", + "상세주소아파트", + "구의동", + LocalDateTime.now(), + "내용입니다." + ); + + given(spec).log().all() + .filter(document("create-offering-fail-with-invalid-meeting-date", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(member)) + .contentType(ContentType.JSON) + .body(request) + .when().post("/offerings") + .then().log().all() + .statusCode(400); + } } @DisplayName("상품 이미지 추출") @@ -724,7 +754,6 @@ class UploadProductImage { .summary("상품 이미지 업로드") .description(""" 상품 이미지를 받아 이미지를 S3에 업로드한다. - 현재 사용 플러그인이 multipart/form-data의 파라미터에 대한 문서화를 지원하지 않습니다. ### Parameters | Part | Type | Description | @@ -760,4 +789,329 @@ void should_uploadImageUrl_when_givenImageFile() { .statusCode(200); } } + + @DisplayName("공모 수정") + @Nested + class UpdateOffering { + + List pathParameterDescriptors = List.of( + parameterWithName("offering-id").description("공모 id (필수)") + ); + + List requestDescriptors = List.of( + fieldWithPath("title").description("제목 (필수)"), + fieldWithPath("productUrl").description("물품 구매 링크"), + fieldWithPath("thumbnailUrl").description("사진 링크"), + fieldWithPath("totalCount").description("총원 (필수)"), + fieldWithPath("totalPrice").description("총가격 (필수)"), + fieldWithPath("originPrice").description("원 가격"), + fieldWithPath("meetingAddress").description("모집 주소 (필수)"), + fieldWithPath("meetingAddressDetail").description("모집 상세 주소"), + fieldWithPath("meetingAddressDong").description("모집 동 주소"), + fieldWithPath("meetingDate").description("모집 종료 시간 (필수)"), + fieldWithPath("description").description("내용 (필수)") + ); + + List successResponseDescriptors = List.of( + fieldWithPath("id").description("공모 id"), + fieldWithPath("title").description("제목"), + fieldWithPath("productUrl").description("물품 링크"), + fieldWithPath("meetingAddress").description("모집 주소"), + fieldWithPath("meetingAddressDetail").description("모집 상세 주소"), + fieldWithPath("description").description("내용"), + fieldWithPath("meetingDate").description("마감시간"), + fieldWithPath("currentCount").description("현재원"), + fieldWithPath("totalCount").description("총원"), + fieldWithPath("thumbnailUrl").description("사진 링크"), + fieldWithPath("dividedPrice").description("n빵 가격"), + fieldWithPath("totalPrice").description("총가격"), + fieldWithPath("status").description("공모 상태" + + getEnumValuesAsString(OfferingStatus.class)), + fieldWithPath("memberId").description("공모자 회원 id"), + fieldWithPath("nickname").description("공모자 회원 닉네임") + ); + + ResourceSnippetParameters successSnippets = builder() + .summary("공모 수정") + .description("공모 정보를 받아 공모를 수정합니다.") + .pathParameters(pathParameterDescriptors) + .requestFields(requestDescriptors) + .responseFields(successResponseDescriptors) + .requestSchema(schema("OfferingUpdateRequest")) + .build(); + + ResourceSnippetParameters failSnippets = builder() + .summary("공모 수정") + .description("공모 정보를 받아 공모를 수정합니다.") + .requestFields(requestDescriptors) + .responseFields(failResponseDescriptors) + .requestSchema(schema("OfferingUpdateRequest")) + .responseSchema(schema("OfferingUpdateFailResponse")) + .build(); + + MemberEntity proposer; + MemberEntity otherMember; + + @BeforeEach + void setUp() { + proposer = memberFixture.createMember("poke"); + otherMember = memberFixture.createMember("other"); + OfferingEntity offering = offeringFixture.createOffering(proposer); + offeringMemberFixture.createProposer(proposer, offering); + List participants = memberFixture.createMembers(9); + participants.forEach(participant -> offeringMemberFixture.createParticipant(participant, offering)); + } + + @DisplayName("공모를 수정할 수 있다.") + @Test + void should_updateOffering_when_givenOfferingId() { + OfferingUpdateRequest request = new OfferingUpdateRequest( + "수정할 제목", + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 20, + 20000, + 5000, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.parse("2024-10-25T00:00:00"), + "수정할 공모 상세 내용" + ); + + given(spec).log().all() + .filter(document("update-offering-success", resource(successSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .contentType(ContentType.JSON) + .pathParam("offering-id", 1) + .body(request) + .when().patch("/offerings/{offering-id}") + .then().log().all() + .statusCode(200); + } + + @DisplayName("제안자가 아닌 사용자가 공모를 수정할 수 없다.") + @Test + void should_throwException_when_updateOtherMember() { + OfferingUpdateRequest request = new OfferingUpdateRequest( + "수정할 제목", + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 20, + 20000, + 5000, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.parse("2024-10-25T00:00:00"), + "수정할 공모 상세 내용" + ); + + given(spec).log().all() + .filter(document("update-offering-fail-not-proposer", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(otherMember)) + .contentType(ContentType.JSON) + .pathParam("offering-id", 1) + .body(request) + .when().patch("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("참여 인원 이하 인원으로 공모를 수정할 수 없다.") + @Test + void should_throwException_when_updateTotalCountLessEqualThanCurrentCount() { + OfferingUpdateRequest request = new OfferingUpdateRequest( + "수정할 제목", + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 9, + 20000, + 5000, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.parse("2024-10-25T00:00:00"), + "수정할 공모 상세 내용" + ); + + given(spec).log().all() + .filter(document("update-offering-fail-less-than-current-count", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .contentType(ContentType.JSON) + .pathParam("offering-id", 1) + .body(request) + .when().patch("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("모집 날짜가 현재와 같거나 지날 경우 수정할 수 없다.") + @Test + void should_throwException_when_modifyMeetingDateBeforeNowToday() { + OfferingUpdateRequest request = new OfferingUpdateRequest( + "수정할 제목", + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 20, + 20000, + 5000, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.now(), + "수정할 공모 상세 내용" + ); + + given(spec).log().all() + .filter(document("update-offering-fail-before-now-today", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .contentType(ContentType.JSON) + .pathParam("offering-id", 1) + .body(request) + .when().patch("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("수정 할 원가격이 N빵 가격보다 작을경우 수정할 수 없다.") + @Test + void should_throwException_when_originPriceLessThanDividePrice() { + OfferingUpdateRequest request = new OfferingUpdateRequest( + "수정할 제목", + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 20, + 20000, + 500, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.parse("2024-10-25T00:00:00"), + "수정할 공모 상세 내용" + ); + + given(spec).log().all() + .filter(document("patch-offering-fail-less-than-divide-price", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .contentType(ContentType.JSON) + .pathParam("offering-id", 1) + .body(request) + .when().patch("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + } + + @DisplayName("공모 삭제") + @Nested + class DeleteOffering { + + List pathParameterDescriptors = List.of( + parameterWithName("offering-id").description("공모 id (필수)") + ); + + ResourceSnippetParameters successSnippets = builder() + .summary("공모 삭제") + .description("공모 id를 통해 공모를 삭제합니다.") + .pathParameters(pathParameterDescriptors) + .responseSchema(schema("OfferingDeleteSuccessResponse")) + .build(); + ResourceSnippetParameters failSnippets = builder() + .summary("공모 삭제") + .description("공모 id를 통해 공모를 삭제합니다.") + .pathParameters(pathParameterDescriptors) + .responseFields(failResponseDescriptors) + .responseSchema(schema("OfferingDeleteFailResponse")) + .build(); + + MemberEntity proposer; + MemberEntity notProposer; + MemberEntity participant; + OfferingEntity offering; + OfferingEntity offeringInProgress; + OfferingEntity offeringDone; + + @BeforeEach + void setUp() { + notProposer = memberFixture.createMember("never"); + proposer = memberFixture.createMember("ever"); + offering = offeringFixture.createOffering(proposer); + participant = memberFixture.createMember("naver"); + offeringMemberFixture.createParticipant(participant, offering); + offeringInProgress = offeringFixture.createOffering(proposer, CommentRoomStatus.TRADING); + offeringDone = offeringFixture.createOffering(proposer, CommentRoomStatus.DONE); + } + + @DisplayName("공모 id로 공모를 삭제할 수 있다") + @Test + void should_deleteOffering_when_givenOfferingId() { + given(spec).log().all() + .filter(document("delete-offering-success", resource(successSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .pathParam("offering-id", offering.getId()) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(204); + } + + @DisplayName("유효하지 않은 공모 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_invalidOffering() { + given(spec).log().all() + .filter(document("delete-offering-fail-invalid-offering", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .pathParam("offering-id", offering.getId() + 9999) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("총대가 아닌 사용자가 공모 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_notProposer() { + given(spec).log().all() + .filter(document("delete-offering-fail-not-proposer", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(notProposer)) + .pathParam("offering-id", offering.getId()) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("참여자가 공모 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_participant() { + given(spec).log().all() + .filter(document("delete-offering-fail-participant", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(participant)) + .pathParam("offering-id", offering.getId()) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("거래 진행 중 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_unavailableStatus() { + given(spec).log().all() + .filter(document("delete-offering-fail-in-progress", resource(failSnippets))) + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .pathParam("offering-id", offeringInProgress.getId()) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + + @DisplayName("거래가 완료된 공모를 삭제할 수 있다.") + @Test + void should_deleteOffering_when_givenDoneOffering() { + given().log().all() + .cookies(cookieProvider.createCookiesWithMember(proposer)) + .pathParam("offering-id", offeringDone.getId()) + .when().delete("/offerings/{offering-id}") + .then().log().all() + .statusCode(204); + } + } } diff --git a/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingReadOnlyIntegrationTest.java b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingReadOnlyIntegrationTest.java new file mode 100644 index 000000000..9a16a4f66 --- /dev/null +++ b/backend/src/test/java/com/zzang/chongdae/offering/integration/OfferingReadOnlyIntegrationTest.java @@ -0,0 +1,186 @@ +package com.zzang.chongdae.offering.integration; + +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.ResourceSnippetParameters.builder; +import static com.epages.restdocs.apispec.RestAssuredRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.Schema.schema; +import static io.restassured.RestAssured.given; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; + +import com.epages.restdocs.apispec.ParameterDescriptorWithType; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.zzang.chongdae.global.integration.IntegrationTest; +import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.offering.domain.OfferingFilter; +import com.zzang.chongdae.offering.domain.OfferingFilterType; +import com.zzang.chongdae.offering.domain.OfferingStatus; +import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.payload.FieldDescriptor; + +public class OfferingReadOnlyIntegrationTest extends IntegrationTest { + + @DisplayName("공모 상세 조회(읽기 전용)") + @Nested + class ReadOnlyGetOffering { + List pathParameterDescriptors = List.of( + parameterWithName("offering-id").description("공모 id (필수)") + ); + List successResponseDescriptors = List.of( + fieldWithPath("id").description("공모 id"), + fieldWithPath("title").description("제목"), + fieldWithPath("meetingAddressDong").description("모집 동 주소"), + fieldWithPath("currentCount").description("현재원"), + fieldWithPath("totalCount").description("총원"), + fieldWithPath("thumbnailUrl").description("사진 링크"), + fieldWithPath("dividedPrice").description("n빵 가격"), + fieldWithPath("originPrice").description("원 가격"), + fieldWithPath("discountRate").description("할인율"), + fieldWithPath("status").description("공모 상태" + + getEnumValuesAsString(OfferingStatus.class)), + fieldWithPath("isOpen").description("공모 참여 가능 여부") + ); + ResourceSnippetParameters successSnippets = builder() + .summary("공모 단건 조회") + .description("공모 단건을 조회합니다.") + .pathParameters(pathParameterDescriptors) + .responseFields(successResponseDescriptors) + .responseSchema(schema("OfferingReadOnlySuccessResponse")) + .build(); + ResourceSnippetParameters failSnippets = builder() + .summary("공모 단건 조회") + .description("공모 id를 통해 공모의 단건 정보를 조회합니다.") + .pathParameters(pathParameterDescriptors) + .responseFields(failResponseDescriptors) + .responseSchema(schema("OfferingReadOnlyFailResponse")) + .build(); + + @BeforeEach + void setUp() { + MemberEntity proposer = memberFixture.createMember("dora"); + OfferingEntity offering = offeringFixture.createOffering(proposer); + offeringMemberFixture.createProposer(proposer, offering); + } + + @DisplayName("공모 단건을 조회할 수 있다") + @Test + void should_responseOffering_when_givenOfferingId() { + given(spec).log().all() + .filter(document("get-offering-read-only-success", resource(successSnippets))) + .pathParam("offering-id", 1) + .when().get("/read-only/offerings/{offering-id}") + .then().log().all() + .statusCode(200); + } + + @DisplayName("유효하지 않은 공모를 단건 조회할 경우 예외가 발생한다.") + @Test + void should_throwException_when_invalidOffering() { + given(spec).log().all() + .filter(document("get-offering-read-only-fail-invalid-offering", resource(failSnippets))) + .pathParam("offering-id", 100) + .when().get("/read-only/offerings/{offering-id}") + .then().log().all() + .statusCode(400); + } + } + + @DisplayName("공모 목록 조회(읽기 전용)") + @Nested + class ReadOnlyGetAllOffering { + + List queryParameterDescriptors = List.of( + parameterWithName("filter").description("필터 이름 (기본값: RECENT)" + + getEnumValuesAsString(OfferingFilter.class)).optional(), + parameterWithName("search").description("검색어").optional(), + parameterWithName("last-id").description("마지막 공모 id").optional(), + parameterWithName("page-size").description("페이지 크기 (기본값: 10)").optional() + ); + List successResponseDescriptors = List.of( + fieldWithPath("offerings[].id").description("공모 id"), + fieldWithPath("offerings[].title").description("제목"), + fieldWithPath("offerings[].meetingAddressDong").description("모집 동 주소"), + fieldWithPath("offerings[].currentCount").description("현재원"), + fieldWithPath("offerings[].totalCount").description("총원"), + fieldWithPath("offerings[].thumbnailUrl").description("사진 링크"), + fieldWithPath("offerings[].dividedPrice").description("n빵 가격"), + fieldWithPath("offerings[].originPrice").description("원 가격"), + fieldWithPath("offerings[].discountRate").description("할인율"), + fieldWithPath("offerings[].status").description("공모 상태" + + getEnumValuesAsString(OfferingStatus.class)), + fieldWithPath("offerings[].isOpen").description("공모 참여 가능 여부") + ); + ResourceSnippetParameters successSnippets = builder() + .summary("공모 목록 조회") + .description("공모 목록을 조회합니다.") + .queryParameters(queryParameterDescriptors) + .responseFields(successResponseDescriptors) + .responseSchema(schema("OfferingAllReadOnlySuccessResponse")) + .build(); + + + @BeforeEach + void setUp() { + MemberEntity proposer = memberFixture.createMember("dora"); + + for (int i = 0; i < 11; i++) { + OfferingEntity offering = offeringFixture.createOffering(proposer); + offeringMemberFixture.createProposer(proposer, offering); + } + } + + @DisplayName("공모 목록을 조회할 수 있다") + @Test + void should_responseAllOffering_when_givenPageInfo() { + given(spec).log().all() + .filter(document("get-all-offering-read-only-success", resource(successSnippets))) + .queryParam("filter", "RECENT") + .queryParam("search", "title") + .queryParam("last-id", 10) + .queryParam("page-size", 10) + .when().get("/read-only/offerings") + .then().log().all() + .statusCode(200); + } + } + + @DisplayName("공모 필터 목록 조회(읽기 전용)") + @Nested + class ReadOnlyGetAllOfferingFilter { + + List successResponseDescriptors = List.of( + fieldWithPath("filters[].name").description("필터 이름" + + getEnumValuesAsString(OfferingFilter.class)), + fieldWithPath("filters[].value").description("필터 디스플레이 이름"), + fieldWithPath("filters[].type").description("필터 디스플레이 여부" + + getEnumValuesAsString(OfferingFilterType.class)) + ); + ResourceSnippetParameters successSnippets = builder() + .summary("공모 필터 목록 조회") + .description("공모 목록 조회 시 필터링할 수 있는 키워드 목록을 조회합니다.") + .responseFields(successResponseDescriptors) + .responseSchema(schema("OfferingFilterReadOnlySuccessResponse")) + .build(); + + + @BeforeEach + void setUp() { + memberFixture.createMember("dora"); + } + + @DisplayName("공모 id로 공모 일정 정보를 조회할 수 있다") + @Test + void should_responseOfferingFilter_when_givenOfferingId() { + given(spec).log().all() + .filter(document("get-all-offering-filter-read-only-success", resource(successSnippets))) + .when().get("/read-only/offerings/filters") + .then().log().all() + .statusCode(200); + } + } +} diff --git a/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java b/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java index 56046217e..b776e9a34 100644 --- a/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java +++ b/backend/src/test/java/com/zzang/chongdae/offering/service/OfferingServiceTest.java @@ -1,16 +1,25 @@ package com.zzang.chongdae.offering.service; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import com.zzang.chongdae.global.exception.MarketException; import com.zzang.chongdae.global.service.ServiceTest; import com.zzang.chongdae.member.repository.entity.MemberEntity; +import com.zzang.chongdae.offering.domain.CommentRoomStatus; +import com.zzang.chongdae.offering.domain.OfferingStatus; import com.zzang.chongdae.offering.repository.entity.OfferingEntity; +import com.zzang.chongdae.offering.service.dto.OfferingAllResponse; import com.zzang.chongdae.offering.service.dto.OfferingAllResponseItem; +import com.zzang.chongdae.offering.service.dto.OfferingDetailResponse; import com.zzang.chongdae.offering.service.dto.OfferingSaveRequest; +import com.zzang.chongdae.offering.service.dto.OfferingUpdateRequest; import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,59 +28,445 @@ public class OfferingServiceTest extends ServiceTest { @Autowired OfferingService offeringService; - @DisplayName("공모 id를 통해 공모를 단건 조회할 수 있다.") - @Test - void should_getOffering_when_givenOfferingId() { - // given - MemberEntity member = memberFixture.createMember("ever"); - OfferingEntity offering = offeringFixture.createOffering(member); - OfferingAllResponseItem expected = new OfferingAllResponseItem(offering, offering.toOfferingPrice()); + @DisplayName("공모 상세 조회") + @Nested + class GetOfferingDetail { - // when - OfferingAllResponseItem actual = offeringService.getOffering(offering.getId()); + MemberEntity member; + OfferingEntity offering; - // then - assertEquals(expected, actual); + @BeforeEach + void setUp() { + member = memberFixture.createMember("dora"); + offering = offeringFixture.createOffering(member); + } + + @DisplayName("공모 id를 통해 공모 상세를 조회할 수 있다") + @Test + void should_getOfferingDetail_when_givenOfferingId() { + // when + OfferingDetailResponse response = offeringService.getOfferingDetail(offering.getId(), member); + + // then + assertEquals(offering.getId(), response.id()); + } + + @DisplayName("존재하지 않는 공모 id를 통해 공모 상세를 조회할 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenInvalidOfferingId() { + // given + long invalidOfferingId = offering.getId() + 9999; + + // when & then + assertThatThrownBy(() -> offeringService.getOfferingDetail(invalidOfferingId, member)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("삭제한 공모 id를 통해 공모 상세를 조회할 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenDeletedOfferingId() { + // given + offeringFixture.deleteOffering(offering); + + // when & then + assertThatThrownBy(() -> offeringService.getOfferingDetail(offering.getId(), member)) + .isInstanceOf(MarketException.class); + } + } + + @DisplayName("공모 단건 조회") + @Nested + class GetOffering { + + OfferingEntity offering; + + @BeforeEach + void setUp() { + MemberEntity member = memberFixture.createMember("ever"); + offering = offeringFixture.createOffering(member); + } + + @DisplayName("공모 id를 통해 공모를 단건 조회할 수 있다") + @Test + void should_getOffering_when_givenOfferingId() { + // given + OfferingAllResponseItem expected = new OfferingAllResponseItem(offering, offering.toOfferingPrice()); + + // when + OfferingAllResponseItem actual = offeringService.getOffering(offering.getId()); + + // then + assertEquals(expected, actual); + } + + @DisplayName("존재하지 않는 공모 id를 통해 공모를 단건 조회할 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenInvalidOfferingId() { + // when & then + long invalidOfferingId = offering.getId() + 9999; + + assertThatThrownBy(() -> offeringService.getOffering(invalidOfferingId)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("삭제한 공모 id를 통해 공모를 단건 조회할 경우 예외가 발생한다.") + @Test + void should_throwException_when_givenDeletedOfferingId() { + // given + offeringFixture.deleteOffering(offering); + + // when & then + assertThatThrownBy(() -> offeringService.getOffering(offering.getId())) + .isInstanceOf(MarketException.class); + } + } + + @DisplayName("공모 목록 조회") + @Nested + class GetOfferings { + + MemberEntity member; + + @BeforeEach + void setUp() { + member = memberFixture.createMember("dora"); + for (int i = 1; i <= 17; i++) { + offeringFixture.createOffering(member); + } + } + + @DisplayName("최신순으로 공모 목록을 10개씩 조회할 수 있다") + @Test + void should_getRecentOfferings() { + // when + OfferingAllResponse response = offeringService.getAllOffering("RECENT", null, null, 10); + + // then + assertEquals(10, response.offerings().size()); + } + + @DisplayName("마지막 페이지 이후로 최신순으로 공모 목록을 조회할 수 있다") + @Test + void should_getRecentOfferings_when_givenLastId() { + // given + OfferingAllResponse lastResponse = offeringService.getAllOffering("RECENT", null, null, 10); + List offerings = lastResponse.offerings(); + Long lastId = offerings.get(offerings.size() - 1).id(); + + // when + OfferingAllResponse response = offeringService.getAllOffering("RECENT", null, lastId, 10); + + // then + assertEquals(7, response.offerings().size()); + } + + @DisplayName("삭제한 공모는 최신순으로 공모 목록 조회 시 포함되지 않는다") + @Test + void should_notIncludeDeletedOffering_when_getOfferings() { + // given + offeringFixture.deleteOfferingById(1L); + + // when + OfferingAllResponse response = offeringService.getAllOffering("RECENT", null, null, 20); + + // then + assertEquals(16, response.offerings().size()); + } + + @DisplayName("검색어를 지정해 최신순으로 공모 목록을 조회할 수 있다") + @Test + void should_getOfferings_when_givenSearchKeyword() { + // given + offeringFixture.createOffering(member, "검색어"); + + // when + OfferingAllResponse response = offeringService.getAllOffering("RECENT", "검색", null, 10); + + // then + assertEquals(1, response.offerings().size()); + } + + @DisplayName("참여 가능한 공모 목록만 조회할 수 있다") + @Test + void should_getJoinableOfferings() { + // when + OfferingAllResponse response = offeringService.getAllOffering("JOINABLE", null, null, 20); + + // then + assertEquals(17, response.offerings().size()); + } + + @DisplayName("마지막 페이지 이후로 참여 가능한 공모 목록을 조회할 수 있다") + @Test + void should_getJoinableOfferings_when_givenLastId() { + // given + OfferingAllResponse lastResponse = offeringService.getAllOffering("JOINABLE", null, null, 10); + List offerings = lastResponse.offerings(); + Long lastId = offerings.get(offerings.size() - 1).id(); + + // when + OfferingAllResponse response = offeringService.getAllOffering("JOINABLE", null, lastId, 10); + + // then + assertEquals(7, response.offerings().size()); + } + + @DisplayName("삭제한 공모는 참여 가능한 공모 목록 조회 시 포함되지 않는다") + @Test + void should_notIncludeDeletedOffering_when_getJoinableOfferings() { + // given + offeringFixture.deleteOfferingById(1L); + + // when + OfferingAllResponse response = offeringService.getAllOffering("JOINABLE", null, null, 20); + + // then + assertEquals(16, response.offerings().size()); + } + + @DisplayName("마감 임박한 공모 목록만 조회할 수 있다") + @Test + void should_getImminentOfferings() { + // when + offeringFixture.createOffering(member, OfferingStatus.IMMINENT); + OfferingAllResponse response = offeringService.getAllOffering("IMMINENT", null, null, 20); + + // then + assertEquals(1, response.offerings().size()); + } + + @DisplayName("마지막 페이지 이후로 마감 임박한 공모 목록을 조회할 수 있다") + @Test + void should_getImminentOfferings_when_givenLastId() { + // given + offeringFixture.createOffering(member, OfferingStatus.IMMINENT); + offeringFixture.createOffering(member, OfferingStatus.IMMINENT); + OfferingAllResponse lastResponse = offeringService.getAllOffering("IMMINENT", null, null, 1); + List offerings = lastResponse.offerings(); + Long lastId = offerings.get(offerings.size() - 1).id(); + + // when + OfferingAllResponse response = offeringService.getAllOffering("IMMINENT", null, lastId, 1); + + // then + assertEquals(1, response.offerings().size()); + } + + @DisplayName("삭제한 공모는 마감 임박한 공모 목록 조회 시 포함되지 않는다") + @Test + void should_notIncludeDeletedOffering_when_getImminentOfferings() { + // given + OfferingEntity offering = offeringFixture.createOffering(member, OfferingStatus.IMMINENT); + offeringFixture.createOffering(member, OfferingStatus.IMMINENT); + offeringFixture.deleteOffering(offering); + + // when + OfferingAllResponse response = offeringService.getAllOffering("IMMINENT", null, null, 20); + + // then + assertEquals(1, response.offerings().size()); + } + + @DisplayName("높은 할인율 순으로 공모 목록을 조회할 수 있다") + @Test + void should_getHighDiscountOfferings() { + // given + offeringFixture.createOffering(member, 50.0); + offeringFixture.createOffering(member, 40.0); + + // when + OfferingAllResponse response = offeringService.getAllOffering("HIGH_DISCOUNT", null, null, 20); + + // then + assertEquals(50.0, response.offerings().get(0).discountRate()); + assertEquals(40.0, response.offerings().get(1).discountRate()); + assertEquals(33.3, response.offerings().get(2).discountRate()); + } + + @DisplayName("마지막 페이지 이후로 높은 할인율 순으로 공모 목록을 조회할 수 있다") + @Test + void should_getHighDiscountOfferings_when_givenLastDiscountRate() { + // given + offeringFixture.createOffering(member, 50.0); + offeringFixture.createOffering(member, 40.0); + OfferingAllResponse lastResponse = offeringService.getAllOffering("HIGH_DISCOUNT", null, null, 1); + List offerings = lastResponse.offerings(); + Long lastId = offerings.get(offerings.size() - 1).id(); + + // when + OfferingAllResponse response = offeringService.getAllOffering("HIGH_DISCOUNT", null, lastId, 10); + + // then + assertEquals(40.0, response.offerings().get(0).discountRate()); + assertEquals(33.3, response.offerings().get(1).discountRate()); + } + + @DisplayName("삭제한 공모는 높은 할인율 순으로 공모 목록 조회 시 포함되지 않는다") + @Test + void should_notIncludeDeletedOffering_when_getHighDiscountOfferings() { + // given + OfferingEntity offering = offeringFixture.createOffering(member, 50.0); + offeringFixture.createOffering(member, 40.0); + offeringFixture.deleteOffering(offering); + + // when + OfferingAllResponse response = offeringService.getAllOffering("HIGH_DISCOUNT", null, null, 20); + + // then + assertEquals(40.0, response.offerings().get(0).discountRate()); + } + } + + @DisplayName("공모 작성") + @Nested + class CreateOffering { + + @DisplayName("공목 등록 시 원 가격 정보가 없더라도 공모 작성에 성공할 수 있다.") + @Test + void should_createOffering_when_givenOfferingWithoutOriginPriceCreateRequest() { + // given + MemberEntity member = memberFixture.createMember("pizza"); + OfferingSaveRequest request = new OfferingSaveRequest( + "공모 제목", + "www.naver.com", + "www.naver.com/favicon.ico", + 5, + 10000, + null, + "서울특별시 광진구 구의강변로 3길 11", + "상세주소아파트", + "구의동", + LocalDateTime.now().plusDays(1), + "내용입니다." + ); + Long expected = 1L; + + // when + Long actual = offeringService.saveOffering(request, member); + + // then + assertEquals(expected, actual); + } } - @DisplayName("유효하지 않은 공모 id를 통해 공모를 단건 조회할 경우 예외가 발생한다.") - @Test - void should_throwException_when_givenInvalidOfferingId() { - // given - MemberEntity member = memberFixture.createMember("ever"); - OfferingEntity offering = offeringFixture.createOffering(member); + @DisplayName("공모 수정") + @Nested + class UpdateOffering { + + @DisplayName("공모를 수정할 수 있음") + @Test + void should_updateOffering_when_givenOfferingIdAndOfferingUpdateRequest() { + // given + MemberEntity member = memberFixture.createMember("poke"); + OfferingEntity offering = offeringFixture.createOffering(member); + String expected = "수정된 공모 제목"; + OfferingUpdateRequest request = new OfferingUpdateRequest( + expected, + "https://to.be.updated/productUrl", + "https://to.be.updated/thumbnail/url", + 10, + 20000, + 5000, + "수정할 모집 장소 주소", + "수정할 모집 상세 주소", + "수정된동", + LocalDateTime.parse("2024-12-31T00:00:00"), + "수정할 공모 상세 내용" + ); - // when & then - long invalidOfferingId = offering.getId() + 9999; + // when + offeringService.updateOffering(offering.getId(), request, member); + OfferingAllResponseItem modifiedOffering = offeringService.getOffering(offering.getId()); + String actual = modifiedOffering.title(); - assertThatThrownBy(() -> offeringService.getOffering(invalidOfferingId)) - .isInstanceOf(MarketException.class); + // then + assertEquals(expected, actual); + } } - @DisplayName("공목 등록 시 원 가격 정보가 없더라도 공모 작성에 성공할 수 있다.") - @Test - void should_createOffering_when_givenOfferingWithoutOriginPriceCreateRequest() { - // given - MemberEntity member = memberFixture.createMember("pizza"); - OfferingSaveRequest request = new OfferingSaveRequest( - "공모 제목", - "www.naver.com", - "www.naver.com/favicon.ico", - 5, - 10000, - null, - "서울특별시 광진구 구의강변로 3길 11", - "상세주소아파트", - "구의동", - LocalDateTime.parse("2024-10-11T10:00:00"), - "내용입니다." - ); - Long expected = 1L; - - // when - Long actual = offeringService.saveOffering(request, member); - - // then - assertEquals(expected, actual); + @DisplayName("공모 삭제") + @Nested + class DeleteOffering { + + MemberEntity notProposer; + MemberEntity proposer; + + @BeforeEach + void setUp() { + notProposer = memberFixture.createMember("never"); + proposer = memberFixture.createMember("ever"); + } + + @DisplayName("공모 id와 총대 엔티티가 주어졌을 때 공모를 삭제할 수 있다.") + @Test + void should_deleteOfferingSoftly_when_givenOfferingIdAndMember() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer); + + // when + offeringService.deleteOffering(offering.getId(), proposer); + + // then + assertThat(offeringFixture.countOffering()).isEqualTo(0); + } + + @DisplayName("총대가 아닌 사용자가 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_deleteWithNotProposer() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer); + + // when & then + assertThatThrownBy(() -> offeringService.deleteOffering(offering.getId(), notProposer)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("유효하지 않은 공모 id에 대해 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_deleteWithInvalidOfferingId() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer); + + // when & then + long invalidOfferingId = offering.getId() + 9999; + + assertThatThrownBy(() -> offeringService.deleteOffering(invalidOfferingId, proposer)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("거래 인원이 확정되고 거래가 완료되기 전 (구매 중 상태) 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_deleteAtBuyingStatus() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer, CommentRoomStatus.BUYING); + + // when & then + assertThatThrownBy(() -> offeringService.deleteOffering(offering.getId(), proposer)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("거래 인원이 확정되고 거래가 완료되기 전 (거래 중 상태) 삭제를 시도할 경우 예외가 발생한다.") + @Test + void should_throwException_when_deleteAtTradingStatus() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer, CommentRoomStatus.TRADING); + + // when & then + assertThatThrownBy(() -> offeringService.deleteOffering(offering.getId(), proposer)) + .isInstanceOf(MarketException.class); + } + + @DisplayName("거래 완료 상태 공모의 경우 삭제가 가능하다.") + @Test + void should_deleteAvailable_when_statusDone() { + // given + OfferingEntity offering = offeringFixture.createOffering(proposer, CommentRoomStatus.DONE); + + // when + offeringService.deleteOffering(offering.getId(), proposer); + + // then + assertThat(offeringFixture.countOffering()).isEqualTo(0); + } } } diff --git a/backend/src/test/resources/static/nickname/adjectives.txt b/backend/src/test/resources/static/nickname/adjectives.txt index be7c8da49..d9dc64607 100644 --- a/backend/src/test/resources/static/nickname/adjectives.txt +++ b/backend/src/test/resources/static/nickname/adjectives.txt @@ -1 +1 @@ -춤추는,달리는,노래하는,사냥하는,지키는,전사,용감한,지혜로운,강한,빠른,조용한,헤엄치는,웃는,슬퍼하는,생각하는,꿈꾸는,사랑하는,기도하는,멋진,아름다운,소중한,힘찬,빛나는,어두운,화려한,단단한,부드러운,귀여운,강렬한,순수한,고요한,신비한,용맹한,차가운,따뜻한,반짝이는,흐르는,가벼운,무거운,흔들리는,날렵한,느린,신속한,강인한,다정한,예민한,온화한,재빠른,굳건한,우직한,유쾌한,의연한,담담한,근엄한,차분한,겸손한,헌신적인,대담한,기민한,예리한,능숙한,창의적인,도전적인,정직한,희망찬,용서하는,배려하는,진실된,정열적인,활기찬,우아한,열정적인,사려깊은,독창적인,성실한,신중한,침착한,냉철한,열렬한,엄격한,단호한,느긋한,탐구하는,분석적인,혁신적인,서있는,앉아있는,누워있는,달콤한,쌉싸름한,매콤한,향기로운,상쾌한,청량한,푸근한,촉촉한,포근한,찬란한,황홀한,짜릿한,아릿한,씩씩한,산뜻한,선명한,생생한,활발한,용기있는,모험적인,신비로운,영롱한,눈부신,고독한,슬픈,기쁜,행복한,즐거운,설레는,기대하는,뿌듯한,흐뭇한,부지런한,당당한,자신있는,평화로운,만족한,흥미로운,매혹적인,기분좋은,상냥한,긍정적인,의심하는,신뢰하는,믿음직한,든든한,편안한,안정적인,평온한,당찬,과감한,확고한,인내하는,예의바른,배려깊은,너그러운,친절한,애정있는,자비로운,은혜로운,강직한,꼼꼼한,기품있는,밝은,고운,자상한,정다운,사근한,아늑한,따사로운,생기있는,호탕한,소박한,맑은,깨끗한,명랑한,존경하는,격려하는,이끄는,희생적인,직관적인,날카로운,재치있는,명석한,영리한,현명한,이성적인,탐구적인,지적인,학구적인,학문적인,박식한,전문적인,기술적인,창조적인,예술적인,감성적인,음악적인,문학적인,철학적인,사색적인,협력적인,친화적인,공감하는,존중하는,포용적인,개방적인,유연한,민첩한,진취적인,섹시한,졸린,화난,과식하는,욕망의,뜨거운,어여쁜,재미있는,돈이많은,우등생,공부하는,밥을먹는,문제아,날아가는,숱이많은,앙증맞은,거대한,향기나는,미세한,독서광,더운,추운,시원한,적당한,네모난,날렵한,야생의,똑똑한,성공한,출세한,이타적인,야심찬,이기적인,엉뚱한,세련된,짓궂은,진지한,말이없는,명령하는,기타치는,소설쓰는,휴가간 +춤추는,달리는,노래하는,사냥하는,지키는,전사,용감한,지혜로운,강한,빠른,조용한,헤엄치는,웃는,생각하는,꿈꾸는,사랑하는,기도하는,멋진,아름다운,소중한,힘찬,빛나는,화려한,단단한,부드러운,귀여운,강렬한,순수한,고요한,신비한,용맹한,따뜻한,반짝이는,흐르는,가벼운,무거운,흔들리는,날렵한,느린,신속한,강인한,다정한,온화한,재빠른,굳건한,우직한,유쾌한,의연한,담담한,근엄한,차분한,겸손한,헌신적인,대담한,기민한,예리한,능숙한,창의적인,도전적인,정직한,희망찬,용서하는,배려하는,진실된,정열적인,활기찬,우아한,열정적인,사려깊은,독창적인,성실한,신중한,침착한,냉철한,열렬한,엄격한,단호한,느긋한,탐구하는,분석적인,혁신적인,서있는,앉아있는,누워있는,달콤한,쌉싸름한,매콤한,향기로운,상쾌한,청량한,푸근한,촉촉한,포근한,찬란한,황홀한,짜릿한,아릿한,씩씩한,산뜻한,선명한,생생한,활발한,용기있는,모험적인,신비로운,영롱한,눈부신,기쁜,행복한,즐거운,설레는,기대하는,뿌듯한,흐뭇한,부지런한,당당한,자신있는,평화로운,만족한,흥미로운,매혹적인,기분좋은,상냥한,긍정적인,의심하는,신뢰하는,믿음직한,든든한,편안한,안정적인,평온한,당찬,과감한,확고한,인내하는,예의바른,배려깊은,너그러운,친절한,애정있는,자비로운,은혜로운,강직한,꼼꼼한,기품있는,밝은,고운,자상한,정다운,사근한,아늑한,따사로운,생기있는,호탕한,소박한,맑은,깨끗한,명랑한,존경하는,격려하는,이끄는,희생적인,직관적인,날카로운,재치있는,명석한,영리한,현명한,이성적인,탐구적인,지적인,학구적인,학문적인,박식한,전문적인,기술적인,창조적인,예술적인,감성적인,음악적인,문학적인,철학적인,사색적인,협력적인,친화적인,공감하는,존중하는,포용적인,개방적인,유연한,민첩한,진취적인,어여쁜,재미있는,돈이많은,우등생,공부하는,밥먹는,날아가는,숱이많은,앙증맞은,거대한,향기나는,미세한,독서광,더운,추운,시원한,적당한,네모난,날렵한,야생의,똑똑한,성공한,출세한,이타적인,야심찬,엉뚱한,세련된,진지한,기타치는,드럼치는,소설쓰는,휴가간 \ No newline at end of file diff --git a/backend/src/test/resources/static/nickname/nouns.txt b/backend/src/test/resources/static/nickname/nouns.txt index fc3504906..838ce7fea 100644 --- a/backend/src/test/resources/static/nickname/nouns.txt +++ b/backend/src/test/resources/static/nickname/nouns.txt @@ -1 +1 @@ -도라,포케,에버,메이슨,서기,알송,채채,제이슨,제임스,토미,포비,리사,총알 +장성,온새미,용머리,루빈,고수,브로콜리,영덕,골목,열쇠,청도,영월,까마귀,하남,아크라,하노이,라즈베리,사과대추,명성산,아차산,익산,불가사리,마드리드,연암산,수련,백양산,미륵산,잠자리,호랑이,바다표범,예천,론,해국,도미,사슴벌레,케리,청량산,멜론,스피츠,체리,치커리,백곰,블랙베리,극락조,검독수리,삼광조,두더지,플래티,연꽃,눈,제리,대암산,야생딸기,산딸기,아세로라,아기돼지,에린,말라보,배추,산청딸기,시츄,향기,양,진도,바마코,휘파람새,정선,치악산,디스커스,한라산,당근,루안다,용담,시냇물,파랑,황석산,돌멩이,문수산,한탄산,바다거북,실러캔스,추억,도쿄,칸나,꽃게,구례,국사봉,콩새,도시,염소,창,게사니,릴리,카네,빅토리아,책,아라,메뚜기,개구리,조지,칸탈루프,복숭아,전복,모나코,로즈,클락새,구관조,공원,우엉,노랑자두,먼치킨,대구,직박구리,로벨,감,자카르타,강,비글,가야산,패랭이,나팔꽃,바람,청송,홍성,강물,과천,종이,페이,민들레,고릴라,물총새,싱가푸라,부여,잉어,오슬로,케인,케일,할미꽃,말미잘,이든,나래,가람,류,제이,토끼,장미,제인,달,닭,예산,커런트,망고스틴,수염고래,팔공산,톤키니즈,거북,체리바브,부천,해바라기,다람쥐,세이,감꽃,양배추,순창,김천,방콕,피타야,나비,게,돼지,햄스터,문어,원주,구절초,랫서판다,유채,은하,페키니즈,무청,블루베리,갈까마귀,베일,크리스,수달,아테네,마닐라,셀리,방울뱀,코뿔소,레인,춘천,대전,비숑,무지개,암꿩,공작,린,보르조이,도베르만,차우차우,금계,운문산,네이,아론,킨샤사,병아리,네일,펭,용인,조령산,마,안개꽃,레몬,홍학,슬기,말,던,빌뉴스,대미산,델리,맘,키위새,수리,순천,프라하,산비취,갈기늑대,꽃잎,토란,매,곰,풍란,루사카,진해,멧도요,청주,니코시아,작약,해오라기,앵무새,삼봉산,고흥,불국산,백조,두견,찌르레기,구름,부엉이,청계산,침팬지,백합,수원,후투티,팔색조,말라뮤트,아보카도,양송이,누리,이슬,소라,성주,추풍령,하늘,암탉,제드,신천옹,금산,산,올빼미,니아메,춤,골드핀치,물수리,하늬,달래,완도,메기,샐러리,퓨마,당근잎,모란,돌,실버샤크,바그다드,속리산,태백,새,내장산,우산,김포,물개,소말리,나이로비,모래,영주,의왕,봉래산,진주,앵초,보고타,바나나,파랑새,마루,라일락,부산,문경,샴,구아바,동해,레드피쉬,달빛,노래,햇살,로지바브,수국,수르남,벵갈,북한산,인형,덕항산,의정부,고래,딱따구리,비버,사천,모닥불,소피아,감악산,까막까치,천안,강진,개미,집오리,철쭉,이구아나,계절,귀,왈라비,연화산,사자산,산책,테헤란,에코,계룡산,브뤼셀,기니피그,파리,고양이,경주,접시꽃,예루살렘,라온,말티즈,조계산,장끼,류블랴나,파슬리,코스모스,귤,새싹,동고비,화순,도락산,낙엽,하마,장흥,모가디슈,바다,아이리,매화,너구리,꺼병이,황지산,갈매기,스컹크,물결,쟝,유산,다올,라브라도,보령,엔젤피쉬,판다,가온,오대산,마이산,수락산,백두산,뱀장어,보아,목련,소,카나리아,광양,손,새벽,솔,공주,고슴도치,베를린,트빌리시,치타,동백,오리엔탈,길,카트룬가,노루귀,무,카이로,거창,펄구라미,백운산,봉화,마리,군산,킹찰스,물,노아,튤립,사막,고령,두타산,푸들,성불산,별빛,울산,팬지,옐로우탱,관악산,솔바람,다리,샤페이,바다사자,루카,파도,사과,라마,족제비,오징어,크낙새,나리,담비,렉스,전갈,르나,데본렉스,학,나릿,불독,비둘기,황매산,시흥,조각,복사꽃,황병산,석류,연어,바르샤바,두륜산,나비꽃,포트비샤,평택,미나리,붕어,파란베타,바다오리,해,바위,고봉산,하이,아나콘다,조개,파인애플,해남,앵두,고양,개개비,바람개비,페더테일,함양,토니,허스키,시베리안,시내,사라예보,진달래,데이지,타일,백일홍,구월산,성남,타임,의성,무궁화,스핑크스,자스민,브라자빌,여름,천관산,청상아리,난초,서울,고창,탈린,함백산,코끼리,메리골,노루,김해,구피,트리폴리,아바나,봄베이,무화과,버마,향로산,펭귄,버만,박새,줄베타,코펜하겐,키토,이브,꾀꼬리,콩,튀니스,알리,여우,까투리,앵무,파프리카,여울,사자,리스본,토리,안산,자몽,올리브,소담,코리,라이,가을,발,자두,쿠웨이트,우주,밤,파파야,스코페,라임,안동,카피바라,백마산,다이,두루미,배,맨드라미,치와와,담양,갈대,오리,뱀,샤인,서산,고니,문조,야자,수박,가마우지,비파꽃,도담,합천,무주,바셋,물소,도르,베타,쥐,원앙,반구,종주산,달맞이,속초,도요새,강아지,거위,천일홍,무스,겨울,워싱턴,리트리버,로리,구미,크랜베리,런던,키예프,따오기,삼척,금오산,독수리,벌,실버달러,포도,로마,노리,대둔산,로드러너,고요,벤,야금,타조,까치,오타와,바라쿠다,솔개,참외,봉숭아,에메랄드,상어,이안,꿈,도마뱀,마가렛,고운,남양주,별,나소,시아,다솜,청포도,진,양양,바바리,집,꿩,고구마,어치,비안,태안,함평,슈나우저,목포,미아,나무,양귀비,에버랜드,청호반새,서귀포,아순시온,천마산,망경산,리아,때까치,리안,광명,모스크바,볼로미,청양,봄,루바브,조이,수꿩,국화,오이,암만,제비,페럿,철새,송골매,미모사,도라에몽,진악산,천황산,파주,영암,평양,페튜니,노을,글로피쉬,십자매,산티아고,신안,제비꽃,북극곰,미어캣,고운산,두견새,라스보라,백로,소백산,라벤더,벌새,베이징,날다람쥐,무등산,몰리,고라니,골담초,영양,원숭이,재규어,보성,월출산,차꽃,종다리,참새,알제,은방울,남원,루비바브,도서,수탉,프리,거제,샤프란,코기,해파리,동자꽃,리가,논산,헬싱키,소금,창원,코알라,돌고래,싱가포르,사슴,비비추,멋쟁이,고사리,연근,대추꽃,딸기,용문산,나무늘보,쟈스,라가머핀,코코넛,물범,포항,강릉,해달,울진,여수,청설모,풍조,스톡홀름,레아,포케,모기,백암산,망고,하르툼,송어,태백산,블루탱,바쿠,월악산,갈고리,대간산,박쥐,유학산,피망,수선화,늑대,라쿤,갑산,반딧불이,인천,물망초,타리꽃,양평,참치,손글,구스베리,상추,무학산,아부자,덕유산,페르시안,아로니아,밀양,주왕산,불영산,시계,운봉산,달마티안,지리산,사모예드,영취산,카트만두,그림,비,미리,빈,사바나,기러기,금강산,청경채,키위,귀촉도,도하,제네바,카라카스,산호세,바질,감자,마나마,음악,거미,리야드,반시,재기러기,양산,리마,도봉산,타이페이,전주,파리지옥,황악산,광주,제천,치산,설악산,달마시안,기린,오소리,펄다니오,새우,감귤 \ No newline at end of file diff --git a/backend/src/test/resources/static/nickname/nouns2.txt b/backend/src/test/resources/static/nickname/nouns2.txt index afded4939..c1ee4fbbc 100644 --- a/backend/src/test/resources/static/nickname/nouns2.txt +++ b/backend/src/test/resources/static/nickname/nouns2.txt @@ -1 +1 @@ -해,달,강,산,나무,바람,구름,별,불,물,꽃,새,호랑이,용,사자,고래,독수리,늑대,여우,곰,사슴,토끼,부엉이,까마귀,참새,매,황소,말,개,고양이,돼지,소,양,닭,거북이,두더지,원숭이,고릴라,코끼리,코뿔소,하마,바다,강아지,올빼미,두루미,까치,앵무새,나비,벌,개미,거미,나무늘보,고슴도치,오소리,공룡,피라미,상어,연어,새우,가재,붕어,잉어,돌고래,참치,연꽃,백합,장미,튤립,국화,해바라기,민들레,무궁화,진달래,철쭉,수선화,제비꽃,나팔꽃,달맞이꽃,제비,학,봉황,비둘기,갈매기,파랑새,물총새,갈색곰,팬더,미어캣,플라밍고,백조,매미,방울새,강산,초원,사막,폭포,숲,눈,우주,천둥,번개,저녁,아침,새벽,황혼,새벽녘,보름달,은하수,해돋이,해질녘,태양,소나기,땅,언덕,계곡,늪,목초지,사파리,정글,밀림,산맥,협곡,절벽,해안,해변,모래사장,바위,암석,산호,해조류,유령,신령,선녀,도깨비,요정,천사,악마,영혼,망령,정령,초록,파랑,빨강,노랑,주황,보라,분홍,회색,흰색,검정,금색,은색,청록,연두,다홍,진홍,남색,청색,미색,담홍,담청,옥색,주황색,갈색,하늘색,청명,무지개,해골,드래곤,유니콘,피닉스,세이렌,메두사,페가수스,히드라,키메라,하피,그리핀,드라큘라,늑대인간,좀비,스켈레톤,레이스,벤시,오로라,자작나무,붉은노을,파도,용암,황금빛,아지랑이,서리,이슬,메아리,흙,잎사귀,뿌리,가시,씨앗,모래,산들바람,비,우박,눈보라,폭풍,폭우,장마,노을,여명,적막,어둠,맑음,흐림,안개,연무,먼지,태풍,허리케인,모래바람,진눈깨비,미풍,강풍,돌풍,눈발,일몰,일출,청둥오리,원앙,왜가리,황새,갈대,억새,연못,호수,시냇물,개천,웅덩이,동굴,바위산,평원,사바나,초지,숲속,정원,공원,대나무숲,잔디밭,유채꽃,벚꽃,라일락,수국,작약,모란,천리향,매화,목련,감나무,배나무,사과나무,포도나무,레몬나무,밤나무,호두나무,은행나무,소나무,참나무,쿼카,악어,기린,오리,너구리,휴먼,침팬지,홍학,가마우지,카멜레온,달팽이,구렁이,이무기,얼룩말,불사조,디멘터,하이에나,맘모스,랩터,햄스터,치타,익룡,멧돼지,산돼지,피글렛,캥거루,산토끼,쥐,기니피그,시골쥐,도시쥐,패럿,수달,북극곰,펭귄,남극곰,밍크,족제비,뱀,코브라,아나콘다,킹코브라,담비,타조,북극여우,오랑우탄,물범,코알라,하프물범,북극토끼,칠면조,직박구리,황제펭귄,물개,판다,랫서판다,범고래,식인고래,개구리,물소,맹꽁이,우파루파,이구아나,염소,노새,당나귀,올챙이,병아리,살모사,도롱뇽,퓨마,다람쥐,알파카,진돗개,웰시코기,말티즈,닥스훈트,리트리버,푸들,낙타,스피츠,삽살개,먼치킨,래그돌,공작새,쥐며느리,키위새,죠스,식인상어,아기상어,자라,표범,청설모,바비루사,빅풋,예티,메추라기,스라소니,삵,카피바라,라마,딱따구리,기러기,스컹크,해태,구미호,인면조,개미핥기,갑오징어,두억시니,샐러맨더,와이번,다오,마리드,배찌,디지니,우니,에띠,케피,로두마니,모스,마리오,루이지,피치,로젤리나,쿠파,키노피오,와리오,사일러스,헤카림,진,가렌,갈리오,갱플랭크,그라가스,나르,나미,나서스,노틸러스,녹턴,누누,니달리,다리우스,다이애나,드레이븐,라이즈,라칸,람머스,럭스,럼블,레넥톤,레오나,렉사이,렐,그웬,렝가,루시안,룰루,르블랑,리신,리븐,바드,미스포츈,문도박사,마스터이,마오카이,말파이트,볼리베어,브라움,모르가나,브랜드,비에고,빅토르,사미라,사이온,샤코,세나,세라핀,세주아니,세트,아리,아무무,신드라,시비르,신짜오,스카너,아이번,아지르,소라카,소나,쉔,애니비아,겐지,둠피스트,리퍼,맥크리,메이,바스티온,솜브라,시메트라,애쉬,에코,정크랫,토르비욘,트레이서,파라,한조,레킹볼,로드호그,시그마,오리사,윈스턴,자리야,루시우,메르시,모이라,바티스트,브리기테,아나,젠야타,방갈로르,미라지,옥테인,레버넌트,호라이즌,퓨즈,크립토,발키리,로바,지브롤터,코스틱,왓슨,램파트,소닉,테일즈,스랄,제이나,가로쉬,데스윙,발리라,마이에브,우서,렉사르,실바나스,말퓨리온,굴단,느조스,메디브,안두인,일리단,피즈,피오라,피들스틱,판테온,파이크,티모,트위치,트런들,고든,탐켄치,탈리야,탈론,타릭,킨드레드,키아나,클레드,퀸,코르키,코그모,케일,케인,케이틀린,케넨,칼리스타,카타리나,카직스,카이사,카서스,카사딘,카밀,카르마,초가스,징크스,질리언,직스,조이,제이스,제라스,제드,잭스,잔나,자크,자르반,일라오이,이즈리얼,이블린,이렐리아,유미,워윅,우르곳,우디르,요릭,요네,올라프,오른,오리아나,오공,엘리스,야스오,애니,알리스타,아트록스,아칼리,아크샨,마린,파이어뱃,벌쳐,시즈탱크,저글링,럴커,메딕,고스트,리버,옵저버,스카웃,캐리어,질럿,아칸,다크아칸,드라군,커세어,스커지,디바우러,가디언,공허충,말자하,카즈야,헤이하치,에디,알리사,간류,니나,안나,리리,브라이언,샤오유,아머킹,요시미츠,자피나,쿠니미츠,화랑,아이작,피터파커,해피호건,페퍼포츠,쟈비스,쿠엔틴벡,호인센,이반반코,페기카터,베티로스,릭메이슨,로라바튼,행크핌,캐시랭,루이스,메이파커,네드,리즈,비전,샘윌슨,스콧랭,트촬라,버키반즈,슈리,은죠부,오코예,나키아,음바쿠,쥬리,웡,칼모르도,도르마무,맷머독,빌리루소,울트론,헬라,헤임달,피터퀼,가모라,드랙스,로켓라쿤,그루트,맨티스,네뷸라,타노스,길가메쉬,스타폭스,킨고,에이잭,치타우리,에고,노웨어,닉퓨리,필콜슨,마리아힐,마야한센,샹치,만다린,드루이그,리오피츠,정복자캉,트촤카,로난,파이리,꼬부기,피카츄,라이츄,버터플,이상해씨,리자몽,거북왕,캐터피,독침붕,피죤,꼬렛,구구,아보,모래두지,고지,니드퀸,니드킹,식스테일,나인테일,픽시,삐삐,주뱃,푸린,뚜벅쵸,디그다,고라파덕,성원숭,윈디,발챙이,슈륙챙이,케이시,윤겔라,알통몬,우츠동,모다피,왕눈해,꼬마돌,롱스톤,야돈,코일,파오리,두두,쥬쥬,질퍽이,파르셀,고오스,팬텀,슬리퍼,크랩,킹크랩,찌리리공,붐볼,아라리,나시,탕구리,홍수몬,시라소몬,내루미,또가스,코뿌리,럭키,쏘드라,콘치,별가사리,아쿠스타,마임맨,스라크,루주라,마그마,잉어킹,갸라도스,라프라스,메타몽,이브이,쥬피썬더,폴리곤,부스터,투구푸스,프테라,잠만보,프리져,썬더,미뇽,망나뇽,뮤츠,뮤,치코리타,토게피,세레비,칠색조,뿔카노,에레브,쁘사이저,켄타로스,샤미드,암나이트,신뇽,질뻐기 +도라,포케,에버,메이슨,서기,알송,채채,제이슨,제임스,토미,포비,리사,총알 \ No newline at end of file