diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..4529bfbec --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,108 @@ +name: Android CI + +on: + push: + branches: [ "develop-AN" ] + paths: + - 'android/**' + pull_request: + branches: [ "develop-AN" ] + paths: + - 'android/**' + +defaults: + run: + working-directory: ./android + +jobs: + ktlint_check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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: Create google-services + run: echo "${{ secrets.GOOGLE_SERVICES_JSON }}" > ./app/google-services.json + + - name: Create local.properties + env: + BASE_URL: ${{ secrets.BASE_URL }} + TOKEN: ${{ secrets.TOKEN }} + NATIVE_APP_KEY: ${{ secrets.NATIVE_APP_KEY }} + run: | + echo "sdk.dir=/Users/chaehyun/Library/Android/sdk" > ./local.properties + echo "base_url=$BASE_URL" >> ./local.properties + echo "token=$TOKEN" >> ./local.properties + echo "native_app_key=$NATIVE_APP_KEY" >> ./local.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Ktlint check + run: ./gradlew ktlintCheck + + build_and_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - 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: Create google-services.json + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: echo "$GOOGLE_SERVICES_JSON" > app/google-services.json + + - name: Create local.properties + env: + BASE_URL: ${{ secrets.BASE_URL }} + TOKEN: ${{ secrets.TOKEN }} + NATIVE_APP_KEY: ${{ secrets.NATIVE_APP_KEY }} + run: | + echo "sdk.dir=/Users/chaehyun/Library/Android/sdk" > ./local.properties + echo "base_url=$BASE_URL" >> ./local.properties + echo "token=$TOKEN" >> ./local.properties + echo "native_app_key=$NATIVE_APP_KEY" >> ./local.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Gradle Clean check + run: ./gradlew clean + + - name: Build with Gradle + run: ./gradlew build + + - name: Run Unit Test + run: ./gradlew test diff --git a/.github/workflows/auto-issue-close.yml b/.github/workflows/auto-issue-close.yml new file mode 100644 index 000000000..0e55d8935 --- /dev/null +++ b/.github/workflows/auto-issue-close.yml @@ -0,0 +1,20 @@ +name: Auto Close Issues on Merge + +on: + pull_request: + types: [closed] + +jobs: + close-related-issue: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.base.ref != 'main' + steps: + - name: Extract and Close Issue + run: | + PR_BODY="${{ github.event.pull_request.body }}" + ISSUE_NUMBERS=$(echo $PR_BODY | grep -oP 'close #\d+' | awk '{print substr($2, 2)}') + for ISSUE_NUMBER in $ISSUE_NUMBERS; do + gh api -X PATCH /repos/${{ github.repository }}/issues/$ISSUE_NUMBER --field state=closed + done + env: + GITHUB_TOKEN: ${{ secrets.AUTO_CLOSE_GITHUB_TOKEN }} diff --git a/.github/workflows/backend-dev-ci-cd.yml b/.github/workflows/backend-dev-ci-cd.yml new file mode 100644 index 000000000..a1f703ad1 --- /dev/null +++ b/.github/workflows/backend-dev-ci-cd.yml @@ -0,0 +1,72 @@ +name: Backend Dev CI/CD Workflow + +on: + push: + branches: [ "develop-BE" ] + paths: + - "backend/**" + - ".github/workflows/backend-dev-ci-cd.yml" + - "Dockerfile" + # pull_request: + # branches: [ "develop-BE" ] + # paths: + # - "backend/**" + # - ".github/workflows/backend-dev-ci-cd.yml" + # - "Dockerfile" + +jobs: + + build-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + + - name: Gradle Caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Set Application yml for dev + run: | + echo "${{ secrets.APPLICATION_PROPERTIES_DEV }}" > src/main/resources/application.properties + working-directory: ./backend + + - name: Build with Gradle Wrapper + run: ./gradlew clean build + working-directory: ./backend + + - name: Docker build and push + run: | + docker login -u ${{ secrets.BE_DOCKERHUB_USERNAME }} -p ${{ secrets.BE_DOCKERHUB_PASSWORD }} + docker build -t ${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }} . + docker tag ${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }} ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7} + docker push ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7} + + deploy: + needs: build-and-test + runs-on: [ self-hosted, dev ] + + steps: + - name: Pull Image And Restart Container + 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_DEV }}:${GITHUB_SHA::7} + docker run --name ${{ secrets.BE_DOCKER_CONTAINER_NAME }} -d -p 80:8080 -v /logs:/logs -e SPRING_PROFILES_ACTIVE=dev ${{ secrets.BE_DOCKERHUB_USERNAME }}/${{ secrets.BE_DOCKER_IMAGE_NAME_DEV }}:${GITHUB_SHA::7} diff --git a/.github/workflows/backend-prod-ci-cd.yml b/.github/workflows/backend-prod-ci-cd.yml new file mode 100644 index 000000000..1b72e3b17 --- /dev/null +++ b/.github/workflows/backend-prod-ci-cd.yml @@ -0,0 +1,72 @@ +name: Backend Prod CI/CD Workflow + +on: + push: + branches: [ "main" ] + paths: + - "backend/**" + - ".github/workflows/backend-prod-ci-cd.yml" + - "Dockerfile" + # pull_request: + # branches: [ "develop-BE" ] + # paths: + # - "backend/**" + # - ".github/workflows/backend-prod-ci-cd.yml" + # - "Dockerfile" + +jobs: + + build-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'corretto' + + - name: Gradle Caching + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Set Application yml for prod + run: | + echo "${{ secrets.APPLICATION_PROPERTIES_PROD }}" > src/main/resources/application.properties + working-directory: ./backend + + - name: Build with Gradle Wrapper + run: ./gradlew clean build -x copyOasToSwagger + working-directory: ./backend + + - name: Docker build and push + run: | + docker login -u ${{ secrets.BE_DOCKERHUB_USERNAME }} -p ${{ secrets.BE_DOCKERHUB_PASSWORD }} + docker build -t ${{ secrets.BE_DOCKER_IMAGE_NAME_PROD }} . + 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: + needs: build-and-test + runs-on: [ self-hosted, prod ] + + steps: + - name: Pull Image And Restart Container + 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} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..c676ab6ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM --platform=linux/arm64 amazoncorretto:17 + +ENV TZ=Asia/Seoul + +COPY backend/build/libs/chongdae-0.0.1-SNAPSHOT.jar app.jar + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c3316c47f..add5aac42 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,32 +1,62 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("de.mannodermaus.android-junit5") version "1.10.0.0" id("kotlin-kapt") + id("com.google.gms.google-services") kotlin("plugin.serialization") version "2.0.0" + id("com.google.firebase.crashlytics") } android { namespace = "com.zzang.chongdae" compileSdk = 34 - + + val properties = + Properties().apply { + try { + load(FileInputStream(rootProject.file("local.properties"))) + } catch (e: Exception) { + e.printStackTrace() + } + } + defaultConfig { applicationId = "com.zzang.chongdae" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0" - + versionCode = 2 + versionName = "1.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder" vectorDrawables { useSupportLibrary = true } + + val baseUrl = properties.getProperty("base_url") + val token = properties.getProperty("token") + val nativeAppKey = properties.getProperty("native_app_key") + + buildConfigField("String", "BASE_URL", "\"$baseUrl\"") + buildConfigField("String", "TOKEN", "\"$token\"") + buildConfigField("String", "NATIVE_APP_KEY", "\"$nativeAppKey\"") + manifestPlaceholders["native_app_key"] = nativeAppKey } - + buildTypes { + debug { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", @@ -47,15 +77,17 @@ android { } } buildFeatures { - dataBinding = true + buildConfig = true } - + dataBinding { enable = true } } 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") @@ -73,30 +105,70 @@ dependencies { 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) + + // 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") + + // Pagination + implementation("androidx.paging:paging-runtime-ktx:3.3.0") + + // WebView + implementation("androidx.webkit:webkit:1.9.0") + + // Firebase + implementation(platform("com.google.firebase:firebase-bom:33.1.2")) + implementation("com.google.firebase:firebase-analytics") + + // 카카오 로그인 + implementation("com.kakao.sdk:v2-all:2.20.3") + + // data store + implementation("androidx.datastore:datastore-preferences:1.0.0") + + implementation("com.google.firebase:firebase-crashlytics") + + // mockk + testImplementation("io.mockk:mockk:1.13.10") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 481bb4348..337b019f2 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -18,4 +18,87 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# retrofit +-keep class com.squareup.retrofit2.** { *; } +-keep class retrofit2.** { *; } +-keepclassmembers,allowobfuscation class * { + @retrofit2.http.* ; +} +-keepattributes Signature +-keepattributes *Annotation* +-keep class com.google.gson.** { *; } +-keep class kotlinx.serialization.** { *; } +-keep class com.zzang.chongdae.data.remote.dto.* { ; } + + +# room +-keep class androidx.room.** { *; } +-keep interface androidx.room.** { *; } +-keepclassmembers class * { + @androidx.room.* ; +} + +# glide +-keep public class * extends com.bumptech.glide.module.AppGlideModule +-keep public class * extends com.bumptech.glide.module.LibraryGlideModule + +-keep public enum com.bumptech.glide.load.resource.bitmap.ImageHeaderParser$** { + **[] $VALUES; + public *; +} + +-keep class * extends com.bumptech.glide.GeneratedAppGlideModule { *; } + +# firebase +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +-keepattributes *Annotation* +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + +# kakao login +-keep class com.kakao.sdk.**.model.* { ; } + +# R8 full mode strips generic signatures from return types if not kept. +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +# paging +-keep class androidx.paging.** { *; } + +# datastore +-keep class androidx.datastore.** { *; } +-keepclassmembers class androidx.datastore.** { *; } + +# mock +-keep class io.mockk.** { *; } +-dontwarn okhttp3.mockwebserver.** + +# webview +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +-keepclassmembers class com.zzang.chongdae.presentation.view.address.JavascriptInterface{ + public *; +} + +-keep public class com.zzang.chongdae.presentation.view.address.JavascriptInterface + +-keepclassmembers class kotlinx.coroutines.** { + *; +} + + +-dontwarn org.bouncycastle.jsse.** +-dontwarn org.conscrypt.* +-dontwarn org.openjsse.** + +-dontwarn javax.swing.** +-dontwarn java.awt.** +-dontwarn java.lang.instrument.** +-dontwarn java.lang.management.** +-dontwarn org.w3c.dom.bootstrap.DOMImplementationRegistry +-dontwarn reactor.blockhound.** diff --git a/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt b/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt new file mode 100644 index 000000000..82e05c116 --- /dev/null +++ b/android/app/src/androidTest/java/com/zzang/chongdae/CommentRoomsFragmentTest.kt @@ -0,0 +1,30 @@ +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/androidTest/java/com/zzang/chongdae/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/com/zzang/chongdae/ExampleInstrumentedTest.kt deleted file mode 100644 index f1451c4cb..000000000 --- a/android/app/src/androidTest/java/com/zzang/chongdae/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.zzang.chongdae - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.zzang.chongdae", appContext.packageName) - } -} diff --git a/android/app/src/androidTest/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivityTest.kt b/android/app/src/androidTest/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivityTest.kt new file mode 100644 index 000000000..78ecbd8b6 --- /dev/null +++ b/android/app/src/androidTest/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivityTest.kt @@ -0,0 +1,30 @@ +package com.zzang.chongdae.presentation.view.commentdetail + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.zzang.chongdae.R +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CommentDetailActivityTest { + private lateinit var scenario: ActivityScenario + + @Before + fun setUp() { + scenario = ActivityScenario.launch(CommentDetailActivity::class.java) + } + + @Test + @DisplayName("댓글 상세 화면이 보여야 한다") + fun displayCommentDetailTest() { + // then + Espresso.onView(ViewMatchers.withId(R.id.view)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index af5bb2380..236f2f386 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + tools:targetApi="33"> + + android:name=".presentation.view.login.LoginActivity" + android:exported="true" + android:theme="@style/Theme.Chongdae"> + + + + + + + + + + + - + + + - + + diff --git a/android/app/src/main/assets/html/address.html b/android/app/src/main/assets/html/address.html new file mode 100644 index 000000000..6b1ff5869 --- /dev/null +++ b/android/app/src/main/assets/html/address.html @@ -0,0 +1,60 @@ + + + + + + + + +
+ + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..a47b43497 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/com/zzang/chongdae/ChongdaeApp.kt b/android/app/src/main/java/com/zzang/chongdae/ChongdaeApp.kt index 6ec0bf91c..e6f0e314b 100644 --- a/android/app/src/main/java/com/zzang/chongdae/ChongdaeApp.kt +++ b/android/app/src/main/java/com/zzang/chongdae/ChongdaeApp.kt @@ -1,9 +1,99 @@ package com.zzang.chongdae import android.app.Application +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore +import com.google.firebase.FirebaseApp +import com.kakao.sdk.common.KakaoSdk +import com.zzang.chongdae.data.local.database.AppDatabase +import com.zzang.chongdae.data.local.source.OfferingLocalDataSourceImpl +import com.zzang.chongdae.data.remote.api.NetworkManager +import com.zzang.chongdae.data.remote.source.AuthRemoteDataSourceImpl +import com.zzang.chongdae.data.remote.source.CommentRemoteDataSourceImpl +import com.zzang.chongdae.data.remote.source.CommentRoomsDataSourceImpl +import com.zzang.chongdae.data.remote.source.OfferingDetailDataSourceImpl +import com.zzang.chongdae.data.remote.source.OfferingRemoteDataSourceImpl +import com.zzang.chongdae.data.remote.source.ParticipantRemoteDataSourceImpl +import com.zzang.chongdae.data.repository.AuthRepositoryImpl +import com.zzang.chongdae.data.repository.CommentDetailRepositoryImpl +import com.zzang.chongdae.data.repository.CommentRoomsRepositoryImpl +import com.zzang.chongdae.data.repository.OfferingDetailRepositoryImpl +import com.zzang.chongdae.data.repository.OfferingRepositoryImpl +import com.zzang.chongdae.data.repository.ParticipantRepositoryImpl +import com.zzang.chongdae.domain.repository.AuthRepository +import com.zzang.chongdae.domain.repository.CommentDetailRepository +import com.zzang.chongdae.domain.repository.CommentRoomsRepository +import com.zzang.chongdae.domain.repository.OfferingDetailRepository +import com.zzang.chongdae.domain.repository.OfferingRepository +import com.zzang.chongdae.domain.repository.ParticipantRepository class ChongdaeApp : Application() { + private val appDatabase: AppDatabase by lazy { AppDatabase.getInstance(this) } + private val networkManager: NetworkManager by lazy { NetworkManager } + + private val offeringDao by lazy { appDatabase.offeringDao() } + + val offeringRepository: OfferingRepository by lazy { + OfferingRepositoryImpl( + offeringLocalDataSource = OfferingLocalDataSourceImpl(offeringDao), + offeringRemoteDataSource = OfferingRemoteDataSourceImpl(networkManager.offeringService()), + ) + } + + val commentDetailRepository: CommentDetailRepository by lazy { + CommentDetailRepositoryImpl( + commentRemoteDataSource = + CommentRemoteDataSourceImpl( + service = networkManager.commentService(), + ), + ) + } + + val commentRoomsRepository: CommentRoomsRepository by lazy { + CommentRoomsRepositoryImpl( + commentRoomsDataSource = CommentRoomsDataSourceImpl(networkManager.commentService()), + ) + } + + val offeringDetailRepository: OfferingDetailRepository by lazy { + OfferingDetailRepositoryImpl( + offeringDetailDataSource = + OfferingDetailDataSourceImpl( + networkManager.offeringService(), + networkManager.participationService(), + ), + ) + } + + val authRepository: AuthRepository by lazy { + AuthRepositoryImpl( + authRemoteDataSource = + AuthRemoteDataSourceImpl( + networkManager.authService(), + ), + ) + } + + val participantRepository: ParticipantRepository by lazy { + ParticipantRepositoryImpl( + participantRemoteDataSource = + ParticipantRemoteDataSourceImpl( + networkManager.participationService(), + ), + ) + } + override fun onCreate() { super.onCreate() + KakaoSdk.init(this, BuildConfig.NATIVE_APP_KEY) + FirebaseApp.initializeApp(this) + _chongdaeApplicationContext = this + } + + companion object { + val Context.dataStore by preferencesDataStore(name = "member_preferences") + + private lateinit var _chongdaeApplicationContext: Context + val chongdaeApplicationContext get() = _chongdaeApplicationContext } } diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/dao/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/local/dao/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/dao/CommentDao.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/dao/CommentDao.kt new file mode 100644 index 000000000..7034a4e76 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/dao/CommentDao.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.zzang.chongdae.data.local.model.CommentEntity + +@Dao +interface CommentDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(commentItemEntities: List) + + @Query("SELECT * FROM comments WHERE offeringId = :offeringId") + suspend fun getCommentsByOfferingId(offeringId: Long): List + + @Query("SELECT commentId FROM comments WHERE offeringId = :offeringId ORDER BY commentId DESC LIMIT 1") + suspend fun getLastCommentIdByOfferingId(offeringId: Long): Long? +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/dao/OfferingDao.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/dao/OfferingDao.kt new file mode 100644 index 000000000..fbe53f9d5 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/dao/OfferingDao.kt @@ -0,0 +1,22 @@ +package com.zzang.chongdae.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.zzang.chongdae.data.local.model.OfferingEntity + +@Dao +interface OfferingDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOffering(offering: OfferingEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(offerings: List) + + @Query("DELETE FROM offerings WHERE id = :id") + suspend fun deleteOfferingById(id: Long) + + @Query("SELECT * FROM offerings") + suspend fun getAllOfferings(): List +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/database/AppDatabase.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/database/AppDatabase.kt new file mode 100644 index 000000000..2b9b6e12e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/database/AppDatabase.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.data.local.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.zzang.chongdae.data.local.dao.CommentDao +import com.zzang.chongdae.data.local.dao.OfferingDao +import com.zzang.chongdae.data.local.model.CommentEntity +import com.zzang.chongdae.data.local.model.OfferingEntity + +@Database( + entities = [ + OfferingEntity::class, + CommentEntity::class, + ], + version = 1, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun commentDao(): CommentDao + + abstract fun offeringDao(): OfferingDao + + companion object { + @Volatile + private var instance: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return instance ?: synchronized(this) { + Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "chongdae_database", + ).addCallback( + object : Callback() {}, + ).build().also { instance = it } + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/model/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/local/model/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/model/CommentEntity.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/model/CommentEntity.kt new file mode 100644 index 000000000..160e8260e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/model/CommentEntity.kt @@ -0,0 +1,27 @@ +package com.zzang.chongdae.data.local.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "comments", + foreignKeys = [ + ForeignKey( + entity = OfferingEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("offeringId"), + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class CommentEntity( + @PrimaryKey(autoGenerate = true) val commentId: Long = 0, + val offeringId: Long, + val content: String, + val isMine: Boolean, + val isProposer: Boolean, + val nickname: String, + val commentCreatedAtDate: String, + val commentCreatedAtTime: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/model/OfferingEntity.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/model/OfferingEntity.kt new file mode 100644 index 000000000..beababe53 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/model/OfferingEntity.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.local.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "offerings") +data class OfferingEntity( + @PrimaryKey val id: Long, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/source/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/local/source/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/local/source/CommentLocalDataSourceImpl.kt b/android/app/src/main/java/com/zzang/chongdae/data/local/source/CommentLocalDataSourceImpl.kt new file mode 100644 index 000000000..cd7f786ce --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/source/CommentLocalDataSourceImpl.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.data.local.source + +import com.zzang.chongdae.data.local.dao.CommentDao +import com.zzang.chongdae.data.local.model.CommentEntity +import com.zzang.chongdae.data.source.comment.CommentLocalDataSource + +class CommentLocalDataSourceImpl(private val commentDao: CommentDao) : CommentLocalDataSource { + override suspend fun insertComments(comments: List) { + commentDao.insertAll(comments) + } + + override suspend fun getLatestCommentId(offeringId: Long): Long? { + return commentDao.getLastCommentIdByOfferingId(offeringId) + } + + override suspend fun getCommentsByOfferingId(offeringId: Long): List { + return commentDao.getCommentsByOfferingId(offeringId) + } +} 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 new file mode 100644 index 000000000..0c0c4e189 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/source/OfferingLocalDataSourceImpl.kt @@ -0,0 +1,15 @@ +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 + +class OfferingLocalDataSourceImpl(private val offeringDao: OfferingDao) : OfferingLocalDataSource { + override suspend fun insertOfferings(offerings: List) { + offeringDao.insertAll(offerings) + } + + 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 new file mode 100644 index 000000000..1f257360d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/local/source/UserPreferencesDataStore.kt @@ -0,0 +1,64 @@ +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/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/mapper/.gitKeep deleted file mode 100644 index e69de29bb..000000000 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/mapper/CommentCreatedAtMapper.kt new file mode 100644 index 000000000..987c3bb08 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentCreatedAtMapper.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.comment.CommentCreatedAtResponse +import com.zzang.chongdae.domain.model.CommentCreatedAt + +fun CommentCreatedAtResponse.toDomain(): CommentCreatedAt { + return CommentCreatedAt( + date = this.date.toLocalDate(), + time = this.time.toLocalTime(), + ) +} 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/mapper/CommentOfferingInfoMapper.kt new file mode 100644 index 000000000..9240ac815 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentOfferingInfoMapper.kt @@ -0,0 +1,14 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.comment.CommentOfferingInfoResponse +import com.zzang.chongdae.domain.model.CommentOfferingInfo + +fun CommentOfferingInfoResponse.toDomain() = + CommentOfferingInfo( + status = this.status, + imageUrl = this.imageUrl, + buttonText = this.buttonText, + message = this.message, + title = this.title, + isProposer = this.isProposer, + ) 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/mapper/CommentRoomResponseMapper.kt new file mode 100644 index 000000000..d38acfaed --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentRoomResponseMapper.kt @@ -0,0 +1,14 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.commentroom.CommentRoomResponse +import com.zzang.chongdae.domain.model.CommentRoom + +fun CommentRoomResponse.toDomain(): CommentRoom { + return CommentRoom( + id = this.offeringId, + title = this.offeringTitle, + latestComment = this.latestComment.content ?: "", + latestCommentTime = this.latestComment.createdAt?.toLocalDateTime(), + isProposer = this.isProposer, + ) +} 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 new file mode 100644 index 000000000..0d8f09e26 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CommentsMapper.kt @@ -0,0 +1,31 @@ +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/CurrentCountMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CurrentCountMapper.kt new file mode 100644 index 000000000..9d7cb36e0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/CurrentCountMapper.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.domain.model.CurrentCount + +fun Int.toCurrentCount() = CurrentCount(this) 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/mapper/FilterMapper.kt new file mode 100644 index 000000000..1209d172a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/FilterMapper.kt @@ -0,0 +1,33 @@ +@file:Suppress("UNUSED_EXPRESSION") + +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.RemoteFilter +import com.zzang.chongdae.data.remote.dto.response.offering.RemoteFilterName +import com.zzang.chongdae.data.remote.dto.response.offering.RemoteFilterType +import com.zzang.chongdae.domain.model.Filter +import com.zzang.chongdae.domain.model.FilterName +import com.zzang.chongdae.domain.model.FilterType + +fun RemoteFilter.toDomain() = + Filter( + name = this.name.toDomain(), + value = this.value, + type = this.type.toDomain(), + ) + +fun RemoteFilterName.toDomain(): FilterName { + return when (this) { + RemoteFilterName.JOINABLE -> FilterName.JOINABLE + RemoteFilterName.IMMINENT -> FilterName.IMMINENT + RemoteFilterName.HIGH_DISCOUNT -> FilterName.HIGH_DISCOUNT + RemoteFilterName.RECENT -> FilterName.RECENT + } +} + +fun RemoteFilterType.toDomain(): FilterType { + return when (this) { + RemoteFilterType.VISIBLE -> FilterType.VISIBLE + RemoteFilterType.INVISIBLE -> FilterType.INVISIBLE + } +} 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/mapper/LocalDateTimeMapper.kt new file mode 100644 index 000000000..6b4bbe711 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/LocalDateTimeMapper.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.mapper + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +fun String.toLocalDateTime(): LocalDateTime = LocalDateTime.parse(this, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + +fun String.toLocalDate(): LocalDate = LocalDate.parse(this, DateTimeFormatter.ISO_LOCAL_DATE) + +fun String.toLocalTime(): LocalTime = LocalTime.parse(this, DateTimeFormatter.ISO_LOCAL_TIME) 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/mapper/MeetingsResponseMapper.kt new file mode 100644 index 000000000..49e75195f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/MeetingsResponseMapper.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.MeetingsResponse +import com.zzang.chongdae.domain.model.Meetings + +fun MeetingsResponse.toDomain() = + Meetings( + meetingDate = this.meetingDate.toLocalDateTime(), + meetingAddress = this.meetingAddress, + meetingAddressDetail = this.meetingAddressDetail, + meetingAddressDong = this.meetingAddressDong, + ) 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 new file mode 100644 index 000000000..1e34a613e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/MemberMapper.kt @@ -0,0 +1,11 @@ +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/mapper/OfferingConditionMapper.kt b/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingConditionMapper.kt new file mode 100644 index 000000000..1231fc25f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingConditionMapper.kt @@ -0,0 +1,13 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOfferingStatus +import com.zzang.chongdae.domain.model.OfferingCondition + +fun RemoteOfferingStatus.toDomain(): OfferingCondition { + return when (this) { + RemoteOfferingStatus.FULL -> OfferingCondition.FULL + RemoteOfferingStatus.IMMINENT -> OfferingCondition.IMMINENT + RemoteOfferingStatus.CONFIRMED -> OfferingCondition.CONFIRMED + RemoteOfferingStatus.AVAILABLE -> OfferingCondition.AVAILABLE + } +} 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/mapper/OfferingDetailResponseMapper.kt new file mode 100644 index 000000000..e40e3440e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingDetailResponseMapper.kt @@ -0,0 +1,24 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.OfferingDetailResponse +import com.zzang.chongdae.domain.model.OfferingDetail + +fun OfferingDetailResponse.toDomain() = + OfferingDetail( + id = this.id, + title = this.title, + nickname = this.nickname, + isProposer = this.isProposer, + productUrl = this.productUrl, + dividedPrice = this.dividedPrice, + thumbnailUrl = this.thumbnailUrl, + totalPrice = this.totalPrice, + meetingDate = this.meetingDate.toLocalDateTime(), + currentCount = this.currentCount.toCurrentCount(), + totalCount = this.totalCount, + meetingAddress = this.meetingAddress, + meetingAddressDetail = this.meetingAddressDetail, + description = this.description, + condition = this.condition.toDomain(), + isParticipated = this.isParticipated, + ) 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/mapper/OfferingMapper.kt new file mode 100644 index 000000000..09818f45b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/OfferingMapper.kt @@ -0,0 +1,18 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.RemoteOffering +import com.zzang.chongdae.domain.model.Offering + +fun RemoteOffering.toDomain() = + Offering( + id = this.id, + title = this.title, + meetingAddressDong = this.meetingAddressDong ?: "", + thumbnailUrl = this.thumbnailUrl, + totalCount = this.totalCount, + currentCount = this.currentCount, + dividedPrice = this.dividedPrice, + originPrice = this.originPrice, + status = this.status.toDomain(), + isOpen = this.isOpen, + ) 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/mapper/ParticipationsResponseMapper.kt new file mode 100644 index 000000000..a4bc9365a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/ParticipationsResponseMapper.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.response.offering.ParticipationResponse +import com.zzang.chongdae.domain.model.Participation + +fun ParticipationResponse.toDomain() = + Participation( + offeringCondition = this.offeringCondition.toDomain(), + currentCount = this.currentCount.toCurrentCount(), + ) 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/mapper/ProductUrlMapper.kt new file mode 100644 index 000000000..655e20aaf --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/ProductUrlMapper.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.data.mapper + +import com.zzang.chongdae.data.remote.dto.request.ProductUrlRequest +import com.zzang.chongdae.data.remote.dto.response.offering.ProductUrlResponse +import com.zzang.chongdae.domain.model.ProductUrl + +fun ProductUrlResponse.toDomain(): ProductUrl { + return ProductUrl( + imageUrl = this.imageUrl, + ) +} + +fun String.toProductUrlRequest(): ProductUrlRequest { + return ProductUrlRequest( + productUrl = this, + ) +} 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/mapper/participant/ParticipantsMapper.kt new file mode 100644 index 000000000..45901377b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/mapper/participant/ParticipantsMapper.kt @@ -0,0 +1,38 @@ +package com.zzang.chongdae.data.mapper.participant + +import com.zzang.chongdae.data.remote.dto.response.participants.ParticipantsResponse +import com.zzang.chongdae.data.remote.dto.response.participants.RemoteCount +import com.zzang.chongdae.data.remote.dto.response.participants.RemoteParticipant +import com.zzang.chongdae.data.remote.dto.response.participants.RemoteProposer +import com.zzang.chongdae.domain.model.participant.Participant +import com.zzang.chongdae.domain.model.participant.ParticipantCount +import com.zzang.chongdae.domain.model.participant.Participants +import com.zzang.chongdae.domain.model.participant.Proposer + +fun ParticipantsResponse.toDomain(): Participants { + return Participants( + proposer = this.remoteProposer.toDomain(), + participants = this.participants.map { it.toDomain() }, + participantCount = this.remoteCount.toDomain(), + price = this.price, + ) +} + +fun RemoteProposer.toDomain(): Proposer { + return Proposer( + nickname = this.nickname, + ) +} + +fun RemoteParticipant.toDomain(): Participant { + return Participant( + nickname = this.nickname, + ) +} + +fun RemoteCount.toDomain(): ParticipantCount { + return ParticipantCount( + totalCount = this.totalCount, + currentCount = this.currentCount, + ) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/AuthApiService.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/AuthApiService.kt new file mode 100644 index 000000000..d90aefe39 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/AuthApiService.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.data.remote.api + +import com.zzang.chongdae.data.remote.dto.request.AccessTokenRequest +import com.zzang.chongdae.data.remote.dto.response.auth.MemberResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthApiService { + @POST("/auth/login/kakao") + suspend fun postLogin( + @Body accessToken: AccessTokenRequest, + ): Response + + @POST("/auth/refresh") + suspend fun postRefresh(): Response +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/CommentApiService.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/CommentApiService.kt new file mode 100644 index 000000000..6697cac75 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/CommentApiService.kt @@ -0,0 +1,38 @@ +package com.zzang.chongdae.data.remote.api + +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.data.remote.dto.response.commentroom.CommentRoomsResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Query + +interface CommentApiService { + @GET("/comments") + suspend fun getCommentRooms(): Response + + @GET("/comments/messages") + suspend fun getComments( + @Query("offering-id") offeringId: Long, + ): Response + + @POST("/comments") + suspend fun postComment( + @Body commentRequest: CommentRequest, + ): Response + + @GET("/comments/info") + suspend fun getCommentOfferingInfo( + @Query("offering-id") offeringId: Long, + ): Response + + @PATCH("/comments/status") + suspend fun patchOfferingStatus( + @Query("offering-id") offeringId: Long, + ): Response +} 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 new file mode 100644 index 000000000..30098821e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/NetworkManager.kt @@ -0,0 +1,48 @@ +package com.zzang.chongdae.data.remote.api + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +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.data.remote.util.TokensCookieJar +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +object NetworkManager { + private var instance: Retrofit? = null + + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + private fun getRetrofit(): Retrofit { + val userDataStore = UserPreferencesDataStore(ChongdaeApp.chongdaeApplicationContext.dataStore) + if (instance == null) { + val contentType = "application/json".toMediaType() + instance = + Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(json.asConverterFactory(contentType)) + .client( + OkHttpClient.Builder() + .cookieJar(TokensCookieJar(userDataStore)) + .build(), + ) + .build() + } + return instance!! + } + + fun offeringService(): OfferingApiService = getRetrofit().create(OfferingApiService::class.java) + + fun participationService(): ParticipationApiService = getRetrofit().create(ParticipationApiService::class.java) + + fun commentService(): CommentApiService = getRetrofit().create(CommentApiService::class.java) + + fun authService(): AuthApiService = getRetrofit().create(AuthApiService::class.java) +} 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 new file mode 100644 index 000000000..8107fd35f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/OfferingApiService.kt @@ -0,0 +1,63 @@ +package com.zzang.chongdae.data.remote.api + +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 +import com.zzang.chongdae.data.remote.dto.response.offering.MeetingsResponse +import com.zzang.chongdae.data.remote.dto.response.offering.OfferingDetailResponse +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 okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface OfferingApiService { + @GET("/offerings") + suspend fun getOfferings( + @Query("filter") filter: String?, + @Query("search") search: String?, + @Query("last-id") lastOfferingId: Long?, + @Query("page-size") pageSize: Int?, + ): Response + + @GET("/offerings/{offering-id}") + suspend fun getOffering( + @Path("offering-id") offeringId: Long, + ): Response + + @GET("/offerings/{offering-id}/detail") + suspend fun getOfferingDetail( + @Path("offering-id") offeringId: Long, + ): Response + + @GET("/offerings/{offering-id}/meetings") + suspend fun getMeetings( + @Path("offering-id") offeringId: Long, + ): Response + + @GET("/offerings/filters") + suspend fun getFilters(): Response + + @POST("/offerings") + suspend fun postOfferingWrite( + @Body offeringWriteRequest: OfferingWriteRequest, + ): Response + + @POST("/offerings/product-images/og") + suspend fun postProductImageOg( + @Body productUrl: ProductUrlRequest, + ): Response + + @Multipart + @POST("/offerings/product-images/s3") + suspend fun postProductImageS3( + @Part image: MultipartBody.Part, + ): Response +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/api/ParticipationApiService.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/ParticipationApiService.kt new file mode 100644 index 000000000..7d9b298e4 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/api/ParticipationApiService.kt @@ -0,0 +1,27 @@ +package com.zzang.chongdae.data.remote.api + +import com.zzang.chongdae.data.remote.dto.request.ParticipationRequest +import com.zzang.chongdae.data.remote.dto.response.participants.ParticipantsResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface ParticipationApiService { + @POST("/participations") + suspend fun postParticipations( + @Body participationRequest: ParticipationRequest, + ): Response + + @GET("/participants") + suspend fun getParticipants( + @Query("offering-id") offeringId: Long, + ): Response + + @DELETE("/participations") + suspend fun deleteParticipations( + @Query("offering-id") offeringId: Long, + ): Response +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/AccessTokenRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/AccessTokenRequest.kt new file mode 100644 index 000000000..a73a07ffa --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/AccessTokenRequest.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AccessTokenRequest( + @SerialName("accessToken") val accessToken: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/CommentRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/CommentRequest.kt new file mode 100644 index 000000000..270b23c95 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/CommentRequest.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentRequest( + @SerialName("offeringId") val offeringId: Long, + @SerialName("content") val content: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingWriteRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingWriteRequest.kt new file mode 100644 index 000000000..ca2eb10e6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/OfferingWriteRequest.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OfferingWriteRequest( + @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/request/ParticipationRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/ParticipationRequest.kt new file mode 100644 index 000000000..d124bfaff --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/ParticipationRequest.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ParticipationRequest( + @SerialName("offeringId") val offeringId: Long, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/ProductUrlRequest.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/ProductUrlRequest.kt new file mode 100644 index 000000000..448359cd2 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/request/ProductUrlRequest.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProductUrlRequest( + @SerialName("productUrl") val productUrl: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/auth/MemberResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/auth/MemberResponse.kt new file mode 100644 index 000000000..74fceb3b7 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/auth/MemberResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MemberResponse( + @SerialName("memberId") val memberId: Long, + @SerialName("nickname") val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentCreatedAtResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentCreatedAtResponse.kt new file mode 100644 index 000000000..bf9bbb9c5 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentCreatedAtResponse.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.remote.dto.response.comment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentCreatedAtResponse( + @SerialName("date") + val date: String, + @SerialName("time") + val time: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentOfferingInfoResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentOfferingInfoResponse.kt new file mode 100644 index 000000000..a773eaa58 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentOfferingInfoResponse.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.data.remote.dto.response.comment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentOfferingInfoResponse( + @SerialName("status") + val status: String, + @SerialName("imageUrl") + val imageUrl: String, + @SerialName("buttonText") + val buttonText: String, + @SerialName("message") + val message: String, + @SerialName("title") + val title: String, + @SerialName("isProposer") + val isProposer: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentResponse.kt new file mode 100644 index 000000000..8726a2b9e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentResponse.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.data.remote.dto.response.comment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentResponse( + @SerialName("commentId") + val commentId: Long, + @SerialName("createdAt") + val commentCreatedAtResponse: CommentCreatedAtResponse, + @SerialName("content") + val content: String, + @SerialName("nickname") + val nickname: String, + @SerialName("isProposer") + val isProposer: Boolean, + @SerialName("isMine") + val isMine: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentsResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentsResponse.kt new file mode 100644 index 000000000..c6b9e4c10 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/CommentsResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.comment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentsResponse( + @SerialName("comments") + val commentsResponse: List, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/UpdatedStatusResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/UpdatedStatusResponse.kt new file mode 100644 index 000000000..18c84f5c7 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/comment/UpdatedStatusResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.comment + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdatedStatusResponse( + @SerialName("updatedStatus") + val updatedStatus: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomResponse.kt new file mode 100644 index 000000000..082737903 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomResponse.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.remote.dto.response.commentroom + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentRoomResponse( + @SerialName("offeringId") val offeringId: Long, + @SerialName("offeringTitle") val offeringTitle: String, + @SerialName("latestComment") val latestComment: LatestCommentResponse, + @SerialName("isProposer") val isProposer: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomsResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomsResponse.kt new file mode 100644 index 000000000..bbe7230f0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/CommentRoomsResponse.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.response.commentroom + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CommentRoomsResponse( + @SerialName("offerings") val commentRoom: List, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/LatestCommentResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/LatestCommentResponse.kt new file mode 100644 index 000000000..be47e8348 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/commentroom/LatestCommentResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.commentroom + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LatestCommentResponse( + @SerialName("content") val content: String?, + @SerialName("createdAt") val createdAt: String?, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/FiltersResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/FiltersResponse.kt new file mode 100644 index 000000000..fadf0bebb --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/FiltersResponse.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FiltersResponse( + @SerialName("filters") val filters: List, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/MeetingsResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/MeetingsResponse.kt new file mode 100644 index 000000000..c3f2ed830 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/MeetingsResponse.kt @@ -0,0 +1,16 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MeetingsResponse( + @SerialName("meetingDate") + val meetingDate: String, + @SerialName("meetingAddress") + val meetingAddress: String, + @SerialName("meetingAddressDetail") + val meetingAddressDetail: String, + @SerialName("meetingAddressDong") + val meetingAddressDong: 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 new file mode 100644 index 000000000..efcde9653 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingDetailResponse.kt @@ -0,0 +1,24 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OfferingDetailResponse( + @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("isProposer") val isProposer: Boolean, + @SerialName("nickname") val nickname: String, + @SerialName("isParticipated") val isParticipated: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingsResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingsResponse.kt new file mode 100644 index 000000000..10b66473e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/OfferingsResponse.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OfferingsResponse( + @SerialName("offerings") val offerings: List, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ParticipationResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ParticipationResponse.kt new file mode 100644 index 000000000..494355420 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ParticipationResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ParticipationResponse( + @SerialName("offeringCondition") val offeringCondition: RemoteOfferingStatus, + @SerialName("currentCount") val currentCount: Int, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ProductUrlResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ProductUrlResponse.kt new file mode 100644 index 000000000..82645fec1 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/ProductUrlResponse.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProductUrlResponse( + @SerialName("imageUrl") + val imageUrl: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilter.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilter.kt new file mode 100644 index 000000000..372b7bdd7 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilter.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RemoteFilter( + @SerialName("name") val name: RemoteFilterName, + @SerialName("value") val value: String, + @SerialName("type") val type: RemoteFilterType, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterName.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterName.kt new file mode 100644 index 000000000..77bb071b0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterName.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName + +enum class RemoteFilterName { + @SerialName("JOINABLE") + JOINABLE, + + @SerialName("IMMINENT") + IMMINENT, + + @SerialName("HIGH_DISCOUNT") + HIGH_DISCOUNT, + + @SerialName("RECENT") + RECENT, +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterType.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterType.kt new file mode 100644 index 000000000..5fc009327 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteFilterType.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName + +enum class RemoteFilterType { + @SerialName("VISIBLE") + VISIBLE, + + @SerialName("INVISIBLE") + INVISIBLE, +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOffering.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOffering.kt new file mode 100644 index 000000000..52e68aa62 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOffering.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RemoteOffering( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("meetingAddressDong") val meetingAddressDong: String?, + @SerialName("currentCount") val currentCount: Int, + @SerialName("totalCount") val totalCount: Int, + @SerialName("thumbnailUrl") val thumbnailUrl: String?, + @SerialName("dividedPrice") val dividedPrice: Int, + @SerialName("originPrice") val originPrice: Int?, + @SerialName("discountRate") val discountRate: Float?, + @SerialName("status") val status: RemoteOfferingStatus, + @SerialName("isOpen") val isOpen: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOfferingStatus.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOfferingStatus.kt new file mode 100644 index 000000000..4467adb94 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/offering/RemoteOfferingStatus.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.data.remote.dto.response.offering + +import kotlinx.serialization.SerialName + +enum class RemoteOfferingStatus { + @SerialName("FULL") + FULL, + + @SerialName("IMMINENT") + IMMINENT, + + @SerialName("CONFIRMED") + CONFIRMED, + + @SerialName("AVAILABLE") + AVAILABLE, +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/ParticipantsResponse.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/ParticipantsResponse.kt new file mode 100644 index 000000000..850aee492 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/ParticipantsResponse.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.remote.dto.response.participants + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ParticipantsResponse( + @SerialName("proposer") val remoteProposer: RemoteProposer, + @SerialName("participants") val participants: List, + @SerialName("count") val remoteCount: RemoteCount, + @SerialName("price") val price: Int, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteCount.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteCount.kt new file mode 100644 index 000000000..ee0d5d394 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteCount.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.participants + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RemoteCount( + @SerialName("currentCount") val currentCount: Int, + @SerialName("totalCount") val totalCount: Int, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteParticipant.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteParticipant.kt new file mode 100644 index 000000000..81f6d1699 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteParticipant.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.participants + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RemoteParticipant( + @SerialName("nickname") + val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteProposer.kt b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteProposer.kt new file mode 100644 index 000000000..ac3570bb5 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/dto/response/participants/RemoteProposer.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.data.remote.dto.response.participants + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RemoteProposer( + @SerialName("nickname") + val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/data/remote/source/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/.gitKeep deleted file mode 100644 index e69de29bb..000000000 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 new file mode 100644 index 000000000..fa01a5385 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/AuthRemoteDataSourceImpl.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..7977fb2fd --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRemoteDataSourceImpl.kt @@ -0,0 +1,35 @@ +package com.zzang.chongdae.data.remote.source + +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 +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 + +class CommentRemoteDataSourceImpl( + 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 fetchCommentOfferingInfo(offeringId: Long): Result = + safeApiCall { + service.getCommentOfferingInfo(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 new file mode 100644 index 000000000..87c72925c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/CommentRoomsDataSourceImpl.kt @@ -0,0 +1,16 @@ +package com.zzang.chongdae.data.remote.source + +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 + +class CommentRoomsDataSourceImpl( + 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 new file mode 100644 index 000000000..38f79e7d1 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingDetailDataSourceImpl.kt @@ -0,0 +1,21 @@ +package com.zzang.chongdae.data.remote.source + +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 + +class OfferingDetailDataSourceImpl( + private val offeringApiService: OfferingApiService, + 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) } +} 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 new file mode 100644 index 000000000..f3994d1a2 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/OfferingRemoteDataSourceImpl.kt @@ -0,0 +1,48 @@ +package com.zzang.chongdae.data.remote.source + +import com.zzang.chongdae.data.mapper.toProductUrlRequest +import com.zzang.chongdae.data.remote.api.OfferingApiService +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.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 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" + } +} 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 new file mode 100644 index 000000000..a847e76df --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/source/ParticipantRemoteDataSourceImpl.kt @@ -0,0 +1,22 @@ +package com.zzang.chongdae.data.remote.source + +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 + +class ParticipantRemoteDataSourceImpl( + 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) + } +} 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 new file mode 100644 index 000000000..aabc7932f --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/CallApiHandler.kt @@ -0,0 +1,38 @@ +package com.zzang.chongdae.data.remote.util + +import com.zzang.chongdae.domain.util.DataError +import com.zzang.chongdae.domain.util.Result +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +inline fun safeApiCall(call: () -> Response): Result { + return try { + val response = call() + if (response.isSuccessful) { + response.body()?.let { + Result.Success(it) + } ?: Result.Error(response.message(), DataError.Network.NULL) + } else { + Result.Error(response.message(), handleHttpError(response.code())) + } + } catch (e: IOException) { + Result.Error(e.message ?: "Unknown IO error", DataError.Network.CONNECTION_ERROR) + } catch (e: HttpException) { + Result.Error(e.message ?: "Unknown HTTP error", handleHttpError(e.code())) + } catch (e: Exception) { + Result.Error(e.message ?: "Unknown error", DataError.Network.UNKNOWN) + } +} + +fun handleHttpError(code: Int): DataError.Network { + return when (code) { + 400 -> DataError.Network.BAD_REQUEST + 401 -> DataError.Network.UNAUTHORIZED + 403 -> DataError.Network.FORBIDDEN + 404 -> DataError.Network.NOT_FOUND + 409 -> DataError.Network.CONFLICT + 500 -> DataError.Network.SERVER_ERROR + else -> DataError.Network.UNKNOWN + } +} 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 new file mode 100644 index 000000000..07b839fbb --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/remote/util/TokensCookieJar.kt @@ -0,0 +1,71 @@ +package com.zzang.chongdae.data.remote.util + +import com.zzang.chongdae.BuildConfig +import com.zzang.chongdae.data.local.source.UserPreferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class TokensCookieJar(private val userPreferencesDataStore: UserPreferencesDataStore) : CookieJar { + private val cookies: MutableMap> = mutableMapOf() + private val urlHost = + BuildConfig.BASE_URL.removePrefix(URL_PREFIX_HTTP).removePrefix(URL_PREFIX_HTTPS) + .substringBefore("/") + + init { + loadTokensFromDataStore() + } + + override fun loadForRequest(url: HttpUrl): List { + return cookies[url.host] ?: emptyList() + } + + override fun saveFromResponse( + url: HttpUrl, + cookies: List, + ) { + this.cookies[url.host] = cookies + saveTokensToDataStore(cookies) + } + + private fun saveTokensToDataStore(cookies: List) { + val accessToken = cookies.first { it.name == ACCESS_TOKEN_NAME }.value + val refreshToken = cookies.first { it.name == REFRESH_TOKEN_NAME }.value + CoroutineScope(Dispatchers.IO).launch { + userPreferencesDataStore.saveTokens(accessToken, refreshToken) + } + } + + private fun loadTokensFromDataStore() { + CoroutineScope(Dispatchers.IO).launch { + val accessToken = userPreferencesDataStore.accessTokenFlow.first() ?: return@launch + val refreshToken = userPreferencesDataStore.refreshTokenFlow.first() ?: return@launch + val accessTokenCookie = makeTokenCookie(ACCESS_TOKEN_NAME, accessToken) + val refreshTokenCookie = makeTokenCookie(REFRESH_TOKEN_NAME, refreshToken) + cookies[urlHost] = listOf(accessTokenCookie, refreshTokenCookie) + } + } + + private fun makeTokenCookie( + tokenName: String, + tokenValue: String, + ): Cookie { + return Cookie.Builder() + .name(tokenName) + .value(tokenValue) + .hostOnlyDomain(urlHost) + .httpOnly() + .build() + } + + companion object { + private const val ACCESS_TOKEN_NAME = "access_token" + private const val REFRESH_TOKEN_NAME = "refresh_token" + private const val URL_PREFIX_HTTP = "http://" + private const val URL_PREFIX_HTTPS = "https://" + } +} 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 new file mode 100644 index 000000000..cdf833974 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..bb4f8d5a2 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentDetailRepositoryImpl.kt @@ -0,0 +1,40 @@ +package com.zzang.chongdae.data.repository + +import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.data.remote.dto.request.CommentRequest +import com.zzang.chongdae.data.source.comment.CommentRemoteDataSource +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 + +class CommentDetailRepositoryImpl( + 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 fetchCommentOfferingInfo(offeringId: Long): Result { + return commentRemoteDataSource.fetchCommentOfferingInfo(offeringId) + .map { it.toDomain() } + } + + 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 new file mode 100644 index 000000000..924cf8544 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/CommentRoomsRepositoryImpl.kt @@ -0,0 +1,18 @@ +package com.zzang.chongdae.data.repository + +import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.data.source.CommentRoomsDataSource +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 + +class CommentRoomsRepositoryImpl( + 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 new file mode 100644 index 000000000..b206f444c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingDetailRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.zzang.chongdae.data.repository + +import com.zzang.chongdae.data.mapper.toDomain +import com.zzang.chongdae.data.remote.dto.request.ParticipationRequest +import com.zzang.chongdae.data.source.OfferingDetailDataSource +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 + +class OfferingDetailRepositoryImpl( + 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), + ) +} 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 new file mode 100644 index 000000000..20793d043 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/OfferingRepositoryImpl.kt @@ -0,0 +1,80 @@ +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.data.source.offering.OfferingLocalDataSource +import com.zzang.chongdae.data.source.offering.OfferingRemoteDataSource +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.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 + +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() } + } + } + + 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 saveProductImageOg(productUrl: String): Result { + return offeringRemoteDataSource.saveProductImageOg(productUrl).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() + } + } +} 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 new file mode 100644 index 000000000..6abae202b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/repository/ParticipantRepositoryImpl.kt @@ -0,0 +1,22 @@ +package com.zzang.chongdae.data.repository + +import com.zzang.chongdae.data.mapper.participant.toDomain +import com.zzang.chongdae.data.source.ParticipantRemoteDataSource +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 + +class ParticipantRepositoryImpl( + 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) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/local/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/repository/local/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/repository/remote/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/repository/remote/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/data/source/.gitKeep deleted file mode 100644 index e69de29bb..000000000 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 new file mode 100644 index 000000000..d101edff6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/AuthRemoteDataSource.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..f133264ed --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/CommentRoomsDataSource.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.source + +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 new file mode 100644 index 000000000..b32806232 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/OfferingDetailDataSource.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.data.source + +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 +} 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 new file mode 100644 index 000000000..b53e3c663 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/ParticipantRemoteDataSource.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.data.source + +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 + + suspend fun deleteParticipations(offeringId: Long): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentLocalDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentLocalDataSource.kt new file mode 100644 index 000000000..cdb8c8489 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentLocalDataSource.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.data.source.comment + +import com.zzang.chongdae.data.local.model.CommentEntity + +interface CommentLocalDataSource { + suspend fun insertComments(comments: List) + + suspend fun getLatestCommentId(offeringId: Long): Long? + + suspend fun getCommentsByOfferingId(offeringId: Long): List +} 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 new file mode 100644 index 000000000..3ce476747 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/comment/CommentRemoteDataSource.kt @@ -0,0 +1,18 @@ +package com.zzang.chongdae.data.source.comment + +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 + + suspend fun fetchComments(offeringId: Long): Result + + suspend fun fetchCommentOfferingInfo(offeringId: Long): Result + + suspend fun updateOfferingStatus(offeringId: Long): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingLocalDataSource.kt b/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingLocalDataSource.kt new file mode 100644 index 000000000..75d85cff7 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingLocalDataSource.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.data.source.offering + +import com.zzang.chongdae.data.local.model.OfferingEntity + +interface OfferingLocalDataSource { + suspend fun insertOfferings(offerings: List) + + suspend fun insertOffering(offering: OfferingEntity) +} 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 new file mode 100644 index 000000000..4f290c056 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/data/source/offering/OfferingRemoteDataSource.kt @@ -0,0 +1,32 @@ +package com.zzang.chongdae.data.source.offering + +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 { + suspend fun fetchOffering(offeringId: Long): Result + + suspend fun fetchOfferings( + filter: String?, + search: String?, + lastOfferingId: Long?, + pageSize: Int?, + ): Result + + suspend fun saveOffering(offeringWriteRequest: OfferingWriteRequest): Result + + suspend fun saveProductImageOg(productUrl: String): Result + + suspend fun saveProductImageS3(image: MultipartBody.Part): Result + + suspend fun fetchFilters(): Result + + suspend fun fetchMeetings(offeringId: Long): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/domain/model/.gitKeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Comment.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Comment.kt new file mode 100644 index 000000000..9f2ccca57 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Comment.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.domain.model + +data class Comment( + val content: String, + val commentCreatedAt: CommentCreatedAt, + val isMine: Boolean, + val isProposer: Boolean, + val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentCreatedAt.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentCreatedAt.kt new file mode 100644 index 000000000..3a0c53fc8 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentCreatedAt.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.domain.model + +import java.time.LocalDate +import java.time.LocalTime + +data class CommentCreatedAt( + val date: LocalDate, + val time: LocalTime, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentOfferingInfo.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentOfferingInfo.kt new file mode 100644 index 000000000..7fe951994 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentOfferingInfo.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.domain.model + +class CommentOfferingInfo( + val status: String, + val imageUrl: String, + val buttonText: String, + val message: String, + val title: String, + val isProposer: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoom.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoom.kt new file mode 100644 index 000000000..c9983ce21 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoom.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.domain.model + +import java.time.LocalDateTime + +data class CommentRoom( + val id: Long, + val title: String, + val latestComment: String, + val latestCommentTime: LocalDateTime?, + val isProposer: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoomType.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoomType.kt new file mode 100644 index 000000000..aa6673b70 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/CommentRoomType.kt @@ -0,0 +1,6 @@ +package com.zzang.chongdae.domain.model + +enum class CommentRoomType(val separator: Int) { + PROPOSER(1), + NOT_PROPOSER(2), +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Count.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Count.kt new file mode 100644 index 000000000..d1fbd19d4 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Count.kt @@ -0,0 +1,26 @@ +package com.zzang.chongdae.domain.model + +@JvmInline +value class Count private constructor(val number: Int) { + fun increase(): Count { + return Count(number + 1) + } + + fun decrease(): Count { + if (number == MINIMUM_COUNT) return Count(number) + return Count(number - 1) + } + + companion object { + private const val ERROR_INTEGER_FORMAT = -1 + private const val MINIMUM_COUNT = 2 + + fun fromString(value: String?): Count { + val intValue = value?.toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (intValue < MINIMUM_COUNT) { + return Count(ERROR_INTEGER_FORMAT) + } + return Count(intValue) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/CurrentCount.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/CurrentCount.kt new file mode 100644 index 000000000..a05c6369a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/CurrentCount.kt @@ -0,0 +1,3 @@ +package com.zzang.chongdae.domain.model + +data class CurrentCount(val value: Int) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/DiscountPrice.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/DiscountPrice.kt new file mode 100644 index 000000000..72a30ab3b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/DiscountPrice.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.domain.model + +@JvmInline +value class DiscountPrice private constructor(val amount: Float) { + init { + require(amount > 0) { "[ERROR] 할인율은 0 초과의 유리수여야 한다." } + } + + companion object { + private const val ERROR_FLOAT_FORMAT = -1f + + fun fromFloat(value: Float?): DiscountPrice { + val floatValue = value ?: ERROR_FLOAT_FORMAT + if (floatValue < 0) { + return DiscountPrice(0f) + } + return DiscountPrice(floatValue) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Filter.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Filter.kt new file mode 100644 index 000000000..90a884cf3 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Filter.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.domain.model + +data class Filter( + val name: FilterName, + val value: String, + val type: FilterType, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterName.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterName.kt new file mode 100644 index 000000000..144ea3517 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterName.kt @@ -0,0 +1,8 @@ +package com.zzang.chongdae.domain.model + +enum class FilterName { + JOINABLE, + IMMINENT, + HIGH_DISCOUNT, + RECENT, +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterType.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterType.kt new file mode 100644 index 000000000..56882bc43 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/FilterType.kt @@ -0,0 +1,6 @@ +package com.zzang.chongdae.domain.model + +enum class FilterType(val isVisible: Boolean) { + VISIBLE(true), + INVISIBLE(false), +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/HttpStatusCode.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/HttpStatusCode.kt new file mode 100644 index 000000000..75b8d3317 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/HttpStatusCode.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.domain.model + +enum class HttpStatusCode(val code: String) { + OK_200("200"), + NOT_FOUND_404("404"), + UNAUTHORIZED_401("401"), + CONFLICT_409("409"), + INTERNAL_SERVER_ERROR_500("500"), +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Meetings.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Meetings.kt new file mode 100644 index 000000000..fc5fad623 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Meetings.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.domain.model + +import java.time.LocalDateTime + +data class Meetings( + val meetingDate: LocalDateTime, + val meetingAddress: String, + val meetingAddressDetail: String, + val meetingAddressDong: String?, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Member.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Member.kt new file mode 100644 index 000000000..86c0f729c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Member.kt @@ -0,0 +1,6 @@ +package com.zzang.chongdae.domain.model + +data class Member( + val memberId: Long, + val nickName: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Offering.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Offering.kt new file mode 100644 index 000000000..44df3f62a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Offering.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.domain.model + +data class Offering( + val id: Long, + val title: String, + val meetingAddressDong: String, + val thumbnailUrl: String?, + val totalCount: Int, + val currentCount: Int, + val dividedPrice: Int, + val originPrice: Int?, + val status: OfferingCondition, + val isOpen: Boolean, +) { + fun calculateDiscountRate(): Float? { + if (originPrice == null) return null + val discountPrice = originPrice - dividedPrice + return (discountPrice.toFloat() / originPrice) * 100 + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingCondition.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingCondition.kt new file mode 100644 index 000000000..022dd11eb --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingCondition.kt @@ -0,0 +1,14 @@ +package com.zzang.chongdae.domain.model + +enum class OfferingCondition { + FULL, + IMMINENT, + CONFIRMED, + AVAILABLE, ; + + companion object { + fun OfferingCondition.isAvailable(): Boolean { + return this == AVAILABLE || this == IMMINENT + } + } +} 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 new file mode 100644 index 000000000..262d50601 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/OfferingDetail.kt @@ -0,0 +1,22 @@ +package com.zzang.chongdae.domain.model + +import java.time.LocalDateTime + +data class OfferingDetail( + val id: Long, + val title: String, + val nickname: String, + val isProposer: Boolean, + val productUrl: String?, + val thumbnailUrl: String?, + val dividedPrice: Int, + val totalPrice: Int, + val meetingDate: LocalDateTime, + val currentCount: CurrentCount, + val totalCount: Int, + val meetingAddress: String, + val meetingAddressDetail: String, + val description: String, + val condition: OfferingCondition, + val isParticipated: Boolean, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Participation.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Participation.kt new file mode 100644 index 000000000..acb690b25 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Participation.kt @@ -0,0 +1,6 @@ +package com.zzang.chongdae.domain.model + +data class Participation( + val offeringCondition: OfferingCondition, + val currentCount: CurrentCount, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/Price.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/Price.kt new file mode 100644 index 000000000..9aa2172db --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/Price.kt @@ -0,0 +1,27 @@ +package com.zzang.chongdae.domain.model + +data class Price(val amount: Int) { + init { + require(amount >= 0) { "[ERROR] 가격은 0 이상의 정수이어야 한다." } + } + + companion object { + private const val ERROR_INTEGER_FORMAT = -1 + + fun fromString(value: String?): Price { + val intValue = value?.toIntOrNull() ?: ERROR_INTEGER_FORMAT + if (intValue < 0) { + return Price(ERROR_INTEGER_FORMAT) + } + return Price(intValue) + } + + fun fromInteger(value: Int?): Price { + val intValue = value ?: ERROR_INTEGER_FORMAT + if (intValue < 0) { + return Price(ERROR_INTEGER_FORMAT) + } + return Price(intValue) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/ProductUrl.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/ProductUrl.kt new file mode 100644 index 000000000..92213b2d9 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/ProductUrl.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.domain.model + +data class ProductUrl( + val imageUrl: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participant.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participant.kt new file mode 100644 index 000000000..7007ce308 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participant.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.domain.model.participant + +data class Participant( + val nickname: String, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/ParticipantCount.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/ParticipantCount.kt new file mode 100644 index 000000000..0c2ad6604 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/ParticipantCount.kt @@ -0,0 +1,6 @@ +package com.zzang.chongdae.domain.model.participant + +data class ParticipantCount( + val currentCount: Int, + val totalCount: Int, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participants.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participants.kt new file mode 100644 index 000000000..4af2218fe --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Participants.kt @@ -0,0 +1,8 @@ +package com.zzang.chongdae.domain.model.participant + +data class Participants( + val proposer: Proposer, + val participants: List, + val participantCount: ParticipantCount, + val price: Int, +) diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Proposer.kt b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Proposer.kt new file mode 100644 index 000000000..35ee101ef --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/model/participant/Proposer.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.domain.model.participant + +data class Proposer( + val nickname: 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 new file mode 100644 index 000000000..b07e7afd0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/paging/OfferingPagingSource.kt @@ -0,0 +1,77 @@ +package com.zzang.chongdae.domain.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +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, + private val authRepository: AuthRepository, + private val search: String?, + private val filter: String?, + private val retry: () -> Unit, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + val lastOfferingId = params.key + return runCatching { + val offerings = + offeringsRepository.fetchOfferings( + filter = filter, + search = search, + lastOfferingId = lastOfferingId, + pageSize = params.loadSize, + ) + + when (offerings) { + is Result.Error -> { + when (offerings.error) { + DataError.Network.UNAUTHORIZED -> { + authRepository.saveRefresh() + retry() + load(params) + } + + else -> { + val prevKey: Long? = null + val nextKey: Long? = null + LoadResult.Page( + data = emptyList(), + prevKey = prevKey, + nextKey = nextKey, + ) + } + } + } + + is Result.Success -> { + val prevKey = + if (lastOfferingId == null) null else lastOfferingId + DEFAULT_PAGE_SIZE + val nextKey = + if (offerings.data.isEmpty() || offerings.data.size < DEFAULT_PAGE_SIZE) null else offerings.data.last().id + + LoadResult.Page( + data = offerings.data, + prevKey = prevKey, + nextKey = nextKey, + ) + } + } + }.onFailure { throwable -> + LoadResult.Error(throwable) + }.getOrThrow() + } + + override fun getRefreshKey(state: PagingState): Long? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.minus(DEFAULT_PAGE_SIZE) + } + } + + companion object { + private const val DEFAULT_PAGE_SIZE = 10 + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/repository/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/domain/repository/.gitKeep deleted file mode 100644 index e69de29bb..000000000 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 new file mode 100644 index 000000000..30b6d0db6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/AuthRepository.kt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 000000000..0b0daa896 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentDetailRepository.kt @@ -0,0 +1,19 @@ +package com.zzang.chongdae.domain.repository + +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( + offeringId: Long, + comment: String, + ): Result + + suspend fun fetchComments(offeringId: Long): Result, DataError.Network> + + suspend fun fetchCommentOfferingInfo(offeringId: Long): Result + + suspend fun updateOfferingStatus(offeringId: Long): Result +} 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 new file mode 100644 index 000000000..e5d883694 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/CommentRoomsRepository.kt @@ -0,0 +1,9 @@ +package com.zzang.chongdae.domain.repository + +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 new file mode 100644 index 000000000..28e682f16 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingDetailRepository.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.domain.repository + +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 +} 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 new file mode 100644 index 000000000..b0ba9e717 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/OfferingRepository.kt @@ -0,0 +1,31 @@ +package com.zzang.chongdae.domain.repository + +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.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 { + suspend fun fetchOffering(offeringId: Long): Result + + suspend fun fetchOfferings( + filter: String?, + search: String?, + lastOfferingId: Long?, + pageSize: Int?, + ): Result, DataError.Network> + + suspend fun saveOffering(uiModel: OfferingWriteUiModel): Result + + suspend fun saveProductImageOg(productUrl: String): Result + + suspend fun saveProductImageS3(image: MultipartBody.Part): Result + + suspend fun fetchFilters(): Result, DataError.Network> + + suspend fun fetchMeetings(offeringId: Long): 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 new file mode 100644 index 000000000..07e502fd0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/repository/ParticipantRepository.kt @@ -0,0 +1,11 @@ +package com.zzang.chongdae.domain.repository + +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 + + suspend fun deleteParticipations(offeringId: Long): Result +} diff --git a/android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt b/android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt new file mode 100644 index 000000000..6c3ec920d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/util/DataError.kt @@ -0,0 +1,16 @@ +package com.zzang.chongdae.domain.util + +sealed interface DataError : Error { + enum class Network : DataError { + UNAUTHORIZED, + UNKNOWN, + NULL, + CONNECTION_ERROR, + FORBIDDEN, + NOT_FOUND, + SERVER_ERROR, + BAD_REQUEST, + CONFLICT, + FAIL_REFRESH, + } +} 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 new file mode 100644 index 000000000..cb8861cc6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/util/Error.kt @@ -0,0 +1,3 @@ +package com.zzang.chongdae.domain.util + +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/domain/util/Result.kt new file mode 100644 index 000000000..1d994e79c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/domain/util/Result.kt @@ -0,0 +1,21 @@ +package com.zzang.chongdae.domain.util + +typealias RootError = Error + +sealed interface Result { + data class Success(val data: D) : Result + + data class Error(val msg: String, val error: E) : Result + + fun map(transform: (D) -> R): Result = + when (this) { + is Success -> Success(transform(data)) + is Error -> this + } + + fun getOrThrow(): D = + when (this) { + is Success -> data + is Error -> throw RuntimeException("Error occurred: $msg, $error") + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/.gitKeep b/android/app/src/main/java/com/zzang/chongdae/presentation/util/.gitKeep deleted file mode 100644 index 8b1378917..000000000 --- a/android/app/src/main/java/com/zzang/chongdae/presentation/util/.gitKeep +++ /dev/null @@ -1 +0,0 @@ - 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 new file mode 100644 index 000000000..1f2bcbccd --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/AccessTokenExpirationHandler.kt @@ -0,0 +1,19 @@ +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 new file mode 100644 index 000000000..35fcd013a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/BindingAdapters.kt @@ -0,0 +1,338 @@ +package com.zzang.chongdae.presentation.util + +import android.animation.ValueAnimator +import android.content.Context +import android.text.Html +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.text.util.Linkify +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.zzang.chongdae.R +import com.zzang.chongdae.domain.model.OfferingCondition +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.regex.Pattern + +@BindingAdapter("title", "colorId") +fun TextView.changeSpecificTextColor( + title: String?, + colorId: Int, +) { + title?.let { + if (!it.contains("*")) return + val originTitle = removeAsterisks(this.text.toString()) + val changedTitleText = extractKeywordBetweenAsterisks(it) ?: return + + val spannableString = SpannableString(originTitle) + + val startIndex = originTitle.indexOf(changedTitleText) + val endIndex = startIndex + changedTitleText.length + + spannableString.setSpan( + ForegroundColorSpan(colorId), + startIndex, + endIndex, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + this.text = spannableString + } +} + +private fun removeAsterisks(title: String): String { + return title.replace("*", "") +} + +private fun extractKeywordBetweenAsterisks(text: String): String? { + val regex = "\\*(.*?)\\*".toRegex() + return regex.find(text)?.groupValues?.get(1) +} + +@BindingAdapter("url") +fun TextView.setHyperlink(url: String?) { + url?.let { + val mTransform = Linkify.TransformFilter { _, _ -> "" } + val pattern = Pattern.compile(this.text.toString()) + Linkify.addLinks(this, pattern, it, null, mTransform) + } +} + +@BindingAdapter("detailProductImageUrl") +fun ImageView.setImageResource(imageUrl: String?) { + imageUrl?.let { + Glide.with(context) + .load(it) + .error(R.drawable.img_detail_product_default) + .fallback(R.drawable.img_detail_product_default) + .into(this) + } +} + +@BindingAdapter("importProductImageUrl") +fun ImageView.importProductImageUrl(imageUrl: String?) { + Glide.with(context) + .load(imageUrl) + .placeholder(R.drawable.btn_upload_photo) + .error(R.drawable.btn_upload_photo) + .into(this) +} + +@BindingAdapter("offeringsProductImageUrl") +fun ImageView.setOfferingsProductImageResource(imageUrl: String?) { + imageUrl.let { + Glide.with(context) + .load(it) + .error(R.drawable.img_main_product_default) + .fallback(R.drawable.img_main_product_default) + .into(this) + } +} + +@BindingAdapter("offeringsStatusImageUrl") +fun ImageView.setOfferingsStatusImageUrl(imageUrl: String?) { + imageUrl.let { + Glide.with(context) + .load(it) + .error(R.drawable.ic_comment_detail_recruiting) + .into(this) + } +} + +@BindingAdapter("imageResource") +fun setImageResource( + imageView: ImageView, + @DrawableRes resource: Int?, +) { + resource?.let { imageView.setImageResource(it) } +} + +@BindingAdapter("offeringConditionForComment", "remaining") +fun TextView.bindConditionComment( + offeringCondition: OfferingCondition?, + remaining: Int, +) { + offeringCondition?.let { + this.text = it.toOfferingComment(context, remaining) + } +} + +private fun OfferingCondition.toOfferingComment( + context: Context, + remaining: Int, +) = when (this) { + OfferingCondition.FULL -> context.getString(R.string.home_offering_condition_full_comment) + OfferingCondition.IMMINENT -> + Html.fromHtml( + context.getString(R.string.home_offering_condition_continue_comment) + .format(remaining), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + + OfferingCondition.CONFIRMED -> "" + OfferingCondition.AVAILABLE -> "" +} + +@BindingAdapter("offeringCondition") +fun TextView.bindConditionText(offeringCondition: OfferingCondition?) { + offeringCondition?.toStyle()?.let { + this.setTextAppearance(it) + } + + offeringCondition?.let { + this.text = it.toOfferingConditionText(context) + this.setTextAppearance(it.toStyle()) + setBackGroundTintByCondition(it) + } +} + +private fun TextView.setBackGroundTintByCondition(offeringCondition: OfferingCondition) { + when (offeringCondition) { + OfferingCondition.FULL -> this.setColor(R.color.offering_full) + OfferingCondition.IMMINENT -> this.setColor(R.color.offering_imminent) + OfferingCondition.CONFIRMED -> this.setColor(R.color.offering_closed) + OfferingCondition.AVAILABLE -> this.setColor(R.color.offering_continue) + } +} + +private fun TextView.setColor(colorId: Int) { + this.background.setTint(this.context.resources.getColor(colorId, null)) +} + +private fun OfferingCondition.toOfferingConditionText(context: Context) = + when (this) { + OfferingCondition.FULL -> context.getString(R.string.home_offering_full) // 인원 만석 + OfferingCondition.IMMINENT -> context.getString(R.string.home_offering_imminent) // 마감임박 + OfferingCondition.CONFIRMED -> context.getString(R.string.home_offering_closed) // 공구마감 + OfferingCondition.AVAILABLE -> context.getString(R.string.home_offering_continue) // 모집중 + } + +private fun OfferingCondition.toStyle() = + when (this) { + OfferingCondition.FULL -> R.style.Theme_AppCompat_TextView_Offering_Full // 인원 만석 + OfferingCondition.IMMINENT -> R.style.Theme_AppCompat_TextView_Offering_Closed // 공구마감 + OfferingCondition.CONFIRMED -> R.style.Theme_AppCompat_TextView_Offering_Closed // 공구마감 + OfferingCondition.AVAILABLE -> R.style.Theme_AppCompat_TextView_Offering_Continue // 모집중 + } + +@BindingAdapter("isVisible") +fun View.setIsVisible(isVisible: Boolean) { + visibility = if (isVisible) View.VISIBLE else View.GONE +} + +@BindingAdapter("formattedDate") +fun TextView.bindFormattedDate(datetime: LocalDateTime?) { + this.text = + datetime?.format(DateTimeFormatter.ofPattern(context.getString(R.string.all_due_datetime))) +} + +@BindingAdapter("currentCount", "totalCount", "condition") +fun TextView.bindStatusComment( + currentCount: Int, + totalCount: Int, + condition: OfferingCondition?, +) { + this.text = condition?.toOfferingConditionText(this.context, currentCount, totalCount) +} + +private fun OfferingCondition.toOfferingConditionText( + context: Context, + currentCount: Int, + totalCount: Int, +) = when (this) { + OfferingCondition.FULL -> context.getString(R.string.offering_detail_participant_full) + OfferingCondition.IMMINENT -> + context.getString( + R.string.offering_detail_participant_count, + currentCount, + totalCount, + ) + + OfferingCondition.CONFIRMED -> context.getString(R.string.offering_detail_participant_end) + OfferingCondition.AVAILABLE -> + context.getString( + R.string.offering_detail_participant_count, + currentCount, + totalCount, + ) +} + +@BindingAdapter("layout_heightWithAnimation") +fun setLayoutHeightWithAnimation( + view: View, + heightDp: Int, +) { + val params: ViewGroup.LayoutParams = view.layoutParams + val startHeight = params.height + + val heightPx = heightDp.toPx(view.context) + + val animator = + ValueAnimator.ofInt(startHeight, heightPx).apply { + duration = 300 + addUpdateListener { animation -> + params.height = animation.animatedValue as Int + view.layoutParams = params + } + } + animator.start() +} + +@BindingAdapter("formattedAmPmTime") +fun TextView.setTime(localDateTime: LocalDateTime?) { + this.text = localDateTime?.format( + DateTimeFormatter.ofPattern( + context.getString(R.string.amPmTime), + Locale.KOREAN, + ), + ) ?: "" +} + +private fun Int.toPx(context: Context): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + context.resources.displayMetrics, + ).toInt() +} + +@BindingAdapter("formattedDeadline") +fun setFormattedDeadline( + textView: TextView, + deadline: LocalDateTime?, +) { + deadline?.let { + val formatter = DateTimeFormatter.ofPattern("yyyy년 M월 d일 EEEE a h시 m분", Locale.KOREAN) + textView.text = it.format(formatter) + } +} + +@BindingAdapter("formattedCommentTime") +fun TextView.setTime(localTime: LocalTime) { + this.text = + localTime.format( + DateTimeFormatter.ofPattern( + context.getString(R.string.amPmTime), + Locale.KOREAN, + ), + ) +} + +@BindingAdapter("setImageProposer") +fun ImageView.setImageProposer(proposer: Boolean) { + val imageRes = if (proposer) R.drawable.ic_proposer else R.drawable.ic_not_proposer + setImageResource(imageRes) +} + +@BindingAdapter("splitPriceValidity", "splitPrice") +fun TextView.setSplitPriceText( + isSplitPriceValid: Boolean?, + splitPrice: Int?, +) { + val text = setSplitPrice(isSplitPriceValid, splitPrice) + this.text = text +} + +private fun TextView.setSplitPrice( + isSplitPriceValid: Boolean?, + splitPrice: Int?, +): String { + if (isSplitPriceValid == true) { + return context.getString(R.string.all_percentage_comma).format(splitPrice) + } + return context.getString(R.string.all_minus) +} + +@BindingAdapter("discountRateValidity", "discountRate") +fun TextView.setDiscountRateValidity( + discountRateValidity: Boolean?, + discountRate: Float?, +) { + val text = setDiscountRate(discountRateValidity, discountRate) + this.text = text +} + +private fun TextView.setDiscountRate( + discountRateValidity: Boolean?, + discountRate: Float?, +): String { + if (discountRateValidity == true) { + return context.getString(R.string.write_discount_rate_value).format(discountRate) + } + return context.getString(R.string.all_minus) +} + +@BindingAdapter("originPrice") +fun EditText.setOriginPriceHint(originPrice: Int) { + this.hint = context.getString(R.string.write_current_split_price).format(originPrice) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/Event.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/Event.kt new file mode 100644 index 000000000..3e3fe958c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/Event.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.presentation.util + +open class Event(private val content: T) { + var hasBeenHandled = false + private set + + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + fun peekContent(): T = content +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/FileUtils.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/FileUtils.kt new file mode 100644 index 000000000..73f808ed5 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/FileUtils.kt @@ -0,0 +1,68 @@ +package com.zzang.chongdae.presentation.util + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +object FileUtils { + fun getMultipartBodyPart( + context: Context, + uri: Uri, + paramName: String, + ): MultipartBody.Part? { + val file = getFileFromUri(context, uri) ?: return null + return getMultipartBodyPart(file, paramName) + } + + private fun getFileFromUri( + context: Context, + uri: Uri, + ): File? { + val contentResolver: ContentResolver = context.contentResolver + val fileName = getFileName(contentResolver, uri) + val file = File(context.cacheDir, fileName) + + try { + val inputStream: InputStream? = contentResolver.openInputStream(uri) + val outputStream = FileOutputStream(file) + inputStream?.copyTo(outputStream) + inputStream?.close() + outputStream.close() + } catch (e: Exception) { + e.printStackTrace() + return null + } + + return file + } + + private fun getFileName( + contentResolver: ContentResolver, + uri: Uri, + ): String { + var name = "" + val returnCursor = contentResolver.query(uri, null, null, null, null) + returnCursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + name = it.getString(nameIndex) + } + } + return name + } + + private fun getMultipartBodyPart( + file: File, + paramName: String, + ): MultipartBody.Part { + val requestBody = file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData(paramName, file.name, requestBody) + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt new file mode 100644 index 000000000..11334b627 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/FirebaseAnalyticsManager.kt @@ -0,0 +1,32 @@ +package com.zzang.chongdae.presentation.util + +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics + +class FirebaseAnalyticsManager(private val firebaseAnalytics: FirebaseAnalytics) { + fun logSelectContentEvent( + id: String, + name: String, + contentType: String, + ) { + val bundle = + Bundle().apply { + putString(FirebaseAnalytics.Param.ITEM_ID, id) + putString(FirebaseAnalytics.Param.ITEM_NAME, name) + putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType) + } + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SELECT_CONTENT, bundle) + } + + fun logScreenView( + screenName: String, + screenClass: String, + ) { + val bundle = + Bundle().apply { + putString(FirebaseAnalytics.Param.SCREEN_NAME, screenName) + putString(FirebaseAnalytics.Param.SCREEN_CLASS, screenClass) + } + firebaseAnalytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle) + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/MutableSingleLiveData.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/MutableSingleLiveData.kt new file mode 100644 index 000000000..7a65326fe --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/MutableSingleLiveData.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.presentation.util + +class MutableSingleLiveData : SingleLiveData { + constructor() : super() + + constructor(value: T) : super(value) + + public override fun postValue(value: T) { + super.postValue(value) + } + + public override fun setValue(value: T) { + super.setValue(value) + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/PermissionManager.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/PermissionManager.kt new file mode 100644 index 000000000..512145fde --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/PermissionManager.kt @@ -0,0 +1,51 @@ +package com.zzang.chongdae.presentation.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment + +class PermissionManager( + private val fragment: Fragment, + private val onPermissionGranted: () -> Unit, + private val onPermissionDenied: () -> Unit, +) { + private val storagePermissions = + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) + + private val requestPermissionLauncher = + fragment.registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + if (permissions.values.all { it }) { + onPermissionGranted() + } else { + onPermissionDenied() + } + } + + fun requestPermissions() { + if (isAndroid13OrAbove() || hasPermissions(fragment.requireContext(), storagePermissions)) { + onPermissionGranted() + } else { + requestPermissionLauncher.launch(storagePermissions) + } + } + + fun isAndroid13OrAbove(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + + private fun hasPermissions( + context: Context, + permissions: Array, + ): Boolean { + return permissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/util/SingleLiveData.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/util/SingleLiveData.kt new file mode 100644 index 000000000..a36fa117d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/util/SingleLiveData.kt @@ -0,0 +1,38 @@ +package com.zzang.chongdae.presentation.util + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData + +abstract class SingleLiveData { + private val liveData = MutableLiveData>() + + protected constructor() + + protected constructor(value: T) { + liveData.value = Event(value) + } + + protected open fun setValue(value: T) { + liveData.value = Event(value) + } + + protected open fun postValue(value: T) { + liveData.postValue(Event(value)) + } + + fun getValue() = liveData.value?.peekContent() + + fun observe( + owner: LifecycleOwner, + onResult: (T) -> Unit, + ) { + liveData.observe(owner) { it.getContentIfNotHandled()?.let(onResult) } + } + + fun observePeek( + owner: LifecycleOwner, + onResult: (T) -> Unit, + ) { + liveData.observe(owner) { onResult(it.peekContent()) } + } +} 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 63a2c2c26..9be6d959d 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 @@ -1,15 +1,71 @@ package com.zzang.chongdae.presentation.view +import android.content.Context +import android.content.Intent import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity +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 class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding + private var _binding: ActivityMainBinding? = null + private val binding get() = _binding!! + private lateinit var navHostFragment: NavHostFragment + private lateinit var navController: NavController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityMainBinding.inflate(layoutInflater) + initBinding() + initNavController() + setupBottomNavigation() + } + + private fun initBinding() { + _binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) } + + private fun initNavController() { + navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment_container) as NavHostFragment + navController = navHostFragment.findNavController() + } + + private fun setupBottomNavigation() { + binding.mainBottomNavigation.setupWithNavController(navController) + } + + fun hideBottomNavigation() { + binding.mainBottomNavigation.visibility = View.GONE + } + + fun showBottomNavigation() { + binding.mainBottomNavigation.visibility = View.VISIBLE + } + + override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).apply { + this.hideSoftInputFromWindow(currentFocus?.windowToken, 0) + } + return super.dispatchTouchEvent(motionEvent) + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + companion object { + 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/address/AddressFinderDialog.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/AddressFinderDialog.kt new file mode 100644 index 000000000..a8250f0fa --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/AddressFinderDialog.kt @@ -0,0 +1,90 @@ +package com.zzang.chongdae.presentation.view.address + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.setFragmentResult +import androidx.webkit.WebViewAssetLoader +import com.zzang.chongdae.databinding.DialogAddressFinderBinding + +class AddressFinderDialog : DialogFragment(), OnAddressClickListener { + private var _binding: DialogAddressFinderBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = DialogAddressFinderBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + initDialog() + initWebView() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun initDialog() { + dialog?.window?.apply { + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView() { + val assetLoader = webViewAssetLoader() + + binding.wvAddress.run { + with(settings) { + javaScriptEnabled = true + allowFileAccess = false + allowContentAccess = false + } + addJavascriptInterface( + JavascriptInterface(this@AddressFinderDialog), + JS_BRIDGE, + ) + webViewClient = AddressWebViewClient(assetLoader) + loadUrl("https://$DOMAIN/$PATH/html/address.html") + } + } + + private fun webViewAssetLoader() = + WebViewAssetLoader.Builder() + .addPathHandler( + "/$PATH/", + WebViewAssetLoader.AssetsPathHandler(requireContext()), + ) + .setDomain(DOMAIN) + .build() + + override fun onClickAddress(address: String) { + setFragmentResult(ADDRESS_KEY, bundleOf(BUNDLE_ADDRESS_KEY to address)) + dismiss() + } + + companion object { + private const val JS_BRIDGE = "address_finder" + private const val DOMAIN = "address.finder.net" + private const val PATH = "assets" + + const val ADDRESS_KEY = "address_key" + const val BUNDLE_ADDRESS_KEY = "address" + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/AddressWebViewClient.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/AddressWebViewClient.kt new file mode 100644 index 000000000..edc95df88 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/AddressWebViewClient.kt @@ -0,0 +1,17 @@ +package com.zzang.chongdae.presentation.view.address + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewClientCompat + +class AddressWebViewClient(private val assetLoader: WebViewAssetLoader) : + WebViewClientCompat() { + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest?, + ): WebResourceResponse? { + return assetLoader.shouldInterceptRequest(request!!.url) + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/JavascriptInterface.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/JavascriptInterface.kt new file mode 100644 index 000000000..d760f7401 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/JavascriptInterface.kt @@ -0,0 +1,12 @@ +package com.zzang.chongdae.presentation.view.address + +import android.os.Looper + +class JavascriptInterface(private val onAddressClickListener: OnAddressClickListener) { + @android.webkit.JavascriptInterface + fun result(address: String) { + android.os.Handler(Looper.getMainLooper()).post { + onAddressClickListener.onClickAddress(address) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/OnAddressClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/OnAddressClickListener.kt new file mode 100644 index 000000000..ecf863c85 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/address/OnAddressClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.address + +interface OnAddressClickListener { + fun onClickAddress(address: String) +} 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 new file mode 100644 index 000000000..b804f5a6c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsFragment.kt @@ -0,0 +1,94 @@ +package com.zzang.chongdae.presentation.view.comment + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +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.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 + +class CommentRoomsFragment : Fragment(), OnCommentRoomClickListener { + private var _binding: FragmentCommentRoomsBinding? = null + private val binding get() = _binding!! + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + private val commentRoomsAdapter: CommentRoomsAdapter by lazy { + CommentRoomsAdapter(this) + } + + private val viewModel by viewModels { + CommentRoomsViewModel.getFactory( + authRepository = (requireActivity().application as ChongdaeApp).authRepository, + commentRoomsRepository = (requireActivity().application as ChongdaeApp).commentRoomsRepository, + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + linkAdapter() + updateCommentRooms() + + return binding.root + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _binding = FragmentCommentRoomsBinding.inflate(inflater, container, false) + binding.fragmentCommentRooms = this + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + } + + private fun linkAdapter() { + binding.rvCommentRoom.adapter = commentRoomsAdapter + viewModel.commentRooms.observe(viewLifecycleOwner) { + commentRoomsAdapter.submitList(it) + } + commentRoomsAdapter.submitList(viewModel.commentRooms.value) + } + + private fun updateCommentRooms() { + viewModel.updateCommentRooms() + } + + override fun onResume() { + super.onResume() + updateCommentRooms() + firebaseAnalyticsManager.logScreenView( + screenName = "CommentRoomsFragment", + screenClass = this::class.java.simpleName, + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onClick( + id: Long, + title: String, + ) { + CommentDetailActivity.startActivity(activity as Context, id) + } +} 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 new file mode 100644 index 000000000..ebaf03186 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/CommentRoomsViewModel.kt @@ -0,0 +1,59 @@ +package com.zzang.chongdae.presentation.view.comment + +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.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 kotlinx.coroutines.launch + +class CommentRoomsViewModel( + private val authRepository: AuthRepository, + private val commentRoomsRepository: CommentRoomsRepository, +) : ViewModel() { + private val _commentRooms: MutableLiveData> = MutableLiveData() + val commentRooms: LiveData> get() = _commentRooms + + 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() + } + else -> {} + } + } + 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/comment/adapter/CommentRoomViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/CommentRoomViewHolder.kt new file mode 100644 index 000000000..7ab98db36 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/CommentRoomViewHolder.kt @@ -0,0 +1,35 @@ +package com.zzang.chongdae.presentation.view.comment.adapter + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemCommentRoomParticipantBinding +import com.zzang.chongdae.databinding.ItemCommentRoomProposerBinding +import com.zzang.chongdae.domain.model.CommentRoom + +sealed class CommentRoomViewHolder( + view: View, +) : RecyclerView.ViewHolder(view) { + class Proposer( + private val binding: ItemCommentRoomProposerBinding, + ) : CommentRoomViewHolder(binding.root) { + fun bind( + commentRoom: CommentRoom, + onClickListener: OnCommentRoomClickListener, + ) { + binding.commentRoom = commentRoom + binding.onCommentRoomClickListener = onClickListener + } + } + + class NotProposer( + private val binding: ItemCommentRoomParticipantBinding, + ) : CommentRoomViewHolder(binding.root) { + fun bind( + commentRoom: CommentRoom, + onClickListener: OnCommentRoomClickListener, + ) { + binding.commentRoom = commentRoom + binding.onCommentRoomClickListener = onClickListener + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/CommentRoomsAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/CommentRoomsAdapter.kt new file mode 100644 index 000000000..d71d97380 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/CommentRoomsAdapter.kt @@ -0,0 +1,89 @@ +package com.zzang.chongdae.presentation.view.comment.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.zzang.chongdae.databinding.ItemCommentRoomParticipantBinding +import com.zzang.chongdae.databinding.ItemCommentRoomProposerBinding +import com.zzang.chongdae.domain.model.CommentRoom +import com.zzang.chongdae.domain.model.CommentRoomType + +class CommentRoomsAdapter( + private val onClickListener: OnCommentRoomClickListener, +) : ListAdapter(productComparator) { + override fun getItemViewType(position: Int): Int { + return if (currentList[position].isProposer == true) { + CommentRoomType.PROPOSER.separator + } else { + CommentRoomType.NOT_PROPOSER.separator + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): CommentRoomViewHolder { + when (viewType) { + CommentRoomType.PROPOSER.separator -> { + val binding = + ItemCommentRoomProposerBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return CommentRoomViewHolder.Proposer(binding) + } + + CommentRoomType.NOT_PROPOSER.separator -> { + val binding = + ItemCommentRoomParticipantBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + return CommentRoomViewHolder.NotProposer(binding) + } + + else -> error("Invalid view type") + } + } + + override fun onBindViewHolder( + holder: CommentRoomViewHolder, + position: Int, + ) { + when (holder) { + is CommentRoomViewHolder.Proposer -> + holder.bind( + currentList[position], + onClickListener, + ) + + is CommentRoomViewHolder.NotProposer -> + holder.bind( + currentList[position], + onClickListener, + ) + } + } + + companion object { + private val productComparator = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CommentRoom, + newItem: CommentRoom, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: CommentRoom, + newItem: CommentRoom, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/OnCommentRoomClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/OnCommentRoomClickListener.kt new file mode 100644 index 000000000..e019d06af --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/comment/adapter/OnCommentRoomClickListener.kt @@ -0,0 +1,8 @@ +package com.zzang.chongdae.presentation.view.comment.adapter + +interface OnCommentRoomClickListener { + fun onClick( + id: Long, + title: String, + ) +} 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 new file mode 100644 index 000000000..33d45c565 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailActivity.kt @@ -0,0 +1,235 @@ +package com.zzang.chongdae.presentation.view.commentdetail + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.MotionEvent +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GravityCompat +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.databinding.ActivityCommentDetailBinding +import com.zzang.chongdae.databinding.DialogUpdateStatusBinding +import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.view.commentdetail.adapter.comment.CommentAdapter +import com.zzang.chongdae.presentation.view.commentdetail.adapter.participant.ParticipantAdapter + +class CommentDetailActivity : AppCompatActivity(), OnUpdateStatusClickListener { + private var _binding: ActivityCommentDetailBinding? = null + private val binding get() = _binding!! + private var toast: Toast? = null + private val commentAdapter: CommentAdapter by lazy { CommentAdapter() } + private val participantAdapter: ParticipantAdapter by lazy { ParticipantAdapter() } + private val dialog: Dialog by lazy { Dialog(this) } + + private val viewModel: CommentDetailViewModel by viewModels { + CommentDetailViewModel.getFactory( + offeringId = offeringId, + authRepository = (application as ChongdaeApp).authRepository, + offeringRepository = (application as ChongdaeApp).offeringRepository, + participantRepository = (application as ChongdaeApp).participantRepository, + commentDetailRepository = (application as ChongdaeApp).commentDetailRepository, + ) + } + + private val offeringId by lazy { + intent.getLongExtra( + EXTRA_OFFERING_ID_KEY, + EXTRA_DEFAULT_VALUE, + ) + } + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(this) + } + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + setupDrawerToggle() + initAdapter() + setUpObserve() + } + + private fun initBinding() { + _binding = DataBindingUtil.setContentView(this, R.layout.activity_comment_detail) + binding.vm = viewModel + binding.lifecycleOwner = this + } + + private fun setupDrawerToggle() { + binding.ivMoreOptions.setOnClickListener { + if (binding.drawerLayout.isDrawerOpen(GravityCompat.END)) { + binding.drawerLayout.closeDrawer(GravityCompat.END) + return@setOnClickListener + } + binding.drawerLayout.openDrawer(GravityCompat.END) + firebaseAnalyticsManager.logSelectContentEvent( + id = "more_comment_detail_options_event", + name = "more_comment_detail_options_event", + contentType = "button", + ) + } + } + + private fun initAdapter() { + binding.rvComments.apply { + adapter = commentAdapter + layoutManager = + LinearLayoutManager(this@CommentDetailActivity).apply { + stackFromEnd = true + } + } + binding.rvOfferingMembers.adapter = participantAdapter + } + + private fun setUpObserve() { + observeComments() + observeParticipants() + observeUpdateOfferingEvent() + observeReportEvent() + observeExitOfferingEvent() + observeBackEvent() + observeErrorEvent() + } + + private fun observeComments() { + viewModel.comments.observe(this) { comments -> + commentAdapter.submitList(comments) { + binding.rvComments.doOnPreDraw { + binding.rvComments.scrollToPosition(comments.size - 1) + } + } + } + } + + private fun observeParticipants() { + viewModel.participants.observe(this) { participants -> + participants?.let { + participantAdapter.submitList(it.participants) + } + } + } + + private fun observeReportEvent() { + viewModel.reportEvent.observe(this) { reportUrlId -> + openUrlInBrowser(getString(reportUrlId)) + } + } + + private fun openUrlInBrowser(url: String) { + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + } + startActivity(intent) + } + + private fun observeUpdateOfferingEvent() { + viewModel.showStatusDialogEvent.observe(this) { + showUpdateStatusDialog() + } + } + + private fun observeExitOfferingEvent() { + viewModel.onExitOfferingEvent.observe(this) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "exit_offering_event", + name = "exit_offering_event", + contentType = "button", + ) + finish() + } + } + + private fun observeBackEvent() { + viewModel.onBackPressedEvent.observe(this) { + finish() + } + } + + private fun observeErrorEvent() { + viewModel.errorEvent.observe(this) { + toast?.cancel() + toast = + Toast.makeText( + this, + it, + Toast.LENGTH_SHORT, + ) + toast?.show() + } + } + + private fun showUpdateStatusDialog() { + val dialogBinding = + DataBindingUtil.inflate( + layoutInflater, + R.layout.dialog_update_status, + null, + false, + ) + + dialogBinding.vm = viewModel + dialogBinding.listener = this + + dialog.setContentView(dialogBinding.root) + dialog.show() + } + + override fun onSubmitClick() { + viewModel.updateOfferingStatus() + firebaseAnalyticsManager.logSelectContentEvent( + id = "update_offering_status_event", + name = "update_offering_status_event", + contentType = "button", + ) + dialog.dismiss() + } + + override fun onCancelClick() { + firebaseAnalyticsManager.logSelectContentEvent( + id = "cancel_update_offering_status_event", + name = "cancel_update_offering_status_event", + contentType = "button", + ) + dialog.dismiss() + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).apply { + this.hideSoftInputFromWindow(currentFocus?.windowToken, 0) + } + return super.dispatchTouchEvent(motionEvent) + } + + companion object { + private const val EXTRA_DEFAULT_VALUE = 1L + private const val EXTRA_OFFERING_ID_KEY = "offering_id_key" + + fun startActivity( + context: Context, + offeringId: Long, + ) = Intent(context, CommentDetailActivity::class.java).run { + putExtra(EXTRA_OFFERING_ID_KEY, offeringId) + context.startActivity(this) + } + } +} 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 new file mode 100644 index 000000000..7902f70be --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/CommentDetailViewModel.kt @@ -0,0 +1,290 @@ +package com.zzang.chongdae.presentation.view.commentdetail + +import androidx.lifecycle.LiveData +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.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 +import com.zzang.chongdae.presentation.view.commentdetail.model.information.CommentOfferingInfoUiModel.Companion.toUiModel +import com.zzang.chongdae.presentation.view.commentdetail.model.meeting.MeetingsUiModel +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 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("") + + private val _comments: MutableLiveData> = MutableLiveData() + val comments: LiveData> get() = _comments + + private val _commentOfferingInfo = MutableLiveData() + val commentOfferingInfo: LiveData get() = _commentOfferingInfo + + private val _meetings = MutableLiveData() + val meetings: LiveData get() = _meetings + + private val _isCollapsibleViewVisible = MutableLiveData(false) + val isCollapsibleViewVisible: LiveData get() = _isCollapsibleViewVisible + + private val _participants = MutableLiveData() + val participants: LiveData get() = _participants + + private val _showStatusDialogEvent = MutableLiveData() + val showStatusDialogEvent: LiveData get() = _showStatusDialogEvent + + private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() + val reportEvent: SingleLiveData get() = _reportEvent + + private val _onExitOfferingEvent = MutableSingleLiveData() + val onExitOfferingEvent: SingleLiveData get() = _onExitOfferingEvent + + private val _onBackPressedEvent = MutableSingleLiveData() + val onBackPressedEvent: SingleLiveData get() = _onBackPressedEvent + + private val _errorEvent = MutableLiveData() + val errorEvent: MutableLiveData get() = _errorEvent + + 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 + } + } + } + } + } + + 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() + } + + 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 -> { + authRepository.saveRefresh() + loadComments() + } + else -> { + pollJob?.cancel() + errorEvent.value = result.error.name + } + } + } + } + } + + 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() + postComment() + } + else -> { + errorEvent.value = result.error.name + } + } + } + } + } + + fun toggleCollapsibleView() { + _isCollapsibleViewVisible.value = _isCollapsibleViewVisible.value?.not() + if (_isCollapsibleViewVisible.value == true) { + loadMeetings() + } + } + + 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 + } + } + } + } + } + + 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() + } + else -> { + errorEvent.value = result.error.name + } + } + } + } + } + + fun onClickReport() { + _reportEvent.setValue(R.string.report_url) + } + + fun exitOffering() { + viewModelScope.launch { + when (val result = participantRepository.deleteParticipations(offeringId)) { + is Result.Success -> { + _onExitOfferingEvent.setValue(Unit) + pollJob?.cancel() + } + 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() + } + + private fun stopPolling() { + pollJob?.cancel() + } + + 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 + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt new file mode 100644 index 000000000..ccc427296 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/OnUpdateStatusClickListener.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.presentation.view.commentdetail + +interface OnUpdateStatusClickListener { + fun onSubmitClick() + + fun onCancelClick() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt new file mode 100644 index 000000000..d07124302 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/CommentAdapter.kt @@ -0,0 +1,66 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemMyCommentBinding +import com.zzang.chongdae.databinding.ItemOtherCommentBinding +import com.zzang.chongdae.domain.model.Comment + +class CommentAdapter : ListAdapter(DIFF_CALLBACK) { + override fun getItemViewType(position: Int): Int { + return if (getItem(position).isMine) VIEW_TYPE_MY_COMMENT else VIEW_TYPE_OTHER_COMMENT + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_MY_COMMENT -> { + val binding = ItemMyCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + MyCommentViewHolder(binding) + } + VIEW_TYPE_OTHER_COMMENT -> { + val binding = ItemOtherCommentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + OtherCommentViewHolder(binding) + } + else -> throw IllegalArgumentException("CommentAdapter viewType error") + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + val comment = getItem(position) + when (holder.itemViewType) { + VIEW_TYPE_MY_COMMENT -> (holder as MyCommentViewHolder).bind(comment) + VIEW_TYPE_OTHER_COMMENT -> (holder as OtherCommentViewHolder).bind(comment) + } + } + + companion object { + private const val VIEW_TYPE_MY_COMMENT = 1 + private const val VIEW_TYPE_OTHER_COMMENT = 2 + + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Comment, + newItem: Comment, + ): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame( + oldItem: Comment, + newItem: Comment, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt new file mode 100644 index 000000000..e6b48a8e6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/MyCommentViewHolder.kt @@ -0,0 +1,13 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment + +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemMyCommentBinding +import com.zzang.chongdae.domain.model.Comment + +class MyCommentViewHolder( + private val binding: ItemMyCommentBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(comment: Comment) { + binding.comment = comment + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt new file mode 100644 index 000000000..f14fd231d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/comment/OtherCommentViewHolder.kt @@ -0,0 +1,13 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.comment + +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemOtherCommentBinding +import com.zzang.chongdae.domain.model.Comment + +class OtherCommentViewHolder( + private val binding: ItemOtherCommentBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(comment: Comment) { + binding.comment = comment + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantAdapter.kt new file mode 100644 index 000000000..014d485e9 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantAdapter.kt @@ -0,0 +1,46 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.participant + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.zzang.chongdae.databinding.ItemOfferingMemberBinding +import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ParticipantUiModel + +class ParticipantAdapter : ListAdapter(DIFF_CALLBACK) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ParticipantViewHolder { + val binding = + ItemOfferingMemberBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ParticipantViewHolder(binding) + } + + override fun onBindViewHolder( + holder: ParticipantViewHolder, + position: Int, + ) { + val participant = getItem(position) + holder.bind(participant) + } + + companion object { + private val DIFF_CALLBACK = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ParticipantUiModel, + newItem: ParticipantUiModel, + ): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame( + oldItem: ParticipantUiModel, + newItem: ParticipantUiModel, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantViewHolder.kt new file mode 100644 index 000000000..48413cf37 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/adapter/participant/ParticipantViewHolder.kt @@ -0,0 +1,13 @@ +package com.zzang.chongdae.presentation.view.commentdetail.adapter.participant + +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemOfferingMemberBinding +import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ParticipantUiModel + +class ParticipantViewHolder( + private val binding: ItemOfferingMemberBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(participant: ParticipantUiModel) { + binding.participant = participant + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/information/CommentOfferingInfoUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/information/CommentOfferingInfoUiModel.kt new file mode 100644 index 000000000..479c5c60c --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/information/CommentOfferingInfoUiModel.kt @@ -0,0 +1,25 @@ +package com.zzang.chongdae.presentation.view.commentdetail.model.information + +import com.zzang.chongdae.domain.model.CommentOfferingInfo + +class CommentOfferingInfoUiModel( + val status: String, + val imageUrl: String, + val buttonText: String, + val message: String, + val title: String, + val isProposer: Boolean, +) { + companion object { + fun CommentOfferingInfo.toUiModel(): CommentOfferingInfoUiModel { + return CommentOfferingInfoUiModel( + status = status, + imageUrl = imageUrl, + buttonText = buttonText, + message = message, + title = title, + isProposer = isProposer, + ) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/meeting/MeetingsUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/meeting/MeetingsUiModel.kt new file mode 100644 index 000000000..bb7da23ad --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/meeting/MeetingsUiModel.kt @@ -0,0 +1,20 @@ +package com.zzang.chongdae.presentation.view.commentdetail.model.meeting + +import com.zzang.chongdae.domain.model.Meetings +import java.time.LocalDateTime + +class MeetingsUiModel( + val meetingDate: LocalDateTime, + val meetingAddress: String, + val meetingAddressDetail: String?, +) { + companion object { + fun Meetings.toUiModel(): MeetingsUiModel { + return MeetingsUiModel( + meetingDate = meetingDate, + meetingAddress = meetingAddress, + meetingAddressDetail = meetingAddressDetail, + ) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantUiModel.kt new file mode 100644 index 000000000..5939b32e6 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantUiModel.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.presentation.view.commentdetail.model.participants + +import com.zzang.chongdae.domain.model.participant.Participant + +data class ParticipantUiModel( + val nickname: String, +) { + companion object { + fun Participant.toUiModel(): ParticipantUiModel { + return ParticipantUiModel( + nickname = nickname, + ) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantsUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantsUiModel.kt new file mode 100644 index 000000000..9a06a6b7d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ParticipantsUiModel.kt @@ -0,0 +1,25 @@ +package com.zzang.chongdae.presentation.view.commentdetail.model.participants + +import com.zzang.chongdae.domain.model.participant.Participants +import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ParticipantUiModel.Companion.toUiModel +import com.zzang.chongdae.presentation.view.commentdetail.model.participants.ProposerUiModel.Companion.toUiModel + +data class ParticipantsUiModel( + val proposer: ProposerUiModel, + val participants: List, + val currentCount: Int, + val totalCount: Int, + val price: Int, +) { + companion object { + fun Participants.toUiModel(): ParticipantsUiModel { + return ParticipantsUiModel( + proposer = proposer.toUiModel(), + participants = participants.map { it.toUiModel() }, + currentCount = participantCount.currentCount, + totalCount = participantCount.totalCount, + price = price, + ) + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ProposerUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ProposerUiModel.kt new file mode 100644 index 000000000..e15a84150 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/commentdetail/model/participants/ProposerUiModel.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.presentation.view.commentdetail.model.participants + +import com.zzang.chongdae.domain.model.participant.Proposer + +data class ProposerUiModel( + val nickname: String, +) { + companion object { + fun Proposer.toUiModel(): ProposerUiModel { + return ProposerUiModel( + nickname = nickname, + ) + } + } +} 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 new file mode 100644 index 000000000..81fcd2dd5 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/HomeFragment.kt @@ -0,0 +1,253 @@ +package com.zzang.chongdae.presentation.view.home + +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.CheckBox +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.paging.LoadState +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.databinding.FragmentHomeBinding +import com.zzang.chongdae.domain.model.FilterName +import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +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 kotlinx.coroutines.launch + +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 firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + initAdapter() + initSearchListener() + setUpOfferingsObserve() + navigateToOfferingWriteFragment() + initFragmentResultListener() + setOnCheckboxListener() + } + + private fun setOnCheckboxListener() { + binding.cbJoinable.setOnClickListener { + handleCheckBoxSelection(FilterName.JOINABLE, (it as CheckBox).isChecked) + } + + binding.cbImminent.setOnClickListener { + handleCheckBoxSelection(FilterName.IMMINENT, (it as CheckBox).isChecked) + } + + binding.cbHighDiscount.setOnClickListener { + handleCheckBoxSelection(FilterName.HIGH_DISCOUNT, (it as CheckBox).isChecked) + } + + viewModel.selectedFilter.observe(viewLifecycleOwner) { selectedFilter -> + updateCheckBoxStates(selectedFilter) + } + + viewModel.error.observe(viewLifecycleOwner) { errMsgId -> + showToast(errMsgId) + } + } + + private fun handleCheckBoxSelection( + filterName: FilterName, + isChecked: Boolean, + ) { + viewModel.onClickFilter(filterName, isChecked) + } + + private fun updateCheckBoxStates(selectedFilterName: String?) { + binding.cbJoinable.isChecked = selectedFilterName == FilterName.JOINABLE.name + binding.cbImminent.isChecked = selectedFilterName == FilterName.IMMINENT.name + binding.cbHighDiscount.isChecked = selectedFilterName == FilterName.HIGH_DISCOUNT.name + } + + private fun initFragmentResultListener() { + setFragmentResultListener(OfferingDetailFragment.OFFERING_DETAIL_BUNDLE_KEY) { _, bundle -> + viewModel.fetchUpdatedOffering(bundle.getLong(OfferingDetailFragment.UPDATED_OFFERING_ID_KEY)) + } + + setFragmentResultListener(OfferingWriteOptionalFragment.OFFERING_WRITE_BUNDLE_KEY) { _, bundle -> + viewModel.refreshOfferingsByOfferingWriteEvent( + bundle.getBoolean( + OfferingWriteOptionalFragment.NEW_OFFERING_EVENT_KEY, + ), + ) + } + } + + private fun initSearchListener() { + binding.etSearch.setOnEditorActionListener { _, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE || event?.keyCode == KeyEvent.KEYCODE_ENTER) { + viewModel.onClickSearchButton() + true + } else { + false + } + } + } + + override fun onResume() { + super.onResume() + (activity as MainActivity).showBottomNavigation() + firebaseAnalyticsManager.logScreenView( + screenName = "HomeFragment", + screenClass = this::class.java.simpleName, + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _binding = FragmentHomeBinding.inflate(inflater, container, false) + binding.lifecycleOwner = viewLifecycleOwner + binding.vm = viewModel + } + + private fun initAdapter() { + offeringAdapter = OfferingAdapter(this) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + offeringAdapter.loadStateFlow.collect { loadState -> + binding.pbLoading.isVisible = loadState.refresh is LoadState.Loading + } + } + } + binding.rvOfferings.adapter = offeringAdapter + binding.rvOfferings.addItemDecoration( + DividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL, + ), + ) + } + + private fun setUpOfferingsObserve() { + viewModel.offeringsRefreshEvent.observe(viewLifecycleOwner) { + offeringAdapter.submitData(viewLifecycleOwner.lifecycle, PagingData.empty()) + } + + viewModel.offerings.observe(viewLifecycleOwner) { + offeringAdapter.submitData(viewLifecycleOwner.lifecycle, it) + } + + viewModel.searchEvent.observe(viewLifecycleOwner) { + offeringAdapter.submitData(viewLifecycleOwner.lifecycle, PagingData.empty()) + offeringAdapter.setSearchKeyword(it) + } + + viewModel.filterOfferingsEvent.observe(viewLifecycleOwner) { + offeringAdapter.submitData(viewLifecycleOwner.lifecycle, PagingData.empty()) + firebaseAnalyticsManager.logSelectContentEvent( + id = "filter_offerings_event", + name = "filter_offerings_event", + contentType = "checkbox", + ) + } + + viewModel.updatedOffering.observe(viewLifecycleOwner) { + offeringAdapter.addUpdatedItem(it.toList()) + } + viewModel.updatedOffering.getValue()?.toList()?.let { offeringAdapter.addUpdatedItem(it) } + + viewModel.refreshTokenExpiredEvent.observe(viewLifecycleOwner) { + LoginActivity.startActivity(requireContext()) + } + } + + override fun onClick(offeringId: Long) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "Offering_Item_ID: $offeringId", + name = "read_offering_detail_event", + contentType = "item", + ) + + findNavController().navigate( + R.id.action_home_fragment_to_offering_detail_fragment, + Bundle().apply { + putLong(OFFERING_ID, offeringId) + }, + ) + } + + private fun navigateToOfferingWriteFragment() { + binding.fabCreateOffering.setOnClickListener { + findNavController().navigate(R.id.action_home_fragment_to_offering_write_fragment) + } + } + + private fun showToast( + @StringRes messageId: Int, + ) { + toast?.cancel() + toast = + Toast.makeText( + requireActivity(), + getString(messageId), + Toast.LENGTH_SHORT, + ) + toast?.show() + } + + companion object { + const val OFFERING_ID = "offering_id" + } +} 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 new file mode 100644 index 000000000..c593cd553 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OfferingViewModel.kt @@ -0,0 +1,242 @@ +package com.zzang.chongdae.presentation.view.home + +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.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 kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +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 highDiscountFilter: LiveData = + _filters.map { + it.first { it.name == FilterName.HIGH_DISCOUNT } + } + + private val _selectedFilter: MutableLiveData = MutableLiveData() + val selectedFilter: LiveData get() = _selectedFilter + + private val _searchEvent: MutableSingleLiveData = MutableSingleLiveData(null) + val searchEvent: SingleLiveData get() = _searchEvent + + private val _filterOfferingsEvent: MutableSingleLiveData = MutableSingleLiveData() + val filterOfferingsEvent: SingleLiveData get() = _filterOfferingsEvent + + private val _updatedOffering: MutableSingleLiveData> = + MutableSingleLiveData(mutableListOf()) + val updatedOffering: SingleLiveData> get() = _updatedOffering + + private val _offeringsRefreshEvent: MutableSingleLiveData = MutableSingleLiveData() + val offeringsRefreshEvent: SingleLiveData get() = _offeringsRefreshEvent + + private val _error: MutableSingleLiveData = MutableSingleLiveData() + val error: SingleLiveData get() = _error + + private val _refreshTokenExpiredEvent: MutableSingleLiveData = MutableSingleLiveData() + val refreshTokenExpiredEvent: SingleLiveData get() = _refreshTokenExpiredEvent + + 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!!, + ), + ) + } + 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) + } + + 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 + } + } + } + } + + override fun onClickFilter( + filterName: FilterName, + isChecked: Boolean, + ) { + if (isChecked) { + _selectedFilter.value = filterName.toString() + } else { + _selectedFilter.value = null + } + + _filterOfferingsEvent.setValue(Unit) + fetchOfferings() + } + + override fun onClickSearchButton() { + _searchEvent.setValue(search.value) + 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) + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.home_updated_offering_error_mesasge) + } + + else -> { + Log.e("error", "fetchUpdatedOffering Error: ${result.error.name}") + } + } + } + + is Result.Success -> { + val updatedOfferings = _updatedOffering.getValue() ?: mutableListOf() + updatedOfferings.add(result.data) + _updatedOffering.setValue(updatedOfferings) + } + } + } + } + + fun refreshOfferingsByOfferingWriteEvent(isSuccess: Boolean) { + if (isSuccess) { + search.value = null + _selectedFilter.value = null + _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 + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnFilterClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnFilterClickListener.kt new file mode 100644 index 000000000..72a807ac9 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnFilterClickListener.kt @@ -0,0 +1,10 @@ +package com.zzang.chongdae.presentation.view.home + +import com.zzang.chongdae.domain.model.FilterName + +interface OnFilterClickListener { + fun onClickFilter( + filterName: FilterName, + isChecked: Boolean, + ) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnOfferingClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnOfferingClickListener.kt new file mode 100644 index 000000000..423003870 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnOfferingClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.home + +interface OnOfferingClickListener { + fun onClick(offeringId: Long) +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnSearchClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnSearchClickListener.kt new file mode 100644 index 000000000..2eaa1e185 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/OnSearchClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.home + +interface OnSearchClickListener { + fun onClickSearchButton() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingAdapter.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingAdapter.kt new file mode 100644 index 000000000..bea0296f4 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingAdapter.kt @@ -0,0 +1,74 @@ +package com.zzang.chongdae.presentation.view.home.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import com.zzang.chongdae.databinding.ItemOfferingBinding +import com.zzang.chongdae.domain.model.Offering +import com.zzang.chongdae.presentation.view.home.OnOfferingClickListener + +class OfferingAdapter( + private val onOfferingClickListener: OnOfferingClickListener, +) : PagingDataAdapter(productComparator) { + private var searchKeyword: String? = null + private var updatedOfferings: List = emptyList() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): OfferingViewHolder { + val binding = + ItemOfferingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return OfferingViewHolder(binding) + } + + override fun onBindViewHolder( + holder: OfferingViewHolder, + position: Int, + ) { + getItem(position)?.let { offering -> + val updatedOffering = updatedOfferings.firstOrNull { offering.id == it.id } + if (updatedOffering != null) { + holder.bind(updatedOffering, onOfferingClickListener, searchKeyword) + return + } + holder.bind(offering, onOfferingClickListener, searchKeyword) + } + } + + fun setSearchKeyword(keyword: String?) { + searchKeyword = keyword + } + + fun addUpdatedItem(updatedOfferings: List) { + this.updatedOfferings = updatedOfferings + updatedOfferings.forEach { offering -> + val position = findPositionByOfferingID(offering) + if (position != -1) { + notifyItemChanged(position) + } + } + } + + private fun findPositionByOfferingID(offering: Offering) = snapshot().items.indexOfFirst { it.id == offering.id } + + companion object { + private val productComparator = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Offering, + newItem: Offering, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: Offering, + newItem: Offering, + ): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingViewHolder.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingViewHolder.kt new file mode 100644 index 000000000..529a159c4 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/home/adapter/OfferingViewHolder.kt @@ -0,0 +1,28 @@ +package com.zzang.chongdae.presentation.view.home.adapter + +import android.graphics.Paint +import androidx.recyclerview.widget.RecyclerView +import com.zzang.chongdae.databinding.ItemOfferingBinding +import com.zzang.chongdae.domain.model.Offering +import com.zzang.chongdae.presentation.view.home.OnOfferingClickListener + +class OfferingViewHolder( + private val binding: ItemOfferingBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind( + offering: Offering, + onOfferingClickListener: OnOfferingClickListener, + searchKeyword: String?, + ) { + binding.offering = offering + binding.onOfferingClickListener = onOfferingClickListener + binding.searchKeyword = searchKeyword + setCancellationLineToOriginPrice() + } + + private fun setCancellationLineToOriginPrice() { + binding.tvOriginPrice.apply { + this.paintFlags = this.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } + } +} 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 new file mode 100644 index 000000000..450fff06e --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginActivity.kt @@ -0,0 +1,140 @@ +package com.zzang.chongdae.presentation.view.login + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import com.google.firebase.analytics.FirebaseAnalytics +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.databinding.ActivityLoginBinding +import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.view.MainActivity + +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 firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(this) + } + + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + val callback: (OAuthToken?, Throwable?) -> Unit = { token, error -> + if (error != null) { + Log.e("error", "카카오계정으로 로그인 실패", error) + } else if (token != null) { + loadUserInformation(token.accessToken) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initBinding() + setupObserve() + } + + private fun setupObserve() { + observeAlreadyLoggedInEvent() + observeLoginSuccessEvent() + } + + private fun observeAlreadyLoggedInEvent() { + viewModel.alreadyLoggedInEvent.observe(this) { + navigateToNextActivity() + } + } + + private fun observeLoginSuccessEvent() { + viewModel.loginSuccessEvent.observe(this) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "login_event", + name = "login_event", + contentType = "button", + ) + navigateToNextActivity() + } + } + + private fun initBinding() { + _binding = ActivityLoginBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.vm = viewModel + binding.onAuthClickListener = this + binding.lifecycleOwner = this + } + + override fun onLoginButtonClick() { + loginWithKakao() + } + + private fun loginWithKakao() { + if (UserApiClient.instance.isKakaoTalkLoginAvailable(this)) { + loginWithKakaoTalk() + } else { + UserApiClient.instance.loginWithKakaoAccount(this, callback = callback) + } + } + + private fun loginWithKakaoTalk() { + UserApiClient.instance.loginWithKakaoTalk(this) { token, error -> + if (error != null) { + Log.e("error", "카카오톡으로 로그인 실패", error) + loginWithKakaoAcountIfKakaoTalkLoginFailed(error) + } else if (token != null) { + loadUserInformation(token.accessToken) + } + } + } + + private fun loginWithKakaoAcountIfKakaoTalkLoginFailed(error: Throwable?) { + if (isKakaoTalkLoginCanceled(error)) { + return + } + UserApiClient.instance.loginWithKakaoAccount(this, callback = callback) + } + + private fun isKakaoTalkLoginCanceled(error: Throwable?): Boolean { + return error is ClientError && error.reason == ClientErrorCause.Cancelled + } + + private fun loadUserInformation(accessToken: String) { + UserApiClient.instance.me { user, error -> + if (error != null) { + Log.d("error", "사용자 정보 요청 실패 $error") + } else if (user != null) { + viewModel.postLogin(accessToken) + } + } + } + + private fun navigateToNextActivity() { + MainActivity.startActivity(this) + finish() + } + + companion object { + fun startActivity(context: Context) = + Intent(context, LoginActivity::class.java).run { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.startActivity(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 new file mode 100644 index 000000000..c3726d2c3 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/LoginViewModel.kt @@ -0,0 +1,68 @@ +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.presentation.util.MutableSingleLiveData +import com.zzang.chongdae.presentation.util.SingleLiveData +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) + } + } + } + + 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}") + } + } + } + } + + 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 + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/OnAuthClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/OnAuthClickListener.kt new file mode 100644 index 000000000..9e0da95eb --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/login/OnAuthClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.login + +interface OnAuthClickListener { + fun onLoginButtonClick() +} 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 new file mode 100644 index 000000000..c00226a20 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageFragment.kt @@ -0,0 +1,87 @@ +package com.zzang.chongdae.presentation.view.mypage + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +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.databinding.FragmentMyPageBinding +import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.view.login.LoginActivity + +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 val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + return binding.root + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _binding = FragmentMyPageBinding.inflate(inflater, container, false) + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + setUpObserve() + } + + private fun setUpObserve() { + viewModel.openUrlInBrowserEvent.observe(viewLifecycleOwner) { + openUrlInBrowser(it) + } + viewModel.logoutEvent.observe(viewLifecycleOwner) { + clearDataAndLogout() + } + } + + private fun clearDataAndLogout() { + LoginActivity.startActivity(requireContext()) + } + + private fun openUrlInBrowser(url: String) { + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + } + startActivity(intent) + } + + override fun onResume() { + super.onResume() + firebaseAnalyticsManager.logScreenView( + screenName = "MyPageFragment", + screenClass = this::class.java.simpleName, + ) + } +} 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 new file mode 100644 index 000000000..8a996a133 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/mypage/MyPageViewModel.kt @@ -0,0 +1,60 @@ +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.presentation.util.MutableSingleLiveData +import com.zzang.chongdae.presentation.util.SingleLiveData +import kotlinx.coroutines.launch + +class MyPageViewModel(private val userPreferencesDataStore: UserPreferencesDataStore) : ViewModel() { + val nickName: LiveData = userPreferencesDataStore.nickNameFlow.asLiveData() + + private val _openUrlInBrowserEvent = MutableSingleLiveData() + val openUrlInBrowserEvent: SingleLiveData get() = _openUrlInBrowserEvent + + 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" + + fun onClickTermsOfUse() { + _openUrlInBrowserEvent.setValue(termsOfUseUrl) + } + + fun onClickPrivacy() { + _openUrlInBrowserEvent.setValue(privacyUrl) + } + + fun onClickLogout() { + viewModelScope.launch { + userPreferencesDataStore.removeAllData() + } + _logoutEvent.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 + } + } + } +} 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 new file mode 100644 index 000000000..1801288be --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailFragment.kt @@ -0,0 +1,136 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +import android.content.Intent +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.annotation.StringRes +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +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.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 + +class OfferingDetailFragment : Fragment() { + 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() + } + private val viewModel: OfferingDetailViewModel by viewModels { + OfferingDetailViewModel.getFactory( + offeringId = offeringId, + offeringDetailRepository = (requireActivity().application as ChongdaeApp).offeringDetailRepository, + authRepository = (requireActivity().applicationContext as ChongdaeApp).authRepository, + ) + } + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + initBinding(inflater, container) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + setUpMoveCommentDetailEventObserve() + (activity as MainActivity).hideBottomNavigation() + + setUpObserve() + } + + private fun setUpObserve() { + viewModel.updatedOfferingId.observe(viewLifecycleOwner) { + setFragmentResult(OFFERING_DETAIL_BUNDLE_KEY, bundleOf(UPDATED_OFFERING_ID_KEY to it)) + } + + viewModel.reportEvent.observe(viewLifecycleOwner) { reportUrlId -> + openUrlInBrowser(getString(reportUrlId)) + } + + viewModel.error.observe(viewLifecycleOwner) { errMsgId -> + showToast(errMsgId) + } + + viewModel.productLinkRedirectEvent.observe(viewLifecycleOwner) { productURL -> + openUrlInBrowser(productURL) + } + } + + private fun openUrlInBrowser(url: String) { + val intent = + Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + } + startActivity(intent) + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + private fun initBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + _binding = FragmentOfferingDetailBinding.inflate(inflater, container, false) + binding.vm = viewModel + binding.lifecycleOwner = this + } + + private fun setUpMoveCommentDetailEventObserve() { + viewModel.commentDetailEvent.observe(this) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "Offering_Item_ID: $offeringId", + name = "participate_offering_event", + contentType = "button", + ) + findNavController().popBackStack() + CommentDetailActivity.startActivity(requireContext(), offeringId) + } + } + + private fun showToast( + @StringRes messageId: Int, + ) { + toast?.cancel() + toast = + Toast.makeText( + requireActivity(), + getString(messageId), + Toast.LENGTH_SHORT, + ) + toast?.show() + } + + companion object { + const val OFFERING_DETAIL_BUNDLE_KEY = "offering_detail_bundle_key" + const val UPDATED_OFFERING_ID_KEY = "updated_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 new file mode 100644 index 000000000..cf65a7a1a --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OfferingDetailViewModel.kt @@ -0,0 +1,166 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +import android.util.Log +import androidx.lifecycle.LiveData +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.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 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 + + private val _currentCount: MutableLiveData = MutableLiveData() + val currentCount: LiveData get() = _currentCount + + private val _offeringCondition: MutableLiveData = MutableLiveData() + val offeringCondition: LiveData get() = _offeringCondition + + private val _isParticipated: MutableLiveData = MutableLiveData(false) + val isParticipated: LiveData get() = _isParticipated + + private val _isParticipationAvailable: MutableLiveData = MutableLiveData(true) + val isParticipationAvailable: LiveData get() = _isParticipationAvailable + + private val _isRepresentative: MutableLiveData = MutableLiveData(true) + val isRepresentative: LiveData get() = _isRepresentative + + private val _commentDetailEvent: MutableSingleLiveData = MutableSingleLiveData() + val commentDetailEvent: SingleLiveData get() = _commentDetailEvent + + private val _updatedOfferingId: MutableLiveData = MutableLiveData() + val updatedOfferingId: LiveData get() = _updatedOfferingId + + private val _reportEvent: MutableSingleLiveData = MutableSingleLiveData() + val reportEvent: SingleLiveData get() = _reportEvent + + private val _productLinkRedirectEvent: MutableSingleLiveData = MutableSingleLiveData() + val productLinkRedirectEvent: SingleLiveData get() = _productLinkRedirectEvent + + private val _error: MutableSingleLiveData = MutableSingleLiveData() + val error: SingleLiveData get() = _error + + init { + loadOffering() + } + + private fun loadOffering() { + viewModelScope.launch { + when (val result = offeringDetailRepository.fetchOfferingDetail(offeringId)) { + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + authRepository.saveRefresh() + loadOffering() + } + + 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 + } + } + } + } + + override fun onClickParticipation() { + viewModelScope.launch { + when (val result = offeringDetailRepository.saveParticipation(offeringId)) { + is Result.Error -> + when (result.error) { + DataError.Network.UNAUTHORIZED -> { + authRepository.saveRefresh() + onClickParticipation() + } + + DataError.Network.BAD_REQUEST -> { + _error.setValue(R.string.offering_detail_participation_error) + } + + 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 + } + } + } + } + + override fun onClickMoveCommentDetail() { + _commentDetailEvent.setValue(offeringDetail.value?.title ?: DEFAULT_TITLE) + } + + override fun onClickReport() { + _reportEvent.setValue(R.string.report_url) + } + + override fun onClickProductRedirectText(productUrl: String) { + _productLinkRedirectEvent.setValue(productUrl) + } + + 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 + } + } + } +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnMoveCommentDetailClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnMoveCommentDetailClickListener.kt new file mode 100644 index 000000000..7b73d5ef0 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnMoveCommentDetailClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +interface OnMoveCommentDetailClickListener { + fun onClickMoveCommentDetail() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingReportClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingReportClickListener.kt new file mode 100644 index 000000000..43ab0736d --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnOfferingReportClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +interface OnOfferingReportClickListener { + fun onClickReport() +} 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 new file mode 100644 index 000000000..9858c47fa --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnParticipationClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +interface OnParticipationClickListener { + fun onClickParticipation() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnProductLinkClickListener.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnProductLinkClickListener.kt new file mode 100644 index 000000000..4adba0f3b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/offeringdetail/OnProductLinkClickListener.kt @@ -0,0 +1,5 @@ +package com.zzang.chongdae.presentation.view.offeringdetail + +interface OnProductLinkClickListener { + fun onClickProductRedirectText(productUrl: String) +} 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 new file mode 100644 index 000000000..cf5f17c68 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteEssentialFragment.kt @@ -0,0 +1,212 @@ +package com.zzang.chongdae.presentation.view.write + +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.ChongdaeApp +import com.zzang.chongdae.R +import com.zzang.chongdae.databinding.DialogDatePickerBinding +import com.zzang.chongdae.databinding.FragmentOfferingWriteEssentialBinding +import com.zzang.chongdae.presentation.util.FirebaseAnalyticsManager +import com.zzang.chongdae.presentation.view.MainActivity +import com.zzang.chongdae.presentation.view.address.AddressFinderDialog +import java.util.Calendar + +class OfferingWriteEssentialFragment : Fragment(), OnOfferingWriteClickListener { + private var _fragmentBinding: FragmentOfferingWriteEssentialBinding? = 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 viewModel: OfferingWriteViewModel by activityViewModels { + OfferingWriteViewModel.getFactory( + offeringRepository = (requireActivity().application as ChongdaeApp).offeringRepository, + authRepository = (requireActivity().application as ChongdaeApp).authRepository, + ) + } + + private val firebaseAnalytics: FirebaseAnalytics by lazy { + FirebaseAnalytics.getInstance(requireContext()) + } + + private val firebaseAnalyticsManager: FirebaseAnalyticsManager by lazy { + FirebaseAnalyticsManager(firebaseAnalytics) + } + + 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.writeUIState.observe(viewLifecycleOwner) { state -> + when (state) { + is WriteUIState.Error -> { + showToast(state.message) + } + is WriteUIState.Empty -> { + showToast(state.message) + } + is WriteUIState.InvalidInput -> { + showToast(state.message) + } + else -> {} + } + } + } + + private fun searchPlace() { + fragmentBinding.tvPlaceValue.setOnClickListener { + 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.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 = FragmentOfferingWriteEssentialBinding.inflate(inflater, container, false) + fragmentBinding.vm = viewModel + fragmentBinding.lifecycleOwner = viewLifecycleOwner + + _dateTimePickerBinding = DialogDatePickerBinding.inflate(inflater, container, false) + dateTimePickerBinding.vm = viewModel + 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_write_fragment_essential_to_offering_write_fragment_optional) + } + } + + 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/write/OfferingWriteOptionalFragment.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt new file mode 100644 index 000000000..a9b3a67dc --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteOptionalFragment.kt @@ -0,0 +1,198 @@ +package com.zzang.chongdae.presentation.view.write + +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.ChongdaeApp +import com.zzang.chongdae.R +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 + +class OfferingWriteOptionalFragment : Fragment() { + private var _fragmentBinding: FragmentOfferingWriteOptionalBinding? = null + private val fragmentBinding get() = _fragmentBinding!! + + private var toast: Toast? = null + + 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 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 = FragmentOfferingWriteOptionalBinding.inflate(inflater, container, false) + fragmentBinding.vm = viewModel + fragmentBinding.lifecycleOwner = viewLifecycleOwner + } + + private fun observeSubmitOfferingEvent() { + viewModel.submitOfferingEvent.observe(viewLifecycleOwner) { + firebaseAnalyticsManager.logSelectContentEvent( + id = "submit_offering_event", + name = "submit_offering_event", + contentType = "button", + ) + showToast(R.string.write_success_writing) + findNavController().popBackStack(R.id.offering_write_fragment_essential, true) + viewModel.initOfferingWriteInputs() + + 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.writeUIState.observe(viewLifecycleOwner) { state -> + when (state) { + is WriteUIState.Error -> { + showToast(state.message) + } + + is WriteUIState.Empty -> { + showToast(state.message) + } + + is WriteUIState.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/write/OfferingWriteUiModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteUiModel.kt new file mode 100644 index 000000000..808096314 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteUiModel.kt @@ -0,0 +1,15 @@ +package com.zzang.chongdae.presentation.view.write + +data class OfferingWriteUiModel( + 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/presentation/view/write/OfferingWriteViewModel.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt new file mode 100644 index 000000000..304f2f8d9 --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OfferingWriteViewModel.kt @@ -0,0 +1,406 @@ +package com.zzang.chongdae.presentation.view.write + +import android.util.Log +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.domain.model.Count +import com.zzang.chongdae.domain.model.DiscountPrice +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 kotlinx.coroutines.launch +import okhttp3.MultipartBody +import java.text.SimpleDateFormat +import java.util.Locale + +class OfferingWriteViewModel( + private val offeringRepository: OfferingRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + 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 _submitOfferingEvent: MutableSingleLiveData = MutableSingleLiveData() + val submitOfferingEvent: SingleLiveData get() = _submitOfferingEvent + + private val _imageUploadEvent = MutableLiveData() + val imageUploadEvent: LiveData get() = _imageUploadEvent + + private val _writeUIState = MutableLiveData(WriteUIState.Initial) + val writeUIState: LiveData get() = _writeUIState + + 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() } + } + + _splitPrice.apply { + addSource(totalCount) { safeUpdateSplitPrice() } + addSource(totalPrice) { safeUpdateSplitPrice() } + } + + _discountRate.apply { + addSource(_splitPrice) { safeUpdateDiscountRate() } + addSource(originPrice) { safeUpdateDiscountRate() } + } + + _extractButtonEnabled.apply { + addSource(productUrl) { value = !productUrl.value.isNullOrBlank() } + } + } + + 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 { + 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) + } + 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() + } + + 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 + } + } + + 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(INPUT_DATE_FORMAT, Locale.KOREAN) + val outputFormat = SimpleDateFormat(OUTPUT_DATE_TIME_FORMAT, Locale.getDefault()) + + val parsedDateTime = inputFormat.parse(dateTime) + meetingDateValue.value = parsedDateTime?.let { outputFormat.format(it) } + meetingDate.value = dateTime + } + + 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( + 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}") + } + } + } + } + } + } + + 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) { + _writeUIState.value = WriteUIState.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) { + _writeUIState.value = WriteUIState.InvalidInput(R.string.write_invalid_total_price) + return null + } + return totalPriceConverted + } + + 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 + } + return false + } + + fun makeNavigateToOptionalEvent() { + _navigateToOptionalEvent.setValue(true) + } + + 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 = "" + } + + 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 + } + } + } +} 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/OnOfferingWriteClickListener.kt new file mode 100644 index 000000000..56fe0e55b --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/OnOfferingWriteClickListener.kt @@ -0,0 +1,7 @@ +package com.zzang.chongdae.presentation.view.write + +interface OnOfferingWriteClickListener { + fun onDateTimeSubmitButtonClick() + + fun onDateTimeCancelButtonClick() +} diff --git a/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/WriteUIState.kt b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/WriteUIState.kt new file mode 100644 index 000000000..30f2672bf --- /dev/null +++ b/android/app/src/main/java/com/zzang/chongdae/presentation/view/write/WriteUIState.kt @@ -0,0 +1,24 @@ +package com.zzang.chongdae.presentation.view.write + +import androidx.annotation.StringRes + +sealed class WriteUIState { + data class Empty( + @StringRes val message: Int, + ) : WriteUIState() + + data object Initial : WriteUIState() + + data object Loading : WriteUIState() + + data class InvalidInput( + @StringRes val message: Int, + ) : WriteUIState() + + data class Success(val url: String) : WriteUIState() + + data class Error( + @StringRes val message: Int, + val errorMessage: String, + ) : WriteUIState() +} diff --git a/android/app/src/main/res/drawable/bg_comment_gray_color.xml b/android/app/src/main/res/drawable/bg_comment_gray_color.xml new file mode 100644 index 000000000..c03a8578f --- /dev/null +++ b/android/app/src/main/res/drawable/bg_comment_gray_color.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_comment_main_color.xml b/android/app/src/main/res/drawable/bg_comment_main_color.xml new file mode 100644 index 000000000..449768a4e --- /dev/null +++ b/android/app/src/main/res/drawable/bg_comment_main_color.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_date_time_picker.xml b/android/app/src/main/res/drawable/bg_date_time_picker.xml new file mode 100644 index 000000000..a02929177 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_date_time_picker.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_date_time_picker_selected.xml b/android/app/src/main/res/drawable/bg_date_time_picker_selected.xml new file mode 100644 index 000000000..07e351fef --- /dev/null +++ b/android/app/src/main/res/drawable/bg_date_time_picker_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_gray100_radius_16dp.xml b/android/app/src/main/res/drawable/bg_gray100_radius_16dp.xml new file mode 100644 index 000000000..076de5b62 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_gray100_radius_16dp.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_main_discount_rate.xml b/android/app/src/main/res/drawable/bg_main_discount_rate.xml new file mode 100644 index 000000000..c8e857e18 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_main_discount_rate.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/bg_main_filter.xml b/android/app/src/main/res/drawable/bg_main_filter.xml new file mode 100644 index 000000000..30c455968 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_main_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_main_gray_color_10dp.xml b/android/app/src/main/res/drawable/bg_main_gray_color_10dp.xml new file mode 100644 index 000000000..a3c0a4412 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_main_gray_color_10dp.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_main_main_color_10dp.xml b/android/app/src/main/res/drawable/bg_main_main_color_10dp.xml new file mode 100644 index 000000000..293c7ccdf --- /dev/null +++ b/android/app/src/main/res/drawable/bg_main_main_color_10dp.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_radius_10dp.xml b/android/app/src/main/res/drawable/bg_radius_10dp.xml new file mode 100644 index 000000000..ec29363ec --- /dev/null +++ b/android/app/src/main/res/drawable/bg_radius_10dp.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bg_white_radius_16dp.xml b/android/app/src/main/res/drawable/bg_white_radius_16dp.xml new file mode 100644 index 000000000..cf3ab0aae --- /dev/null +++ b/android/app/src/main/res/drawable/bg_white_radius_16dp.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bg_yellow_radius_12dp.xml b/android/app/src/main/res/drawable/bg_yellow_radius_12dp.xml new file mode 100644 index 000000000..fb20fac8c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_yellow_radius_12dp.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/drawable/bottom_nav_item_color.xml b/android/app/src/main/res/drawable/bottom_nav_item_color.xml new file mode 100644 index 000000000..b1b8c9c6d --- /dev/null +++ b/android/app/src/main/res/drawable/bottom_nav_item_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/btn_background_selector.xml b/android/app/src/main/res/drawable/btn_background_selector.xml new file mode 100644 index 000000000..151861f45 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_background_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/btn_chat.xml b/android/app/src/main/res/drawable/btn_chat.xml new file mode 100644 index 000000000..9c90aa45a --- /dev/null +++ b/android/app/src/main/res/drawable/btn_chat.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/btn_comment_detail_send.xml b/android/app/src/main/res/drawable/btn_comment_detail_send.xml new file mode 100644 index 000000000..8f66f805a --- /dev/null +++ b/android/app/src/main/res/drawable/btn_comment_detail_send.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/app/src/main/res/drawable/btn_exit.xml b/android/app/src/main/res/drawable/btn_exit.xml new file mode 100644 index 000000000..b771438bf --- /dev/null +++ b/android/app/src/main/res/drawable/btn_exit.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_home.xml b/android/app/src/main/res/drawable/btn_home.xml new file mode 100644 index 000000000..e04033fee --- /dev/null +++ b/android/app/src/main/res/drawable/btn_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_left_arrow.xml b/android/app/src/main/res/drawable/btn_left_arrow.xml new file mode 100644 index 000000000..cabecdc24 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_left_arrow.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_left_vector.xml b/android/app/src/main/res/drawable/btn_left_vector.xml new file mode 100644 index 000000000..ba900f486 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_left_vector.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_main_create_offering.xml b/android/app/src/main/res/drawable/btn_main_create_offering.xml new file mode 100644 index 000000000..92f915b24 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_main_create_offering.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/btn_main_filter.xml b/android/app/src/main/res/drawable/btn_main_filter.xml new file mode 100644 index 000000000..3836c2488 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_main_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/btn_more.xml b/android/app/src/main/res/drawable/btn_more.xml new file mode 100644 index 000000000..7530fe6da --- /dev/null +++ b/android/app/src/main/res/drawable/btn_more.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/btn_my_page.xml b/android/app/src/main/res/drawable/btn_my_page.xml new file mode 100644 index 000000000..14d12c682 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_my_page.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_offering_write_clear.xml b/android/app/src/main/res/drawable/btn_offering_write_clear.xml new file mode 100644 index 000000000..496623884 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_offering_write_clear.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/btn_offering_write_delete_image.xml b/android/app/src/main/res/drawable/btn_offering_write_delete_image.xml new file mode 100644 index 000000000..fa0584669 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_offering_write_delete_image.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/btn_offering_write_minus_total_personnel.xml b/android/app/src/main/res/drawable/btn_offering_write_minus_total_personnel.xml new file mode 100644 index 000000000..7b2cd08d2 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_offering_write_minus_total_personnel.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/btn_offering_write_plus_total_personnel.xml b/android/app/src/main/res/drawable/btn_offering_write_plus_total_personnel.xml new file mode 100644 index 000000000..1e66756eb --- /dev/null +++ b/android/app/src/main/res/drawable/btn_offering_write_plus_total_personnel.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/btn_participation_closed.xml b/android/app/src/main/res/drawable/btn_participation_closed.xml new file mode 100644 index 000000000..4be87e2e5 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_participation_closed.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/btn_participation_enabled.xml b/android/app/src/main/res/drawable/btn_participation_enabled.xml new file mode 100644 index 000000000..944d45081 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_participation_enabled.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/drawable/btn_participation_opened.xml b/android/app/src/main/res/drawable/btn_participation_opened.xml new file mode 100644 index 000000000..24872d43a --- /dev/null +++ b/android/app/src/main/res/drawable/btn_participation_opened.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/btn_right_arrow.xml b/android/app/src/main/res/drawable/btn_right_arrow.xml new file mode 100644 index 000000000..1080f75ba --- /dev/null +++ b/android/app/src/main/res/drawable/btn_right_arrow.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_rounded.xml b/android/app/src/main/res/drawable/btn_rounded.xml new file mode 100644 index 000000000..87a981182 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_rounded.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/btn_rounded_disabled.xml b/android/app/src/main/res/drawable/btn_rounded_disabled.xml new file mode 100644 index 000000000..ce8eee5ec --- /dev/null +++ b/android/app/src/main/res/drawable/btn_rounded_disabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/btn_rounded_gray.xml b/android/app/src/main/res/drawable/btn_rounded_gray.xml new file mode 100644 index 000000000..cde21b032 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_rounded_gray.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/btn_under_vector.xml b/android/app/src/main/res/drawable/btn_under_vector.xml new file mode 100644 index 000000000..eeaae9d80 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_under_vector.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_up_vector.xml b/android/app/src/main/res/drawable/btn_up_vector.xml new file mode 100644 index 000000000..66f61ffc6 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_up_vector.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/btn_upload_photo.xml b/android/app/src/main/res/drawable/btn_upload_photo.xml new file mode 100644 index 000000000..aeba2f2a1 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_upload_photo.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/drawable/btn_upside.xml b/android/app/src/main/res/drawable/btn_upside.xml new file mode 100644 index 000000000..c51beee90 --- /dev/null +++ b/android/app/src/main/res/drawable/btn_upside.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/et_border_gray.xml b/android/app/src/main/res/drawable/et_border_gray.xml new file mode 100644 index 000000000..8217f865e --- /dev/null +++ b/android/app/src/main/res/drawable/et_border_gray.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/et_border_main_color.xml b/android/app/src/main/res/drawable/et_border_main_color.xml new file mode 100644 index 000000000..c454835a9 --- /dev/null +++ b/android/app/src/main/res/drawable/et_border_main_color.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_calendar.xml b/android/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 000000000..267e7204e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_calendar2.xml b/android/app/src/main/res/drawable/ic_calendar2.xml new file mode 100644 index 000000000..19d0a87ed --- /dev/null +++ b/android/app/src/main/res/drawable/ic_calendar2.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_check.xml b/android/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..3895bf2aa --- /dev/null +++ b/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_chongdae_main_color.xml b/android/app/src/main/res/drawable/ic_chongdae_main_color.xml new file mode 100644 index 000000000..e33ff6683 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chongdae_main_color.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_chongdae_sub.xml b/android/app/src/main/res/drawable/ic_chongdae_sub.xml new file mode 100644 index 000000000..e33ff6683 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chongdae_sub.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_comment_detail_calendar.xml b/android/app/src/main/res/drawable/ic_comment_detail_calendar.xml new file mode 100644 index 000000000..267e7204e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_comment_detail_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_comment_detail_location.xml b/android/app/src/main/res/drawable/ic_comment_detail_location.xml new file mode 100644 index 000000000..a0845da2f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_comment_detail_location.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_comment_detail_recruiting.png b/android/app/src/main/res/drawable/ic_comment_detail_recruiting.png new file mode 100644 index 000000000..8652bb2cc Binary files /dev/null and b/android/app/src/main/res/drawable/ic_comment_detail_recruiting.png differ diff --git a/android/app/src/main/res/drawable/ic_comment_room_proposer.xml b/android/app/src/main/res/drawable/ic_comment_room_proposer.xml new file mode 100644 index 000000000..df9465bde --- /dev/null +++ b/android/app/src/main/res/drawable/ic_comment_room_proposer.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_clock.xml b/android/app/src/main/res/drawable/ic_detail_clock.xml new file mode 100644 index 000000000..e5297910a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_clock.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_divide_line.xml b/android/app/src/main/res/drawable/ic_detail_divide_line.xml new file mode 100644 index 000000000..ca8d2c921 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_divide_line.xml @@ -0,0 +1,11 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_detail_location.xml b/android/app/src/main/res/drawable/ic_detail_location.xml new file mode 100644 index 000000000..8dbc86a40 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_location.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_modify.xml b/android/app/src/main/res/drawable/ic_detail_modify.xml new file mode 100644 index 000000000..4d92a367a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_modify.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_money.xml b/android/app/src/main/res/drawable/ic_detail_money.xml new file mode 100644 index 000000000..a38290899 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_money.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_remove.xml b/android/app/src/main/res/drawable/ic_detail_remove.xml new file mode 100644 index 000000000..9af26eca6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_remove.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_report.xml b/android/app/src/main/res/drawable/ic_detail_report.xml new file mode 100644 index 000000000..a51331610 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_report.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_detail_user.xml b/android/app/src/main/res/drawable/ic_detail_user.xml new file mode 100644 index 000000000..bcf075b73 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_detail_user.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cb..ca3826a46 100644 --- a/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,74 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 7706ab9e6..9f48cad25 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,43 @@ - - - - - - - - + android:viewportWidth="128" + android:viewportHeight="128"> + + android:pathData="M16,0L112,0A16,16 0,0 1,128 16L128,112A16,16 0,0 1,112 128L16,128A16,16 0,0 1,0 112L0,16A16,16 0,0 1,16 0z" + android:fillColor="#F15642"/> + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_location.xml b/android/app/src/main/res/drawable/ic_location.xml new file mode 100644 index 000000000..a0845da2f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_location.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_main_checked_circle.xml b/android/app/src/main/res/drawable/ic_main_checked_circle.xml new file mode 100644 index 000000000..1e4644d2d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_main_checked_circle.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_main_search.xml b/android/app/src/main/res/drawable/ic_main_search.xml new file mode 100644 index 000000000..7a4bd207f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_main_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_main_unchecked_circle.xml b/android/app/src/main/res/drawable/ic_main_unchecked_circle.xml new file mode 100644 index 000000000..3833a0858 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_main_unchecked_circle.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_logout.xml b/android/app/src/main/res/drawable/ic_my_page_logout.xml new file mode 100644 index 000000000..0a91782b8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_logout.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_my_offerings.xml b/android/app/src/main/res/drawable/ic_my_page_my_offerings.xml new file mode 100644 index 000000000..229730f3d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_my_offerings.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_nickname_modification.xml b/android/app/src/main/res/drawable/ic_my_page_nickname_modification.xml new file mode 100644 index 000000000..9d93443aa --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_nickname_modification.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_privacy.xml b/android/app/src/main/res/drawable/ic_my_page_privacy.xml new file mode 100644 index 000000000..8a1c69528 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_privacy.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_profile.xml b/android/app/src/main/res/drawable/ic_my_page_profile.xml new file mode 100644 index 000000000..77fd75edd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_profile.xml @@ -0,0 +1,17 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml b/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml new file mode 100644 index 000000000..07834b7f8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_terms_of_use.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml b/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml new file mode 100644 index 000000000..48c1c485b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_my_page_withdrawal.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_not_proposer.xml b/android/app/src/main/res/drawable/ic_not_proposer.xml new file mode 100644 index 000000000..31dcd2e40 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_not_proposer.xml @@ -0,0 +1,17 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_proposer.xml b/android/app/src/main/res/drawable/ic_proposer.xml new file mode 100644 index 000000000..497839a55 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_proposer.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/img_comment_room_empty_comment_room.xml b/android/app/src/main/res/drawable/img_comment_room_empty_comment_room.xml new file mode 100644 index 000000000..812df049d --- /dev/null +++ b/android/app/src/main/res/drawable/img_comment_room_empty_comment_room.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/img_detail_product_default.xml b/android/app/src/main/res/drawable/img_detail_product_default.xml new file mode 100644 index 000000000..47968b117 --- /dev/null +++ b/android/app/src/main/res/drawable/img_detail_product_default.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/img_main_product_default.xml b/android/app/src/main/res/drawable/img_main_product_default.xml new file mode 100644 index 000000000..b8bf49b4b --- /dev/null +++ b/android/app/src/main/res/drawable/img_main_product_default.xml @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/img_profile.xml b/android/app/src/main/res/drawable/img_profile.xml new file mode 100644 index 000000000..d8b667073 --- /dev/null +++ b/android/app/src/main/res/drawable/img_profile.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/android/app/src/main/res/drawable/login_kakao_symbol.xml b/android/app/src/main/res/drawable/login_kakao_symbol.xml new file mode 100644 index 000000000..a768edff0 --- /dev/null +++ b/android/app/src/main/res/drawable/login_kakao_symbol.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/font/suit_bold.otf b/android/app/src/main/res/font/suit_bold.otf new file mode 100644 index 000000000..2aef29841 Binary files /dev/null and b/android/app/src/main/res/font/suit_bold.otf differ diff --git a/android/app/src/main/res/font/suit_extrabold.otf b/android/app/src/main/res/font/suit_extrabold.otf new file mode 100644 index 000000000..24b0adae5 Binary files /dev/null and b/android/app/src/main/res/font/suit_extrabold.otf differ diff --git a/android/app/src/main/res/font/suit_extralight.otf b/android/app/src/main/res/font/suit_extralight.otf new file mode 100644 index 000000000..dbfe91bac Binary files /dev/null and b/android/app/src/main/res/font/suit_extralight.otf differ diff --git a/android/app/src/main/res/font/suit_heavy.otf b/android/app/src/main/res/font/suit_heavy.otf new file mode 100644 index 000000000..302acc8bd Binary files /dev/null and b/android/app/src/main/res/font/suit_heavy.otf differ diff --git a/android/app/src/main/res/font/suit_light.otf b/android/app/src/main/res/font/suit_light.otf new file mode 100644 index 000000000..e3cf2c9d8 Binary files /dev/null and b/android/app/src/main/res/font/suit_light.otf differ diff --git a/android/app/src/main/res/font/suit_medium.otf b/android/app/src/main/res/font/suit_medium.otf new file mode 100644 index 000000000..64864ec56 Binary files /dev/null and b/android/app/src/main/res/font/suit_medium.otf differ diff --git a/android/app/src/main/res/font/suit_regular.otf b/android/app/src/main/res/font/suit_regular.otf new file mode 100644 index 000000000..11b893e3e Binary files /dev/null and b/android/app/src/main/res/font/suit_regular.otf differ diff --git a/android/app/src/main/res/font/suit_semibold.otf b/android/app/src/main/res/font/suit_semibold.otf new file mode 100644 index 000000000..c5fabf83c Binary files /dev/null and b/android/app/src/main/res/font/suit_semibold.otf differ diff --git a/android/app/src/main/res/font/suit_thin.otf b/android/app/src/main/res/font/suit_thin.otf new file mode 100644 index 000000000..f9f248e8e Binary files /dev/null and b/android/app/src/main/res/font/suit_thin.otf differ diff --git a/android/app/src/main/res/layout/activity_comment_detail.xml b/android/app/src/main/res/layout/activity_comment_detail.xml new file mode 100644 index 000000000..7cc1621fd --- /dev/null +++ b/android/app/src/main/res/layout/activity_comment_detail.xml @@ -0,0 +1,402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml new file mode 100644 index 000000000..2023b3e1c --- /dev/null +++ b/android/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index c2c9cc90d..32f865813 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -7,12 +7,32 @@ android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/white" tools:context=".presentation.view.home.MainActivity"> - + android:layout_height="0dp" + app:defaultNavHost="true" + app:layout_constraintBottom_toTopOf="@id/main_bottom_navigation" + app:layout_constraintTop_toTopOf="parent" + app:navGraph="@navigation/bottom_menu_navigation" /> + + diff --git a/android/app/src/main/res/layout/dialog_address_finder.xml b/android/app/src/main/res/layout/dialog_address_finder.xml new file mode 100644 index 000000000..16ca5106d --- /dev/null +++ b/android/app/src/main/res/layout/dialog_address_finder.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_date_picker.xml b/android/app/src/main/res/layout/dialog_date_picker.xml new file mode 100644 index 000000000..78d4e866c --- /dev/null +++ b/android/app/src/main/res/layout/dialog_date_picker.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + +