diff --git a/.github/workflows/backend_pr_decorator.yml b/.github/workflows/backend_pr_decorator.yml
index 92236bdf0..246e81b8d 100644
--- a/.github/workflows/backend_pr_decorator.yml
+++ b/.github/workflows/backend_pr_decorator.yml
@@ -36,7 +36,7 @@ jobs:
- name: run jacocoTestCoverage
run: |
cd backend/ddang
- ./gradlew jacocoTestCoverage --info
+ ./gradlew jacocoTestCoverage
- name: set author slack id
if: always()
@@ -50,9 +50,9 @@ jobs:
elif [ "$GIT_ID" == "swonny" ]; then
AUTHOR_NAME="${{ secrets.swonny_slack_display_name }}"
AUTHOR_ID="${{ secrets.swonny_slack_id }}"
- elif [ "$GIT_ID" == "jj503" ]; then
- AUTHOR_NAME="${{ secrets.jj503_slack_display_name }}"
- AUTHOR_ID="${{ secrets.jj503_slack_id }}"
+ elif [ "$GIT_ID" == "JJ503" ]; then
+ AUTHOR_NAME="${{ secrets.JJ503_slack_display_name }}"
+ AUTHOR_ID="${{ secrets.JJ503_slack_id }}"
elif [ "$GIT_ID" == "kwonyj1022" ]; then
AUTHOR_NAME="${{ secrets.kwonyj1022_slack_display_name }}"
AUTHOR_ID="${{ secrets.kwonyj1022_slack_id }}"
@@ -61,44 +61,9 @@ jobs:
echo "AUTHOR_NAME=${AUTHOR_NAME}" >> $GITHUB_OUTPUT
echo "AUTHOR_ID=${AUTHOR_ID}" >> $GITHUB_OUTPUT
- - name: run an analysis of the ${{ github.REF }} branch ${{ github.BASE_REF }} base
- uses: sonarsource/sonarqube-scan-action@master
- env:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- SONAR_HOST_URL: ${{ secrets.SONAR_URL }}
- with:
- args: >
- -Dsonar.issuesReport.console.enable=true
- -Dsonar.projectKey=develop-be-project
- -Dsonar.java.binaries=backend/ddang/build/classes
- -Dsonar.exclusions=**/*Dto*.java,**/*Application*.java,**/*Exception*.java,**/*Response*.java,**/*Request*.java,**/*Configuration*.java,**/*Appender*.java,**/*.html,**/generated/**,**/resources/**
- -Dsonar.sourceEncoding=UTF-8
- -Dsonar.java.coveragePlugin=jacoco
- -Dsonar.coverage.jacoco.xmlReportPaths=backend/ddang/build/reports/jacoco/test/jacocoTestReport.xml
- -Dsonar.issue.ignore.multicriteria=e1,e2
- -Dsonar.issue.ignore.multicriteria.e1.ruleKey=java:S100
- -Dsonar.issue.ignore.multicriteria.e1.resourceKey=**/*Test.java
- -Dsonar.issue.ignore.multicriteria.e2.ruleKey=java:S1192
- -Dsonar.issue.ignore.multicriteria.e2.resourceKey=**/*Test.java
-
- - name: sonarqube quality check
- id: sonar-quality
- run: |
- SONAR_PROJECT_KEY="develop-be-project"
- SONAR_TOKEN="${{ secrets.SONAR_TOKEN }}"
-
- RESULT=$(curl -s -u "admin:root" "${{ secrets.SONAR_URL }}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}&pullRequest=${{github.event.number}}")
- STATUS=$(echo "$RESULT" | jq -r '.projectStatus.status')
- ERROR_METRIC_KEYS=$(echo "$RESULT" | jq -r '.projectStatus.conditions[] | select(.status == "ERROR").metricKey')
-
- echo "STATUS=${STATUS}" >> $GITHUB_OUTPUT
- echo "ERROR_METRIC_KEYS=${ERROR_METRIC_KEYS}" >> $GITHUB_OUTPUT
-
- name: set variables
id: variables
run: |
- SONAR_SCANNER_URL="${{ secrets.SONAR_URL }}/dashboard?id=sonarqube-test&pullRequest=${{github.event.number}}"
-
REVIEWERS_GIT_ID='${{ toJson(github.event.pull_request.requested_reviewers[*].login) }}'
reviewers=$(echo "$REVIEWERS_GIT_ID" | jq -r '.[]')
@@ -111,32 +76,34 @@ jobs:
elif [ "$reviewer" == "swonny" ]; then
REVIEWERS_SLACK_ID+="<@${{ secrets.swonny_slack_id }}> "
elif [ "$reviewer" == "JJ503" ]; then
- REVIEWERS_SLACK_ID+="<@${{ secrets.jj503_slack_id }}> "
+ REVIEWERS_SLACK_ID+="<@${{ secrets.JJ503_slack_id }}> "
elif [ "$reviewer" == "kwonyj1022" ]; then
REVIEWERS_SLACK_ID+="<@${{ secrets.kwonyj1022_slack_id }}> "
fi
done
echo "AUTHOR=${AUTHOR}" >> $GITHUB_OUTPUT
- echo "SONAR_SCANNER_URL=${SONAR_SCANNER_URL}" >> $GITHUB_OUTPUT
echo "REVIEWERS=${REVIEWERS}" >> $GITHUB_OUTPUT
echo "REVIEWERS_SLACK_ID=${REVIEWERS_SLACK_ID}" >> $GITHUB_OUTPUT
- - name: slack test
+ - name: slack notification
+ if: github.event_name == 'pull_request' && github.event.action != 'synchronize'
run: |
SLACK_MESSAGE='{"text":"PR 브랜치 분석","blocks":[{"type":"section","text":{"type":"mrkdwn","text":">*PR 브랜치 분석* \n>\n>*PR Author*\n>'
SLACK_MESSAGE+="${{ steps.author-slack.outputs.AUTHOR_NAME }}"
SLACK_MESSAGE+="\n>\n>*PR 링크*\n><"
SLACK_MESSAGE+="${{ github.event.pull_request.html_url }} "
- SLACK_MESSAGE+="> \n>\n>분석 결과\n>"
+ SLACK_MESSAGE+="> \n>\n>*PR 제목*\n>"
+ SLACK_MESSAGE+="${{ github.event.pull_request.title }}"
+ SLACK_MESSAGE+="\n>\n>분석 결과\n>"
SLACK_MESSAGE+=":white_check_mark:"
SLACK_MESSAGE+="\n>\n>*리뷰어*\n>"
SLACK_MESSAGE+="${{ steps.variables.outputs.REVIEWERS_SLACK_ID }}"
SLACK_MESSAGE+='"}}]}'
- curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d "${SLACK_MESSAGE}"
+ curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d "${SLACK_MESSAGE}"
- - name: slack failed test
+ - name: slack failed notification
if: failure()
run: |
SLACK_MESSAGE='{"text":"PR 브랜치 분석","blocks":[{"type":"section","text":{"type":"mrkdwn","text":">*PR 브랜치 분석* \n>\n>*PR Author*\n>'
@@ -145,13 +112,15 @@ jobs:
SLACK_MESSAGE+=">"
SLACK_MESSAGE+="\n>\n>*PR 링크*\n><"
SLACK_MESSAGE+="${{ github.event.pull_request.html_url }} "
- SLACK_MESSAGE+="> \n>\n>분석 결과\n>"
+ SLACK_MESSAGE+="> \n>\n>*PR 제목*\n>"
+ SLACK_MESSAGE+="${{ github.event.pull_request.title }}"
+ SLACK_MESSAGE+="\n>\n>분석 결과\n>"
SLACK_MESSAGE+=":x:"
SLACK_MESSAGE+='"}}]}'
curl -X POST ${{ secrets.SLACK_WEBHOOK }} -d "${SLACK_MESSAGE}"
- - name: slack cancelled test
+ - name: slack cancelled notification
if: cancelled()
run: |
SLACK_MESSAGE='{"text":"PR 브랜치 분석","blocks":[{"type":"section","text":{"type":"mrkdwn","text":">*PR 브랜치 분석* \n>\n>*PR Author*\n>'
@@ -160,7 +129,9 @@ jobs:
SLACK_MESSAGE+=">"
SLACK_MESSAGE+="\n>\n>*PR 링크*\n><"
SLACK_MESSAGE+="${{ github.event.pull_request.html_url }} "
- SLACK_MESSAGE+="> \n>\n>분석 결과\n>"
+ SLACK_MESSAGE+="> \n>\n>*PR 제목*\n>"
+ SLACK_MESSAGE+="${{ github.event.pull_request.title }}"
+ SLACK_MESSAGE+="\n>\n>분석 결과\n>"
SLACK_MESSAGE+=":black_square_for_stop:"
SLACK_MESSAGE+='"}}]}'
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2bee75e2e..c73909063 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
+ id 'com.google.dagger.hilt.android'
id 'kotlin-parcelize'
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
@@ -19,14 +20,15 @@ android {
minSdk 28
targetSdk 33
- versionCode 3
- versionName "2.1.0"
+ versionCode 12
+ versionName "5.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [KEY_KAKAO: properties['key.kakao']]
buildConfigField 'String', 'KEY_KAKAO', properties['key.kakao.string']
buildConfigField "String", "PRIVACY_POLICY_URL", properties['url.privacyPolicy']
+ buildConfigField "String", "DDANG_EMAIL_ADDRESS", properties['email.address.ddangddangddang']
}
buildTypes {
@@ -94,7 +96,21 @@ dependencies {
implementation platform('com.google.firebase:firebase-bom:32.2.0')
implementation 'com.google.firebase:firebase-analytics-ktx'
implementation 'com.google.firebase:firebase-crashlytics-ktx'
+ implementation 'com.google.firebase:firebase-messaging-ktx:23.2.1'
// 카카오 로그인
implementation 'com.kakao.sdk:v2-user:2.11.1'
+
+ // lottie 애니메이션
+ implementation 'com.airbnb.android:lottie:6.1.0'
+
+ // hilt
+ implementation "com.google.dagger:hilt-android:2.44"
+ kapt "com.google.dagger:hilt-compiler:2.44"
+
+ // app update manager
+ implementation 'com.google.android.play:app-update-ktx:2.1.0'
+}
+kapt {
+ correctErrorTypes true
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8aa6d559f..f2cf40d13 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+
+
+
+
+ android:exported="false"
+ android:windowSoftInputMode="adjustPan" />
@@ -82,6 +96,13 @@
android:name="photopicker_activity:0:required"
android:value="" />
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt b/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt
new file mode 100644
index 000000000..f45a1df38
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/Annotations.kt
@@ -0,0 +1,19 @@
+package com.ddangddangddang.android.di
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class AuthRetrofitQualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class AuctionRetrofitQualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class DateFormatter
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class TimeFormatter
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/ApiServiceModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/ApiServiceModule.kt
new file mode 100644
index 000000000..e6838f410
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/ApiServiceModule.kt
@@ -0,0 +1,25 @@
+package com.ddangddangddang.android.di
+
+import com.ddangddangddang.data.remote.AuctionService
+import com.ddangddangddang.data.remote.AuthService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ApiServiceModule {
+
+ @Singleton
+ @Provides
+ fun provideAuthService(@AuthRetrofitQualifier retrofit: Retrofit): AuthService =
+ retrofit.create(AuthService::class.java)
+
+ @Singleton
+ @Provides
+ fun provideAuctionService(@AuctionRetrofitQualifier retrofit: Retrofit): AuctionService =
+ retrofit.create(AuctionService::class.java)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt
new file mode 100644
index 000000000..8cf7fbffb
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/FormatterModule.kt
@@ -0,0 +1,36 @@
+package com.ddangddangddang.android.di
+
+import android.content.Context
+import com.ddangddangddang.android.R
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object FormatterModule {
+ @DateFormatter
+ @Singleton
+ @Provides
+ fun provideDateFormatter(@ApplicationContext context: Context): DateTimeFormatter {
+ return DateTimeFormatter.ofPattern(
+ context.getString(R.string.all_date_format),
+ Locale.KOREAN,
+ )
+ }
+
+ @TimeFormatter
+ @Singleton
+ @Provides
+ fun provideTimeFormatter(@ApplicationContext context: Context): DateTimeFormatter {
+ return DateTimeFormatter.ofPattern(
+ context.getString(R.string.all_time_format),
+ Locale.KOREAN,
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/RepositoryModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/RepositoryModule.kt
new file mode 100644
index 000000000..4442600ab
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/RepositoryModule.kt
@@ -0,0 +1,53 @@
+package com.ddangddangddang.android.di
+
+import com.ddangddangddang.data.repository.AuctionRepository
+import com.ddangddangddang.data.repository.AuctionRepositoryImpl
+import com.ddangddangddang.data.repository.AuthRepository
+import com.ddangddangddang.data.repository.AuthRepositoryImpl
+import com.ddangddangddang.data.repository.CategoryRepository
+import com.ddangddangddang.data.repository.CategoryRepositoryImpl
+import com.ddangddangddang.data.repository.ChatRepository
+import com.ddangddangddang.data.repository.ChatRepositoryImpl
+import com.ddangddangddang.data.repository.RegionRepository
+import com.ddangddangddang.data.repository.RegionRepositoryImpl
+import com.ddangddangddang.data.repository.ReviewRepository
+import com.ddangddangddang.data.repository.ReviewRepositoryImpl
+import com.ddangddangddang.data.repository.UserRepository
+import com.ddangddangddang.data.repository.UserRepositoryImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+ @Singleton
+ @Binds
+ abstract fun bindAuctionRepository(auctionRepository: AuctionRepositoryImpl): AuctionRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindAuthRepository(authRepository: AuthRepositoryImpl): AuthRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindCategoryRepository(categoryRepository: CategoryRepositoryImpl): CategoryRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindChatRepository(chatRepository: ChatRepositoryImpl): ChatRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindRegionRepository(regionRepository: RegionRepositoryImpl): RegionRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindUserRepository(userRepository: UserRepositoryImpl): UserRepository
+
+ @Singleton
+ @Binds
+ abstract fun bindReviewRepository(reviewRepository: ReviewRepositoryImpl): ReviewRepository
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/RetrofitModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/RetrofitModule.kt
new file mode 100644
index 000000000..39896cd2c
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/RetrofitModule.kt
@@ -0,0 +1,26 @@
+package com.ddangddangddang.android.di
+
+import com.ddangddangddang.data.remote.AuctionRetrofit
+import com.ddangddangddang.data.remote.AuthRetrofit
+import com.ddangddangddang.data.repository.AuthRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RetrofitModule {
+ @AuthRetrofitQualifier
+ @Singleton
+ @Provides
+ fun provideAuthRetrofit(): Retrofit = AuthRetrofit.createInstance()
+
+ @AuctionRetrofitQualifier
+ @Singleton
+ @Provides
+ fun provideAuctionRetrofit(authRepository: AuthRepository): Retrofit =
+ AuctionRetrofit.createInstance(authRepository)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/di/SharedPreferenceModule.kt b/android/app/src/main/java/com/ddangddangddang/android/di/SharedPreferenceModule.kt
new file mode 100644
index 000000000..524616415
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/di/SharedPreferenceModule.kt
@@ -0,0 +1,19 @@
+package com.ddangddangddang.android.di
+
+import android.content.Context
+import com.ddangddangddang.data.local.AuthSharedPreference
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object SharedPreferenceModule {
+ @Singleton
+ @Provides
+ fun provideAuthSharedPreferences(@ApplicationContext context: Context): AuthSharedPreference =
+ AuthSharedPreference(context)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
index 2d279287f..ddce2225b 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/BindingAdapter.kt
@@ -19,11 +19,12 @@ fun ImageView.setImageUrl(url: String?, placeholder: Drawable? = null) {
.into(this)
}
-@BindingAdapter("imageUri")
-fun ImageView.setImageUrl(uri: Uri?) {
+@BindingAdapter("imageUri", "placeholder", requireAll = false)
+fun ImageView.setImageUrl(uri: Uri?, placeholder: Drawable? = null) {
uri?.let {
Glide.with(context)
.load(it)
+ .error(placeholder)
.into(this)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorType.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorType.kt
new file mode 100644
index 000000000..497cc5a0b
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorType.kt
@@ -0,0 +1,12 @@
+package com.ddangddangddang.android.feature.common
+
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.global.DdangDdangDdang
+
+sealed class ErrorType(open val message: String?) {
+ data class FAILURE(override val message: String?) : ErrorType(message)
+ object NETWORK_ERROR :
+ ErrorType(DdangDdangDdang.resources.getString(R.string.all_network_error_message))
+ object UNEXPECTED :
+ ErrorType(DdangDdangDdang.resources.getString(R.string.all_unexpected_error_message))
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt
new file mode 100644
index 000000000..0d1184d89
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ErrorTypeHandler.kt
@@ -0,0 +1,10 @@
+package com.ddangddangddang.android.feature.common
+
+import android.app.Activity
+import androidx.annotation.StringRes
+import com.ddangddangddang.android.util.view.Toaster
+
+fun Activity.notifyFailureMessage(errorType: ErrorType, @StringRes defaultMessageId: Int) {
+ val defaultMessage = getString(defaultMessageId)
+ Toaster.showShort(this, errorType.message ?: defaultMessage)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ViewModelFactory.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/common/ViewModelFactory.kt
deleted file mode 100644
index 83d46d19f..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/common/ViewModelFactory.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.ddangddangddang.android.feature.common
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewmodel.CreationExtras
-import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
-import com.ddangddangddang.android.feature.detail.bid.AuctionBidViewModel
-import com.ddangddangddang.android.feature.home.HomeViewModel
-import com.ddangddangddang.android.feature.login.LoginViewModel
-import com.ddangddangddang.android.feature.main.MainViewModel
-import com.ddangddangddang.android.feature.message.MessageViewModel
-import com.ddangddangddang.android.feature.messageRoom.MessageRoomViewModel
-import com.ddangddangddang.android.feature.mypage.MyPageViewModel
-import com.ddangddangddang.android.feature.register.RegisterAuctionViewModel
-import com.ddangddangddang.android.feature.register.category.SelectCategoryViewModel
-import com.ddangddangddang.android.feature.register.region.SelectRegionsViewModel
-import com.ddangddangddang.android.feature.report.ReportViewModel
-import com.ddangddangddang.android.feature.splash.SplashViewModel
-import com.ddangddangddang.android.global.DdangDdangDdang
-import com.ddangddangddang.data.repository.AuctionRepositoryImpl
-import com.ddangddangddang.data.repository.CategoryRepositoryImpl
-import com.ddangddangddang.data.repository.ChatRepositoryImpl
-import com.ddangddangddang.data.repository.RegionRepositoryImpl
-import com.ddangddangddang.data.repository.UserRepositoryImpl
-
-val auctionRepository = AuctionRepositoryImpl.getInstance(DdangDdangDdang.auctionRetrofit.service)
-val categoryRepository = CategoryRepositoryImpl.getInstance(DdangDdangDdang.auctionRetrofit.service)
-val regionRepository = RegionRepositoryImpl.getInstance(DdangDdangDdang.auctionRetrofit.service)
-val chatRepository = ChatRepositoryImpl.getInstance(DdangDdangDdang.auctionRetrofit.service)
-val userRepository = UserRepositoryImpl.getInstance(DdangDdangDdang.auctionRetrofit.service)
-
-@Suppress("UNCHECKED_CAST")
-val viewModelFactory = object : ViewModelProvider.Factory {
- override fun create(modelClass: Class, extras: CreationExtras): T =
- with(modelClass) {
- when {
- isAssignableFrom(MainViewModel::class.java) -> MainViewModel()
- isAssignableFrom(HomeViewModel::class.java) -> HomeViewModel(auctionRepository)
- isAssignableFrom(AuctionDetailViewModel::class.java) -> AuctionDetailViewModel(
- auctionRepository,
- chatRepository,
- )
-
- isAssignableFrom(RegisterAuctionViewModel::class.java) -> RegisterAuctionViewModel(
- auctionRepository,
- )
-
- isAssignableFrom(AuctionBidViewModel::class.java) -> AuctionBidViewModel(
- auctionRepository,
- )
-
- isAssignableFrom(SelectCategoryViewModel::class.java) -> SelectCategoryViewModel(
- categoryRepository,
- )
-
- isAssignableFrom(SelectRegionsViewModel::class.java) -> SelectRegionsViewModel(
- regionRepository,
- )
-
- isAssignableFrom(MessageViewModel::class.java) -> MessageViewModel(chatRepository)
- isAssignableFrom(MessageRoomViewModel::class.java) -> MessageRoomViewModel(
- chatRepository,
- )
-
- isAssignableFrom(LoginViewModel::class.java) -> LoginViewModel(DdangDdangDdang.authRepository)
- isAssignableFrom(SplashViewModel::class.java) -> SplashViewModel(DdangDdangDdang.authRepository)
- isAssignableFrom(MyPageViewModel::class.java) -> MyPageViewModel(
- DdangDdangDdang.authRepository,
- userRepository,
- )
-
- isAssignableFrom(ReportViewModel::class.java) -> ReportViewModel(auctionRepository)
- else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
- }
- } as T
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
index 2ccbe5aed..182dc99fd 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailActivity.kt
@@ -2,42 +2,51 @@ package com.ddangddangddang.android.feature.detail
import android.content.Context
import android.content.Intent
-import android.content.res.Resources
import android.os.Bundle
import androidx.activity.viewModels
-import androidx.viewpager2.widget.MarginPageTransformer
+import androidx.viewpager2.widget.ViewPager2
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityAuctionDetailBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.detail.bid.AuctionBidDialog
+import com.ddangddangddang.android.feature.imageDetail.ImageDetailActivity
import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
import com.ddangddangddang.android.feature.report.ReportActivity
-import com.ddangddangddang.android.model.RegionModel
+import com.ddangddangddang.android.model.ReportType
+import com.ddangddangddang.android.notification.NotificationType
+import com.ddangddangddang.android.notification.cancelActiveNotification
import com.ddangddangddang.android.util.binding.BindingActivity
import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.observeLoadingWithDialog
import com.ddangddangddang.android.util.view.showDialog
import com.google.android.material.tabs.TabLayoutMediator
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class AuctionDetailActivity :
BindingActivity(R.layout.activity_auction_detail) {
- private val viewModel: AuctionDetailViewModel by viewModels { viewModelFactory }
+ private val viewModel: AuctionDetailViewModel by viewModels()
+ private val auctionId: Long by lazy { intent.getLongExtra(AUCTION_ID_KEY, -1L) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.viewModel = viewModel
- val auctionId = intent.getLongExtra(AUCTION_ID_KEY, -1L)
setupViewModel()
if (savedInstanceState == null) viewModel.loadAuctionDetail(auctionId)
}
private fun setupViewModel() {
+ observeLoadingWithDialog(
+ this,
+ viewModel.isLoadingWithAnimation,
+ binding.clAuctionDetailContainer,
+ )
viewModel.event.observe(this) { event ->
handleEvent(event)
}
viewModel.auctionDetailModel.observe(this) {
setupAuctionImages(it.images)
- setupDirectRegions(it.directRegions)
}
}
@@ -50,14 +59,29 @@ class AuctionDetailActivity :
)
is AuctionDetailViewModel.AuctionDetailEvent.ReportAuction -> navigateToReport(event.auctionId)
+ is AuctionDetailViewModel.AuctionDetailEvent.NavigateToImageDetail -> {
+ navigateToImageDetail(event.images, event.focusPosition)
+ }
+
is AuctionDetailViewModel.AuctionDetailEvent.NotifyAuctionDoesNotExist -> notifyAuctionDoesNotExist()
AuctionDetailViewModel.AuctionDetailEvent.DeleteAuction -> askDeletion()
AuctionDetailViewModel.AuctionDetailEvent.NotifyAuctionDeletionComplete -> notifyDeleteComplete()
+ is AuctionDetailViewModel.AuctionDetailEvent.AuctionDeleteFailure -> {
+ notifyFailureMessage(event.error, R.string.detail_auction_delete_failure)
+ }
+
+ is AuctionDetailViewModel.AuctionDetailEvent.AuctionLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.detail_auction_load_failure)
+ }
+
+ is AuctionDetailViewModel.AuctionDetailEvent.EnterChatLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.detail_auction_enter_chat_room_failure)
+ }
}
}
private fun showAuctionBidDialog() {
- AuctionBidDialog().show(supportFragmentManager, BID_DIALOG_TAG)
+ AuctionBidDialog.show(supportFragmentManager)
}
private fun navigateToMessageRoom(roomId: Long) {
@@ -67,7 +91,13 @@ class AuctionDetailActivity :
}
private fun navigateToReport(auctionId: Long) {
- startActivity(ReportActivity.getIntent(this, auctionId))
+ startActivity(ReportActivity.getIntent(this, ReportType.ArticleReport.ordinal, auctionId))
+ }
+
+ private fun navigateToImageDetail(images: List, focusPosition: Int) {
+ startActivity(
+ ImageDetailActivity.getIntent(this@AuctionDetailActivity, images, focusPosition),
+ )
}
private fun notifyAuctionDoesNotExist() {
@@ -93,29 +123,35 @@ class AuctionDetailActivity :
private fun setupAuctionImages(images: List) {
binding.vpImageList.apply {
- clipToPadding = false
- clipChildren = false
- offscreenPageLimit = 1
- adapter = AuctionImageAdapter(images)
- setPageTransformer(MarginPageTransformer(convertDpToPx(20f)))
- setPadding(200, 0, 200, 0)
+ offscreenPageLimit = 3
+ setSideVisiblePageTransformer()
+ adapter = AuctionImageAdapter(images) { viewModel.navigateToImageDetail(it) }
}
TabLayoutMediator(binding.tlIndicator, binding.vpImageList) { _, _ -> }.attach()
}
- private fun convertDpToPx(dp: Float): Int {
- val density = Resources.getSystem().displayMetrics.density
- return (dp * density + 0.5f).toInt()
+ private fun ViewPager2.setSideVisiblePageTransformer() {
+ val pageMarginPx =
+ resources.getDimensionPixelOffset(R.dimen.auction_detail_image_page_margin)
+ val offsetPx = resources.getDimensionPixelOffset(R.dimen.auction_detail_image_page_offset)
+ setPageTransformer { page, position ->
+ val offset = position * (-2 * pageMarginPx + offsetPx)
+ page.translationX = offset
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ cancelNotification()
}
- private fun setupDirectRegions(regions: List) {
- binding.rvDirectExchangeRegions.adapter = AuctionDirectRegionsAdapter(regions)
+ private fun cancelNotification() {
+ cancelActiveNotification(NotificationType.BID.name, auctionId.toInt())
}
companion object {
private const val AUCTION_ID_KEY = "auction_id_key"
- private const val BID_DIALOG_TAG = "bid_dialog_tag"
fun getIntent(context: Context, auctionId: Long): Intent {
return Intent(context, AuctionDetailActivity::class.java).apply {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
index 3d519cfbd..4a29086cc 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailFormatter.kt
@@ -1,36 +1,89 @@
package com.ddangddangddang.android.feature.detail
import android.content.Context
+import com.ddangddangddang.android.R
import com.ddangddangddang.android.model.AuctionDetailModel
-import java.text.DecimalFormat
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
+import java.util.GregorianCalendar
object AuctionDetailFormatter {
private const val emptyContent = ""
- fun formatClosingTime(dateTime: LocalDateTime): String {
+ fun formatClosingTime(dateTime: LocalDateTime?): String {
+ if (dateTime == null) return emptyContent
val formatter = DateTimeFormatter.ofPattern("'~ 'yyyy.MM.dd a hh:mm")
return dateTime.format(formatter)
}
- fun formatAuctionDetailStatus(auctionDetailModel: AuctionDetailModel): String {
+ fun formatCategoryText(context: Context, mainCategory: String?, subCategory: String?): String {
+ if (mainCategory.isNullOrBlank() || subCategory.isNullOrBlank()) return emptyContent
+ return context.getString(R.string.detail_auction_category, mainCategory, subCategory)
+ }
+
+ fun formatAuctionStatusColor(context: Context, colorId: Int): Int {
+ if (colorId == 0) return context.getColor(R.color.white)
+ return context.getColor(colorId)
+ }
+
+ fun formatAuctionDetailStatus(
+ context: Context,
+ auctionDetailModel: AuctionDetailModel?,
+ ): String {
+ if (auctionDetailModel == null) return emptyContent
val priceStatus = auctionDetailModel.auctionDetailStatusModel.priceStatus
- val progressStatus = auctionDetailModel.auctionDetailStatusModel.progressStatus
val lastBidPrice = auctionDetailModel.lastBidPrice
- val formatter = DecimalFormat("#,###")
- return if (priceStatus == "입찰전") {
+ return if (priceStatus == context.getString(R.string.all_auction_unbidden)) {
priceStatus
} else {
- "$priceStatus ${formatter.format(lastBidPrice)}원"
+ context.getString(R.string.detail_auction_price_status, priceStatus, lastBidPrice)
}
}
+ fun formatClosingRemainDateText(context: Context, closingTime: LocalDateTime?): String {
+ if (closingTime == null) return emptyContent
+ val remainTime = closingTime.remainTimeFromNow()
+ if (remainTime.isEmpty()) return context.getString(R.string.detail_auction_remain_time_finish)
+ return context.getString(R.string.detail_auction_remain_time, remainTime)
+ }
+
fun getAuctionBottomButtonText(
context: Context,
bottomButtonStatus: AuctionDetailBottomButtonStatus?,
): String {
- return bottomButtonStatus?.let {
- context.getString(it.text)
- } ?: emptyContent
+ if (bottomButtonStatus == null) return emptyContent
+ return context.getString(bottomButtonStatus.text)
+ }
+
+ private fun LocalDateTime.remainTimeFromNow(): String {
+ val nowCalendar = LocalDateTime.now().toCalendar()
+ val nowDT = nowCalendar.time
+
+ val closingCalendar = this.toCalendar()
+ val closingDT = closingCalendar.time
+
+ val differenceInMills = closingDT.time - nowDT.time
+ if (differenceInMills <= 0) return ""
+ if (differenceInMills < 1000L * 60) return "${(differenceInMills / (1000L)) % 60}초"
+
+ val days = (differenceInMills / (24 * 60 * 60 * 1000L)) % 365
+ val hours = (differenceInMills / (60 * 60 * 1000L)) % 24
+ val minutes = (differenceInMills / (60 * 1000L)) % 60
+
+ return buildString {
+ if (days > 0L) append("${days}일")
+ if (hours > 0L) append(" ${hours}시간")
+ if (minutes > 0L) append(" ${minutes}분")
+ }.trim()
+ }
+
+ private fun LocalDateTime.toCalendar(): GregorianCalendar {
+ return GregorianCalendar(
+ year,
+ monthValue,
+ dayOfMonth,
+ hour,
+ minute,
+ second,
+ )
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
index 8333f2f5b..dba80674e 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDetailViewModel.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.AuctionDetailModel
import com.ddangddangddang.android.model.mapper.AuctionDetailModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
@@ -12,9 +13,12 @@ import com.ddangddangddang.data.model.request.GetChatRoomIdRequest
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
import com.ddangddangddang.data.repository.ChatRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class AuctionDetailViewModel(
+@HiltViewModel
+class AuctionDetailViewModel @Inject constructor(
private val auctionRepository: AuctionRepository,
private val chatRepository: ChatRepository,
) : ViewModel() {
@@ -22,6 +26,12 @@ class AuctionDetailViewModel(
val event: LiveData
get() = _event
+ private val _isLoadingWithAnimation: MutableLiveData = MutableLiveData()
+ val isLoadingWithAnimation: LiveData
+ get() = _isLoadingWithAnimation
+
+ private var isLoadingWithoutAnimation: Boolean = false
+
private val _auctionDetailModel: MutableLiveData = MutableLiveData()
val auctionDetailModel: LiveData
get() = _auctionDetailModel
@@ -39,18 +49,29 @@ class AuctionDetailViewModel(
}
fun loadAuctionDetail(auctionId: Long) {
+ if (_isLoadingWithAnimation.value == true) return
+ _isLoadingWithAnimation.value = true
viewModelScope.launch {
when (val response = auctionRepository.getAuctionDetail(auctionId)) {
is ApiResponse.Success -> _auctionDetailModel.value = response.body.toPresentation()
is ApiResponse.Failure -> {
if (response.responseCode == 404) {
_event.value = AuctionDetailEvent.NotifyAuctionDoesNotExist
+ } else {
+ _event.value =
+ AuctionDetailEvent.AuctionLoadFailure(ErrorType.FAILURE(response.error))
}
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value = AuctionDetailEvent.AuctionLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = AuctionDetailEvent.AuctionLoadFailure(ErrorType.UNEXPECTED)
+ }
}
+ _isLoadingWithAnimation.value = false
}
}
@@ -73,6 +94,8 @@ class AuctionDetailViewModel(
private fun enterChatRoomEvent() {
_auctionDetailModel.value?.let {
+ if (isLoadingWithoutAnimation) return@let
+ isLoadingWithoutAnimation = true
viewModelScope.launch {
val request = GetChatRoomIdRequest(it.id)
when (val response = chatRepository.getChatRoomId(request)) {
@@ -80,14 +103,32 @@ class AuctionDetailViewModel(
_event.value = AuctionDetailEvent.EnterMessageRoom(response.body.chatRoomId)
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ AuctionDetailEvent.EnterChatLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ AuctionDetailEvent.EnterChatLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = AuctionDetailEvent.EnterChatLoadFailure(ErrorType.UNEXPECTED)
+ }
}
+ isLoadingWithoutAnimation = false
}
}
}
+ fun navigateToImageDetail(image: String) {
+ val images = auctionDetailModel.value?.images ?: return
+ val focusPosition = images.indexOf(image)
+ if (focusPosition == -1) return
+ _event.value = AuctionDetailEvent.NavigateToImageDetail(images, focusPosition)
+ }
+
fun setExitEvent() {
_event.value = AuctionDetailEvent.Exit
}
@@ -106,16 +147,28 @@ class AuctionDetailViewModel(
fun deleteAuction() {
_auctionDetailModel.value?.let {
+ if (isLoadingWithoutAnimation) return@let
+ isLoadingWithoutAnimation = true
viewModelScope.launch {
- when (auctionRepository.deleteAuction(it.id)) {
+ when (val response = auctionRepository.deleteAuction(it.id)) {
is ApiResponse.Success ->
+ _event.value = AuctionDetailEvent.NotifyAuctionDeletionComplete
+
+ is ApiResponse.Failure -> {
_event.value =
- AuctionDetailEvent.NotifyAuctionDeletionComplete
+ AuctionDetailEvent.AuctionDeleteFailure(ErrorType.FAILURE(response.error))
+ }
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ AuctionDetailEvent.AuctionDeleteFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = AuctionDetailEvent.AuctionDeleteFailure(ErrorType.UNEXPECTED)
+ }
}
+ isLoadingWithoutAnimation = false
}
}
}
@@ -125,8 +178,14 @@ class AuctionDetailViewModel(
object PopupAuctionBid : AuctionDetailEvent()
data class EnterMessageRoom(val roomId: Long) : AuctionDetailEvent()
data class ReportAuction(val auctionId: Long) : AuctionDetailEvent()
+ data class NavigateToImageDetail(val images: List, val focusPosition: Int) :
+ AuctionDetailEvent()
+
object DeleteAuction : AuctionDetailEvent()
object NotifyAuctionDoesNotExist : AuctionDetailEvent()
object NotifyAuctionDeletionComplete : AuctionDetailEvent()
+ data class AuctionLoadFailure(val error: ErrorType) : AuctionDetailEvent()
+ data class EnterChatLoadFailure(val error: ErrorType) : AuctionDetailEvent()
+ data class AuctionDeleteFailure(val error: ErrorType) : AuctionDetailEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageAdapter.kt
index f7db5b9df..55ee3fe24 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageAdapter.kt
@@ -5,9 +5,10 @@ import androidx.recyclerview.widget.RecyclerView
class AuctionImageAdapter(
private var images: List,
+ private val onImageClick: (image: String) -> Unit = {},
) : RecyclerView.Adapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuctionImageViewHolder {
- return AuctionImageViewHolder.create(parent)
+ return AuctionImageViewHolder.create(parent, onImageClick)
}
override fun onBindViewHolder(holder: AuctionImageViewHolder, position: Int) {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageViewHolder.kt
index 661f8f74a..aa80168ec 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionImageViewHolder.kt
@@ -5,17 +5,26 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.databinding.ItemDetailAuctionBinding
-class AuctionImageViewHolder private constructor(private val binding: ItemDetailAuctionBinding) :
- RecyclerView.ViewHolder(binding.root) {
+class AuctionImageViewHolder private constructor(
+ private val binding: ItemDetailAuctionBinding,
+ onImageClick: (image: String) -> Unit = {},
+) : RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.onImageClickListener = onImageClick
+ }
+
fun bind(imageUrl: String) {
binding.imageUrl = imageUrl
}
companion object {
- fun create(parent: ViewGroup): AuctionImageViewHolder {
+ fun create(
+ parent: ViewGroup,
+ onImageClick: (image: String) -> Unit = {},
+ ): AuctionImageViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ItemDetailAuctionBinding.inflate(layoutInflater, parent, false)
- return AuctionImageViewHolder(binding)
+ return AuctionImageViewHolder(binding, onImageClick)
}
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
index 0423e06bc..2a40744e1 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidDialog.kt
@@ -9,21 +9,24 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentAuctionBidDialogBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
import com.ddangddangddang.android.util.view.Toaster
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class AuctionBidDialog : DialogFragment() {
private var _binding: FragmentAuctionBidDialogBinding? = null
private val binding: FragmentAuctionBidDialogBinding
get() = _binding!!
- private val viewModel: AuctionBidViewModel by viewModels { viewModelFactory }
- private val activityViewModel: AuctionDetailViewModel by activityViewModels { viewModelFactory }
+ private val viewModel: AuctionBidViewModel by viewModels()
+ private val activityViewModel: AuctionDetailViewModel by activityViewModels()
private val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@@ -84,14 +87,10 @@ class AuctionBidDialog : DialogFragment() {
when (event) {
is AuctionBidViewModel.AuctionBidEvent.Cancel -> exit()
is AuctionBidViewModel.AuctionBidEvent.SubmitSuccess -> submitSuccess(event.price)
- is AuctionBidViewModel.AuctionBidEvent.SubmitFailureCustomEvent -> {
- showMessage(event.message)
- exit()
- }
is AuctionBidViewModel.AuctionBidEvent.UnderPrice -> notifyUnderPriceSubmitFailed()
- is AuctionBidViewModel.AuctionBidEvent.SubmitFailureEvent -> handleSubmitFailureEvent(
- event,
- )
+ is AuctionBidViewModel.AuctionBidEvent.FailureSubmitEvent -> {
+ handleSubmitFailureEvent(event.type)
+ }
}
}
@@ -104,8 +103,9 @@ class AuctionBidDialog : DialogFragment() {
exit()
}
- private fun handleSubmitFailureEvent(event: AuctionBidViewModel.AuctionBidEvent.SubmitFailureEvent) {
- showMessage(getString(event.messageId))
+ private fun handleSubmitFailureEvent(errorType: ErrorType) {
+ val defaultMessage = getString(R.string.detail_auction_bid_dialog_failure_default_message)
+ Toaster.showShort(requireContext(), errorType.message ?: defaultMessage)
exit()
}
@@ -134,4 +134,12 @@ class AuctionBidDialog : DialogFragment() {
super.onDestroyView()
_binding = null
}
+
+ companion object {
+ private const val BID_DIALOG_TAG = "bid_dialog_tag"
+
+ fun show(fragmentManager: FragmentManager) {
+ AuctionBidDialog().show(fragmentManager, BID_DIALOG_TAG)
+ }
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
index 74bd01824..ec288ec97 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/AuctionBidViewModel.kt
@@ -1,19 +1,20 @@
package com.ddangddangddang.android.feature.detail.bid
-import androidx.annotation.StringRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.ddangddangddang.android.R
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
-import org.json.JSONObject
import java.math.BigInteger
+import javax.inject.Inject
-class AuctionBidViewModel(
+@HiltViewModel
+class AuctionBidViewModel @Inject constructor(
private val repository: AuctionRepository,
) : ViewModel() {
private val _event: SingleLiveEvent = SingleLiveEvent()
@@ -28,14 +29,18 @@ class AuctionBidViewModel(
_bidPrice.value = price
}
- fun changeInputPriceText(string: String) {
- val originalValue = string.replace(",", "") // 문자열 내 들어있는 콤마를 모두 제거
+ fun changeInputPriceText(text: String) {
+ setBidPrice(convertStringPriceToInt(text)) // 파싱에 성공한 금액으로 설정
+ }
+
+ private fun convertStringPriceToInt(text: String): Int {
+ val originalValue = text.replace(",", "") // 문자열 내 들어있는 콤마를 모두 제거
val priceValue = originalValue.substringBefore(SUFFIX_INPUT_PRICE) // " 원"
val parsedValue =
- priceValue.toBigIntegerOrNull() ?: return setBidPrice(ZERO) // 입력에 문자가 섞인 경우
+ priceValue.toBigIntegerOrNull() ?: return ZERO // 입력에 문자가 섞인 경우
- if (parsedValue.isOverMaxPrice()) return setBidPrice(MAX_PRICE)
- setBidPrice(parsedValue.toInt()) // 파싱에 성공한 금액으로 설정
+ if (parsedValue.isOverMaxPrice()) return MAX_PRICE
+ return parsedValue.toInt()
}
private fun BigInteger.isOverMaxPrice(): Boolean {
@@ -53,18 +58,19 @@ class AuctionBidViewModel(
when (val response = repository.submitAuctionBid(auctionId, bidPrice)) {
is ApiResponse.Success -> _event.value = AuctionBidEvent.SubmitSuccess(bidPrice)
is ApiResponse.Failure -> {
- val jsonObject = JSONObject(response.error)
- val message = jsonObject.getString("message")
- val errorType = SubmitBidFailureResponse.find(message)
- if (errorType == SubmitBidFailureResponse.ELSE) {
- _event.value = AuctionBidEvent.SubmitFailureCustomEvent(message)
- } else {
- handleSubmitBidFailure(errorType)
- }
+ _event.value =
+ AuctionBidEvent.FailureSubmitEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ AuctionBidEvent.FailureSubmitEvent(ErrorType.NETWORK_ERROR)
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ AuctionBidEvent.FailureSubmitEvent(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -73,34 +79,6 @@ class AuctionBidViewModel(
return bidPrice < minBidPrice
}
- private fun handleSubmitBidFailure(failure: SubmitBidFailureResponse) {
- when (failure) {
- SubmitBidFailureResponse.FINISH -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.Finish
- }
-
- SubmitBidFailureResponse.DELETED -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.Deleted
- }
-
- SubmitBidFailureResponse.EXIST_HIGHER_BIDDER -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.ExistHigherBidder
- }
-
- SubmitBidFailureResponse.ALREADY_HIGHEST_BIDDER -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.AlreadyHighestBidder
- }
-
- SubmitBidFailureResponse.SELLER_CAN_NOT_BID -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.SellerCanNotBid
- }
-
- SubmitBidFailureResponse.ELSE -> {
- _event.value = AuctionBidEvent.SubmitFailureEvent.Unknown
- }
- }
- }
-
private fun setUnderPriceEvent() {
_event.value = AuctionBidEvent.UnderPrice
}
@@ -109,21 +87,7 @@ class AuctionBidViewModel(
object Cancel : AuctionBidEvent()
object UnderPrice : AuctionBidEvent()
data class SubmitSuccess(val price: Int) : AuctionBidEvent()
- data class SubmitFailureCustomEvent(val message: String) : AuctionBidEvent()
- sealed class SubmitFailureEvent(@StringRes val messageId: Int) : AuctionBidEvent() {
- object Finish : SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_finish)
- object Deleted : SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_deleted)
- object ExistHigherBidder :
- SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_already_exist_higher_bidder)
-
- object AlreadyHighestBidder :
- SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_already_highest_bidder)
-
- object SellerCanNotBid :
- SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_seller_can_not_bid)
-
- object Unknown : SubmitFailureEvent(R.string.detail_auction_bid_dialog_failure_else)
- }
+ data class FailureSubmitEvent(val type: ErrorType) : AuctionBidEvent()
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/SubmitBidFailureResponse.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/SubmitBidFailureResponse.kt
deleted file mode 100644
index 352e9d450..000000000
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/bid/SubmitBidFailureResponse.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.ddangddangddang.android.feature.detail.bid
-
-enum class SubmitBidFailureResponse(private val message: String) {
- FINISH("이미 종료된 경매입니다"),
- DELETED("삭제된 경매입니다"),
- EXIST_HIGHER_BIDDER("가능 입찰액보다 낮은 금액을 입력했습니다"),
- ALREADY_HIGHEST_BIDDER("이미 최고 입찰자입니다"),
- SELLER_CAN_NOT_BID("판매자는 입찰할 수 없습니다"),
- ELSE("기타"),
- ;
-
- companion object {
- fun find(message: String?): SubmitBidFailureResponse {
- return values().find { it.message == message } ?: ELSE
- }
- }
-}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionViewHolder.kt
similarity index 93%
rename from android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionViewHolder.kt
rename to android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionViewHolder.kt
index 558f714b4..09609d036 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionViewHolder.kt
@@ -1,4 +1,4 @@
-package com.ddangddangddang.android.feature.detail
+package com.ddangddangddang.android.feature.detail.info
import android.view.LayoutInflater
import android.view.ViewGroup
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionsAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionsAdapter.kt
similarity index 92%
rename from android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionsAdapter.kt
rename to android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionsAdapter.kt
index 5ea4cc15b..46675d9e1 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/AuctionDirectRegionsAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionDirectRegionsAdapter.kt
@@ -1,4 +1,4 @@
-package com.ddangddangddang.android.feature.detail
+package com.ddangddangddang.android.feature.detail.info
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionInfoFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionInfoFragment.kt
new file mode 100644
index 000000000..3d0819a4c
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/detail/info/AuctionInfoFragment.kt
@@ -0,0 +1,33 @@
+package com.ddangddangddang.android.feature.detail.info
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentAuctionInfoBinding
+import com.ddangddangddang.android.feature.detail.AuctionDetailViewModel
+import com.ddangddangddang.android.model.RegionModel
+import com.ddangddangddang.android.util.binding.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class AuctionInfoFragment :
+ BindingFragment(R.layout.fragment_auction_info) {
+ private val activityViewModel: AuctionDetailViewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.activityViewModel = activityViewModel
+ setupViewModel()
+ }
+
+ private fun setupViewModel() {
+ activityViewModel.auctionDetailModel.observe(viewLifecycleOwner) {
+ setupDirectRegions(it.directRegions)
+ }
+ }
+
+ private fun setupDirectRegions(regions: List) {
+ binding.rvDirectExchangeRegions.adapter = AuctionDirectRegionsAdapter(regions)
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionAdapter.kt
index 8f10bd91a..624d46e3e 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionAdapter.kt
@@ -7,6 +7,11 @@ import com.ddangddangddang.android.model.AuctionHomeModel
class AuctionAdapter(private val onItemClick: (Long) -> Unit) :
ListAdapter(AuctionDiffUtil) {
+
+ fun setAuctions(list: List, callback: (() -> Unit)? = null) {
+ submitList(list, callback)
+ }
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuctionViewHolder {
return AuctionViewHolder.create(parent, onItemClick)
}
@@ -15,10 +20,6 @@ class AuctionAdapter(private val onItemClick: (Long) -> Unit) :
holder.bind(currentList[position])
}
- fun setAuctions(list: List) {
- submitList(list)
- }
-
companion object {
private val AuctionDiffUtil = object : DiffUtil.ItemCallback() {
override fun areItemsTheSame(
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionSpaceItemDecoration.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionSpaceItemDecoration.kt
index cdccf7585..85d7800ed 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionSpaceItemDecoration.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/home/AuctionSpaceItemDecoration.kt
@@ -15,7 +15,7 @@ class AuctionSpaceItemDecoration(private val spanCount: Int, private val space:
val position = parent.getChildAdapterPosition(view)
val column = position % spanCount // 1부터 시작
- if (position < spanCount) outRect.top = space
+ outRect.top = space / 2
if (column == 0) {
outRect.left = space
outRect.right = space / 2
@@ -23,6 +23,6 @@ class AuctionSpaceItemDecoration(private val spanCount: Int, private val space:
outRect.left = space / 2
outRect.right = space
}
- outRect.bottom = space
+ outRect.bottom = space / 2
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
index f0cba1e79..fd884a87a 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeFragment.kt
@@ -8,13 +8,15 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentHomeBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
import com.ddangddangddang.android.feature.register.RegisterAuctionActivity
import com.ddangddangddang.android.util.binding.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class HomeFragment : BindingFragment(R.layout.fragment_home) {
- private val viewModel: HomeViewModel by viewModels { viewModelFactory }
+ private val viewModel: HomeViewModel by viewModels()
private val auctionScrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
@@ -25,7 +27,7 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
val lastVisibleItemPosition =
(binding.rvAuction.layoutManager as GridLayoutManager).findLastCompletelyVisibleItemPosition()
val auctionsSize = viewModel.auctions.value?.size ?: 0
- if (lastVisibleItemPosition + 5 >= auctionsSize) {
+ if (lastVisibleItemPosition + 10 >= auctionsSize) {
viewModel.loadAuctions()
}
}
@@ -40,14 +42,16 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
binding.viewModel = viewModel
setupViewModel()
setupAuctionRecyclerView()
- if (viewModel.lastAuctionId.value == null) {
- viewModel.loadAuctions()
- }
+ if (viewModel.page == 0) viewModel.loadAuctions()
setupReloadAuctions()
}
private fun setupViewModel() {
- viewModel.auctions.observe(viewLifecycleOwner) { auctionAdapter.setAuctions(it) }
+ viewModel.auctions.observe(viewLifecycleOwner) {
+ auctionAdapter.setAuctions(it) {
+ if (viewModel.page == 1) binding.rvAuction.scrollToPosition(0)
+ }
+ }
viewModel.event.observe(viewLifecycleOwner) { handleEvent(it) }
}
@@ -60,6 +64,13 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
is HomeViewModel.HomeEvent.NavigateToRegisterAuction -> {
navigateToRegisterAuction()
}
+
+ is HomeViewModel.HomeEvent.FailureLoadAuctions -> {
+ requireActivity().notifyFailureMessage(
+ event.errorType,
+ R.string.home_default_error_message,
+ )
+ }
}
}
@@ -77,7 +88,9 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
private fun setupAuctionRecyclerView() {
with(binding.rvAuction) {
adapter = auctionAdapter
- addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = 20))
+
+ val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
+ addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
addOnScrollListener(auctionScrollListener)
}
}
@@ -88,4 +101,14 @@ class HomeFragment : BindingFragment(R.layout.fragment_home
binding.srlReloadAuctions.isRefreshing = false
}
}
+
+ fun scrollToTop() {
+ val position =
+ (binding.rvAuction.layoutManager as GridLayoutManager).findLastCompletelyVisibleItemPosition()
+ if (position < 30) {
+ binding.rvAuction.smoothScrollToPosition(0)
+ } else {
+ binding.rvAuction.scrollToPosition(0)
+ }
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeViewModel.kt
index e41a82ef7..e0d1ed875 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/home/HomeViewModel.kt
@@ -1,30 +1,36 @@
package com.ddangddangddang.android.feature.home
import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.AuctionHomeModel
import com.ddangddangddang.android.model.mapper.AuctionHomeModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.SortType
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class HomeViewModel(private val repository: AuctionRepository) : ViewModel() {
+@HiltViewModel
+class HomeViewModel @Inject constructor(private val repository: AuctionRepository) : ViewModel() {
val auctions: LiveData> =
repository.observeAuctionPreviews().map { auctionPreviews ->
- lastAuctionId.value = auctionPreviews.lastOrNull()?.id
auctionPreviews.map { it.toPresentation() }
}
- val lastAuctionId: MutableLiveData = MutableLiveData()
-
private var _loadingAuctionsInProgress: Boolean = false
val loadingAuctionInProgress: Boolean
get() = _loadingAuctionsInProgress
+ private var sortType: SortType = SortType.NEW
+ private var _page = 0
+ val page: Int
+ get() = _page
+
private var _isLast = false
val isLast: Boolean
get() = _isLast
@@ -34,22 +40,7 @@ class HomeViewModel(private val repository: AuctionRepository) : ViewModel() {
get() = _event
fun loadAuctions() {
- _loadingAuctionsInProgress = true
- viewModelScope.launch {
- when (
- val response =
- repository.getAuctionPreviews(lastAuctionId.value, SIZE_AUCTION_LOAD)
- ) {
- is ApiResponse.Success -> {
- _isLast = response.body.isLast
- }
-
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
- }
- _loadingAuctionsInProgress = false
- }
+ if (!loadingAuctionInProgress) fetchAuctions(_page + 1)
}
fun navigateToAuctionDetail(auctionId: Long) {
@@ -61,32 +52,55 @@ class HomeViewModel(private val repository: AuctionRepository) : ViewModel() {
}
fun reloadAuctions() {
- if (loadingAuctionInProgress.not()) {
+ if (!loadingAuctionInProgress) fetchAuctions(DEFAULT_PAGE)
+ }
+
+ private fun fetchAuctions(newPage: Int) {
+ viewModelScope.launch {
_loadingAuctionsInProgress = true
- viewModelScope.launch {
- when (
- val response =
- repository.getAuctionPreviews(null, SIZE_AUCTION_LOAD)
- ) {
- is ApiResponse.Success -> {
- _isLast = response.body.isLast
- }
-
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ when (
+ val response =
+ repository.getAuctionPreviews(
+ page = newPage,
+ size = SIZE_AUCTION_LOAD,
+ sortType = sortType,
+ )
+ ) {
+ is ApiResponse.Success -> {
+ _isLast = response.body.isLast
+ _page = newPage
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = HomeEvent.FailureLoadAuctions(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = HomeEvent.FailureLoadAuctions(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = HomeEvent.FailureLoadAuctions(ErrorType.UNEXPECTED)
}
- _loadingAuctionsInProgress = false
}
+ _loadingAuctionsInProgress = false
}
}
+ fun changeFilter(type: SortType) {
+ sortType = type
+ reloadAuctions()
+ }
+
sealed class HomeEvent {
data class NavigateToAuctionDetail(val auctionId: Long) : HomeEvent()
object NavigateToRegisterAuction : HomeEvent()
+
+ data class FailureLoadAuctions(val errorType: ErrorType) : HomeEvent()
}
companion object {
- private val SIZE_AUCTION_LOAD = 10
+ private const val SIZE_AUCTION_LOAD = 20
+ private const val DEFAULT_PAGE = 1
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailActivity.kt
new file mode 100644
index 000000000..6eb93b51e
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailActivity.kt
@@ -0,0 +1,75 @@
+package com.ddangddangddang.android.feature.imageDetail
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ActivityImageDetailBinding
+import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.Toaster
+import com.google.android.material.tabs.TabLayoutMediator
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ImageDetailActivity :
+ BindingActivity(R.layout.activity_image_detail) {
+ private val viewModel: ImageDetailViewModel by viewModels()
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.viewModel = viewModel
+ if (viewModel.images.value == null) viewModel.setImages(getImageUrls(), getImageFocus())
+ setupViewModel()
+ }
+
+ private fun setupViewModel() {
+ viewModel.images.observe(this) { setupImages(it) }
+ viewModel.initPosition.observe(this) {
+ binding.vpImageList.setCurrentItem(it, false)
+ }
+ viewModel.event.observe(this) { handleEvent(it) }
+ }
+
+ private fun handleEvent(event: ImageDetailViewModel.Event) {
+ when (event) {
+ ImageDetailViewModel.Event.Exit -> finish()
+ }
+ }
+
+ private fun getImageUrls(): List {
+ val images = intent.getStringArrayExtra(IMAGE_URL_KEY) ?: emptyArray()
+ if (images.isEmpty()) {
+ notifyNotExistImages()
+ finish()
+ }
+ return images.toList()
+ }
+
+ private fun getImageFocus(): Int {
+ return intent.getIntExtra(FOCUS_IMAGE_POSITION_KEY, 0)
+ }
+
+ private fun notifyNotExistImages() {
+ Toaster.showShort(this, getString(R.string.image_detail_images_not_exist))
+ }
+
+ private fun setupImages(images: List) {
+ binding.vpImageList.apply {
+ offscreenPageLimit = 1
+ adapter = ImageDetailAdapter(images)
+ }
+
+ TabLayoutMediator(binding.tlIndicator, binding.vpImageList) { _, _ -> }.attach()
+ }
+
+ companion object {
+ private const val IMAGE_URL_KEY = "image_url_key"
+ private const val FOCUS_IMAGE_POSITION_KEY = "focus_image_position_key"
+ fun getIntent(context: Context, images: List, focusPosition: Int): Intent {
+ return Intent(context, ImageDetailActivity::class.java).apply {
+ putExtra(FOCUS_IMAGE_POSITION_KEY, focusPosition)
+ putExtra(IMAGE_URL_KEY, images.toTypedArray())
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailAdapter.kt
new file mode 100644
index 000000000..f7b9b8f93
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailAdapter.kt
@@ -0,0 +1,18 @@
+package com.ddangddangddang.android.feature.imageDetail
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+
+class ImageDetailAdapter(
+ private var images: List,
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageDetailViewHolder {
+ return ImageDetailViewHolder.create(parent)
+ }
+
+ override fun onBindViewHolder(holder: ImageDetailViewHolder, position: Int) {
+ holder.bind(images[position])
+ }
+
+ override fun getItemCount(): Int = images.size
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewHolder.kt
new file mode 100644
index 000000000..f0ba992ea
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewHolder.kt
@@ -0,0 +1,25 @@
+package com.ddangddangddang.android.feature.imageDetail
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.ddangddangddang.android.databinding.ItemDetailImageBinding
+
+class ImageDetailViewHolder private constructor(
+ private val binding: ItemDetailImageBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(imageUrl: String) {
+ binding.imageUrl = imageUrl
+ }
+
+ companion object {
+ fun create(
+ parent: ViewGroup,
+ ): ImageDetailViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding = ItemDetailImageBinding.inflate(layoutInflater, parent, false)
+ return ImageDetailViewHolder(binding)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewModel.kt
new file mode 100644
index 000000000..b850d6b34
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/imageDetail/ImageDetailViewModel.kt
@@ -0,0 +1,36 @@
+package com.ddangddangddang.android.feature.imageDetail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+@HiltViewModel
+class ImageDetailViewModel @Inject constructor() : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: SingleLiveEvent
+ get() = _event
+
+ private val _images: MutableLiveData> = MutableLiveData()
+ val images: LiveData>
+ get() = _images
+
+ private val _initPosition: SingleLiveEvent = SingleLiveEvent()
+ val initPosition: LiveData
+ get() = _initPosition
+
+ fun setImages(images: List, focusPosition: Int) {
+ _images.value = images
+ _initPosition.value = focusPosition
+ }
+
+ fun setExitEvent() {
+ _event.value = Event.Exit
+ }
+
+ sealed class Event {
+ object Exit : Event()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
index 5a140eff7..4ea9824df 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginActivity.kt
@@ -6,7 +6,7 @@ import android.util.Log
import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityLoginBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.main.MainActivity
import com.ddangddangddang.android.global.AnalyticsDelegate
import com.ddangddangddang.android.global.AnalyticsDelegateImpl
@@ -16,11 +16,13 @@ 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 dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class LoginActivity :
BindingActivity(R.layout.activity_login),
AnalyticsDelegate by AnalyticsDelegateImpl() {
- private val viewModel: LoginViewModel by viewModels { viewModelFactory }
+ private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -33,9 +35,9 @@ class LoginActivity :
private fun setupViewModel() {
viewModel.event.observe(this) {
when (it) {
- LoginViewModel.LoginEvent.KakaoLoginEvent -> loginByKakao()
- LoginViewModel.LoginEvent.CompleteLoginEvent -> navigateToMain()
- LoginViewModel.LoginEvent.FailureLoginEvent -> notifyLoginFailed()
+ is LoginViewModel.LoginEvent.KakaoLoginEvent -> loginByKakao()
+ is LoginViewModel.LoginEvent.CompleteLoginEvent -> navigateToMain()
+ is LoginViewModel.LoginEvent.FailureLoginEvent -> notifyLoginFailed(it.type)
}
}
}
@@ -86,7 +88,12 @@ class LoginActivity :
finish()
}
- private fun notifyLoginFailed() {
- binding.root.showSnackbar(R.string.login_snackbar_login_failed_title)
+ private fun notifyLoginFailed(type: ErrorType) {
+ val defaultMessage = getString(R.string.login_snackbar_login_failed_title)
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = type.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
index 4be2e8aaa..728a4a9ce 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/login/LoginViewModel.kt
@@ -3,13 +3,17 @@ package com.ddangddangddang.android.feature.login
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.model.request.KakaoLoginRequest
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuthRepositoryImpl
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class LoginViewModel(
+@HiltViewModel
+class LoginViewModel @Inject constructor(
private val repository: AuthRepositoryImpl,
) : ViewModel() {
private val _event: SingleLiveEvent = SingleLiveEvent()
@@ -22,10 +26,18 @@ class LoginViewModel(
fun completeLoginByKakao(accessToken: String) {
viewModelScope.launch {
- val kakaoToken = KakaoLoginRequest(accessToken)
- when (repository.loginByKakao(kakaoToken)) {
+ val deviceToken = repository.getDeviceToken()
+ if (deviceToken.isNullOrBlank()) {
+ LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
+ return@launch
+ }
+
+ val request = KakaoLoginRequest(accessToken, deviceToken)
+ when (val response = repository.loginByKakao(request)) {
is ApiResponse.Success -> _event.value = LoginEvent.CompleteLoginEvent
- else -> _event.value = LoginEvent.FailureLoginEvent
+ is ApiResponse.Failure -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.FAILURE(response.error))
+ is ApiResponse.NetworkError -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.NETWORK_ERROR)
+ is ApiResponse.Unexpected -> _event.value = LoginEvent.FailureLoginEvent(ErrorType.UNEXPECTED)
}
}
}
@@ -33,6 +45,6 @@ class LoginViewModel(
sealed class LoginEvent {
object KakaoLoginEvent : LoginEvent()
object CompleteLoginEvent : LoginEvent()
- object FailureLoginEvent : LoginEvent()
+ data class FailureLoginEvent(val type: ErrorType) : LoginEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
index 75f78d0ad..dac8e77a6 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainActivity.kt
@@ -1,27 +1,68 @@
package com.ddangddangddang.android.feature.main
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
+import android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS
+import android.provider.Settings.EXTRA_APP_PACKAGE
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityMainBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
import com.ddangddangddang.android.feature.home.HomeFragment
import com.ddangddangddang.android.feature.message.MessageFragment
import com.ddangddangddang.android.feature.mypage.MyPageFragment
import com.ddangddangddang.android.feature.search.SearchFragment
import com.ddangddangddang.android.global.screenViewLogEvent
import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.BackKeyHandler
+import com.ddangddangddang.android.util.view.showDialog
+import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MainActivity : BindingActivity(R.layout.activity_main) {
- private val viewModel by viewModels { viewModelFactory }
+ private val viewModel: MainViewModel by viewModels()
private var isInitialized = false
+ private val backKeyHandler = BackKeyHandler(this)
+
+ private val callback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ backKeyHandler.onBackPressed()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private val requestNotificationPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ // FCM SDK (and your app) can post notifications.
+ } else {
+ if (shouldShowHowToGrantNotificationPermission()) {
+ binding.root.showSnackbar(R.string.alarm_snackbar_how_to_grant_permission)
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private fun shouldShowHowToGrantNotificationPermission(): Boolean =
+ shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.viewModel = viewModel
setupViewModel()
+ askNotificationPermission()
+ onBackPressedDispatcher.addCallback(this, callback)
}
override fun onResume() {
@@ -34,12 +75,25 @@ class MainActivity : BindingActivity(R.layout.activity_main
}
private fun setupViewModel() {
+ viewModel.event.observe(this) { handleEvent(it) }
viewModel.currentFragmentType.observe(this) {
changeFragment(it)
screenViewLogEvent(it.name)
}
}
+ private fun handleEvent(event: MainViewModel.MainEvent) {
+ when (event) {
+ MainViewModel.MainEvent.HomeToTop -> scrollHomeToTop()
+ }
+ }
+
+ private fun scrollHomeToTop() {
+ val homeFragment =
+ supportFragmentManager.findFragmentByTag(FragmentType.HOME.tag) as? HomeFragment
+ homeFragment?.scrollToTop()
+ }
+
private fun changeFragment(type: FragmentType) {
supportFragmentManager.commit {
setReorderingAllowed(true)
@@ -62,4 +116,52 @@ class MainActivity : BindingActivity(R.layout.activity_main
FragmentType.MY_PAGE -> MyPageFragment()
}
}
+
+ private fun askNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ // FCM SDK (and your app) can post notifications.
+ } else if (!shouldShowHowToGrantNotificationPermission()) {
+ showRequestAlarmPermissionRationale()
+ }
+ } else {
+ val notificationManager = NotificationManagerCompat.from(this)
+ if (!notificationManager.areNotificationsEnabled()) {
+ showRequestAlarmPermissionRationale()
+ }
+ }
+ }
+
+ private fun showRequestAlarmPermissionRationale() {
+ showDialog(
+ titleId = R.string.alarm_dialog_check_permission_title,
+ messageId = R.string.alarm_dialog_check_permission_message,
+ positiveStringId = R.string.alarm_dialog_check_permission_positive_button,
+ actionPositive = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ requestNotificationPermissionForUnderTiramisu()
+ }
+ },
+ isCancelable = false,
+ )
+ }
+
+ private fun requestNotificationPermissionForUnderTiramisu() {
+ showDialog(
+ titleId = R.string.alarm_dialog_check_permission_title,
+ messageId = R.string.alarm_dialog_check_permission_under_tiramisu_message,
+ negativeStringId = R.string.all_dialog_default_negative_button,
+ positiveStringId = R.string.alarm_dialog_check_permission_under_tiramisu_positive_button,
+ actionPositive = ::openNotificationSettings,
+ isCancelable = false,
+ )
+ }
+
+ private fun openNotificationSettings() {
+ val intent = Intent(ACTION_APP_NOTIFICATION_SETTINGS)
+ intent.putExtra(EXTRA_APP_PACKAGE, this.packageName)
+ startActivity(intent)
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
index f1a108d98..74250ea8d 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainBindingAdapter.kt
@@ -1,12 +1,22 @@
package com.ddangddangddang.android.feature.main
import androidx.databinding.BindingAdapter
+import com.ddangddangddang.android.R
import com.google.android.material.bottomnavigation.BottomNavigationView
-import com.google.android.material.navigation.NavigationBarView
@BindingAdapter("onNavigationItemSelected")
fun BottomNavigationView.bindOnNavigationItemSelectedListener(
- listener: NavigationBarView.OnItemSelectedListener,
+ onFragmentChange: (FragmentType) -> Unit,
) {
- this.setOnItemSelectedListener(listener)
+ this.setOnItemSelectedListener { menuItem ->
+ val fragmentType = when (menuItem.itemId) {
+ R.id.menu_item_home -> FragmentType.HOME
+ R.id.menu_item_search -> FragmentType.SEARCH
+ R.id.menu_item_message -> FragmentType.MESSAGE
+ R.id.menu_item_my_page -> FragmentType.MY_PAGE
+ else -> throw IllegalArgumentException("Not found menu item")
+ }
+ onFragmentChange(fragmentType)
+ true
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
index d5ac8e0fe..e07cc8cf3 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/main/MainViewModel.kt
@@ -1,38 +1,37 @@
package com.ddangddangddang.android.feature.main
-import android.view.MenuItem
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
-import com.ddangddangddang.android.R
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
-class MainViewModel : ViewModel() {
+@HiltViewModel
+class MainViewModel @Inject constructor() : ViewModel() {
private val _currentFragmentType: MutableLiveData =
MutableLiveData(FragmentType.HOME)
val currentFragmentType: LiveData
get() = _currentFragmentType
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
- fun setCurrentFragment(item: MenuItem): Boolean {
- val menuItemId = item.itemId
- val pageType = getPageType(menuItemId)
- changeCurrentFragmentType(pageType)
-
- return true
- }
-
- private fun getPageType(menuItemId: Int): FragmentType {
- return when (menuItemId) {
- R.id.menu_item_home -> FragmentType.HOME
- R.id.menu_item_search -> FragmentType.SEARCH
- R.id.menu_item_message -> FragmentType.MESSAGE
- R.id.menu_item_my_page -> FragmentType.MY_PAGE
- else -> throw IllegalArgumentException("Not found menu item")
- }
+ val fragmentChange = { fragmentType: FragmentType ->
+ changeCurrentFragmentType(fragmentType)
}
private fun changeCurrentFragmentType(fragmentType: FragmentType) {
- if (currentFragmentType.value == fragmentType) return
+ if (currentFragmentType.value == fragmentType) {
+ if (fragmentType == FragmentType.HOME) {
+ _event.value = MainEvent.HomeToTop
+ }
+ }
_currentFragmentType.value = fragmentType
}
+
+ sealed class MainEvent {
+ object HomeToTop : MainEvent()
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
index a0acec049..2b4744688 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageFragment.kt
@@ -7,12 +7,14 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentMessageBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
import com.ddangddangddang.android.util.binding.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MessageFragment : BindingFragment(R.layout.fragment_message) {
- private val viewModel: MessageViewModel by viewModels { viewModelFactory }
+ private val viewModel: MessageViewModel by viewModels()
private val messageRoomAdapter: MessageRoomAdapter = MessageRoomAdapter { roomId ->
viewModel.navigateToMessageRoom(roomId)
}
@@ -49,6 +51,12 @@ class MessageFragment : BindingFragment(R.layout.fragmen
private fun handleEvent(event: MessageViewModel.MessageEvent) {
when (event) {
is MessageViewModel.MessageEvent.NavigateToMessageRoom -> navigateToMessageRoom(event.roomId)
+ is MessageViewModel.MessageEvent.MessageLoadFailure -> {
+ requireActivity().notifyFailureMessage(
+ event.error,
+ R.string.message_rooms_load_failure,
+ )
+ }
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
index d2bd5a556..a1fd78c6f 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/message/MessageViewModel.kt
@@ -4,14 +4,18 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.MessageRoomModel
import com.ddangddangddang.android.model.mapper.MessageRoomModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.ChatRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class MessageViewModel(
+@HiltViewModel
+class MessageViewModel @Inject constructor(
private val repository: ChatRepository,
) : ViewModel() {
private val _event: SingleLiveEvent = SingleLiveEvent()
@@ -29,9 +33,18 @@ class MessageViewModel(
_messageRooms.value = response.body.map { it.toPresentation() }
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ MessageEvent.MessageLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = MessageEvent.MessageLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = MessageEvent.MessageLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -42,5 +55,6 @@ class MessageViewModel(
sealed class MessageEvent {
data class NavigateToMessageRoom(val roomId: Long) : MessageEvent()
+ data class MessageLoadFailure(val error: ErrorType) : MessageEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
index fa8cee861..4975dfd7d 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageAdapter.kt
@@ -3,13 +3,24 @@ package com.ddangddangddang.android.feature.messageRoom
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
-
-class MessageAdapter(
- private val diffUtilCommitCallback: Runnable,
+import com.ddangddangddang.android.di.DateFormatter
+import com.ddangddangddang.android.di.TimeFormatter
+import dagger.hilt.android.scopes.ActivityRetainedScoped
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
+
+@ActivityRetainedScoped
+class MessageAdapter @Inject constructor(
+ @DateFormatter private val dateFormatter: DateTimeFormatter,
+ @TimeFormatter private val timeFormatter: DateTimeFormatter,
) : ListAdapter(MessageDiffUtil) {
-
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
- return MessageViewHolder.of(parent, MessageViewType.values()[viewType])
+ return MessageViewHolder.of(
+ parent,
+ MessageViewType.values()[viewType],
+ dateFormatter,
+ timeFormatter,
+ )
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
@@ -23,7 +34,7 @@ class MessageAdapter(
return currentList[position].type.ordinal
}
- fun setMessages(list: List) {
+ fun setMessages(list: List, diffUtilCommitCallback: Runnable? = null) {
submitList(list, diffUtilCommitCallback)
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
index a27579905..f5a916677 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomActivity.kt
@@ -8,28 +8,46 @@ import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityMessageRoomBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.messageRoom.review.UserReviewDialog
import com.ddangddangddang.android.feature.report.ReportActivity
import com.ddangddangddang.android.global.AnalyticsDelegate
import com.ddangddangddang.android.global.AnalyticsDelegateImpl
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.model.ReportType
+import com.ddangddangddang.android.notification.NotificationType
+import com.ddangddangddang.android.notification.cancelActiveNotification
+import com.ddangddangddang.android.reciever.MessageReceiver
import com.ddangddangddang.android.util.binding.BindingActivity
-import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+@AndroidEntryPoint
class MessageRoomActivity :
BindingActivity(R.layout.activity_message_room),
AnalyticsDelegate by AnalyticsDelegateImpl() {
- private val viewModel: MessageRoomViewModel by viewModels { viewModelFactory }
+ private val viewModel: MessageRoomViewModel by viewModels()
private val roomCreatedNotifyAdapter by lazy { RoomCreatedNotifyAdapter() }
- private val messageAdapter by lazy {
- MessageAdapter { viewModel.messages.value?.let { binding.rvMessageList.scrollToPosition(it.size) } }
- }
+
+ @Inject
+ lateinit var messageAdapter: MessageAdapter
+
private val adapter by lazy { ConcatAdapter(roomCreatedNotifyAdapter, messageAdapter) }
+
+ private val messageReceiver: MessageReceiver by lazy {
+ MessageReceiver { messageRoomId ->
+ if (messageRoomId == viewModel.messageRoomInfo.value?.roomId) {
+ viewModel.loadMessages()
+ }
+ }
+ }
+ private val roomId: Long by lazy { intent.getLongExtra(ROOM_ID_KEY, -1L) }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
registerAnalytics(javaClass.simpleName, lifecycle)
binding.viewModel = viewModel
- val roomId: Long = intent.getLongExtra(ROOM_ID_KEY, -1L)
if (viewModel.messageRoomInfo.value == null) viewModel.loadMessageRoom(roomId)
setupViewModel()
setupMessageRecyclerView()
@@ -38,7 +56,7 @@ class MessageRoomActivity :
private fun setupViewModel() {
viewModel.event.observe(this) { handleEvent(it) }
viewModel.messages.observe(this) {
- messageAdapter.setMessages(it)
+ messageAdapter.setMessages(it) { scrollToDown() }
}
}
@@ -51,27 +69,72 @@ class MessageRoomActivity :
when (event) {
is MessageRoomViewModel.MessageRoomEvent.Exit -> finish()
is MessageRoomViewModel.MessageRoomEvent.Report -> navigateToReport(event.roomId)
+ is MessageRoomViewModel.MessageRoomEvent.Rate -> showUserRate()
is MessageRoomViewModel.MessageRoomEvent.NavigateToAuctionDetail -> {
navigateToAuctionDetail(event.auctionId)
}
- is MessageRoomViewModel.MessageRoomEvent.LoadRoomInfoFailed -> {
- notifyLoadRoomInfoFailed()
- finish()
- }
+ is MessageRoomViewModel.MessageRoomEvent.FailureEvent -> handleFailureEvent(event)
}
}
+ private fun scrollToDown() {
+ viewModel.messages.value?.let { binding.rvMessageList.scrollToPosition(it.size) }
+ }
+
private fun navigateToReport(roomId: Long) {
- startActivity(ReportActivity.getIntent(this, roomId))
+ startActivity(ReportActivity.getIntent(this, ReportType.MessageRoomReport.ordinal, roomId))
+ }
+
+ private fun showUserRate() {
+ UserReviewDialog.show(supportFragmentManager)
}
private fun navigateToAuctionDetail(auctionId: Long) {
startActivity(AuctionDetailActivity.getIntent(this, auctionId))
}
- private fun notifyLoadRoomInfoFailed() {
- Toaster.showShort(this, getString(R.string.message_room_toast_load_room_info_failed))
+ private fun handleFailureEvent(event: MessageRoomViewModel.MessageRoomEvent.FailureEvent) {
+ val defaultMessage = getDefaultFailureMessageByFailureEvent(event)
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = event.type.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
+ }
+
+ private fun getDefaultFailureMessageByFailureEvent(event: MessageRoomViewModel.MessageRoomEvent.FailureEvent): String {
+ return when (event) {
+ is MessageRoomViewModel.MessageRoomEvent.FailureEvent.LoadRoomInfo -> {
+ getString(R.string.message_room_load_room_info_failed)
+ }
+
+ is MessageRoomViewModel.MessageRoomEvent.FailureEvent.LoadMessages -> {
+ getString(R.string.message_room_load_messages_failed)
+ }
+
+ is MessageRoomViewModel.MessageRoomEvent.FailureEvent.SendMessage -> {
+ getString(R.string.message_room_send_message_failed)
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ (application as DdangDdangDdang).activeMessageRoomId = roomId
+ registerReceiver(messageReceiver, MessageReceiver.getIntentFilter())
+ if (viewModel.messageRoomInfo.value != null) viewModel.loadMessages()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ (application as DdangDdangDdang).activeMessageRoomId = null
+ unregisterReceiver(messageReceiver)
+ cancelNotification()
+ }
+
+ private fun cancelNotification() {
+ cancelActiveNotification(NotificationType.MESSAGE.name, roomId.toInt())
}
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
index fc20b9448..b82764f27 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageRoomViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.MessageModel
import com.ddangddangddang.android.model.MessageRoomDetailModel
import com.ddangddangddang.android.model.mapper.MessageModelMapper.toPresentation
@@ -12,9 +13,12 @@ import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.model.request.ChatMessageRequest
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.ChatRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class MessageRoomViewModel(
+@HiltViewModel
+class MessageRoomViewModel @Inject constructor(
private val repository: ChatRepository,
) : ViewModel() {
val inputMessage: MutableLiveData = MutableLiveData("")
@@ -44,11 +48,18 @@ class MessageRoomViewModel(
}
is ApiResponse.Failure -> {
- _event.value = MessageRoomEvent.LoadRoomInfoFailed
+ _event.value =
+ MessageRoomEvent.FailureEvent.LoadRoomInfo(ErrorType.FAILURE(response.error))
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.LoadRoomInfo(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = MessageRoomEvent.FailureEvent.LoadRoomInfo(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -61,12 +72,23 @@ class MessageRoomViewModel(
viewModelScope.launch {
when (val response = repository.getMessages(it.roomId, lastMessageId)) {
is ApiResponse.Success -> {
- addMessages(response.body.map { it.toPresentation().toViewItem() })
+ addMessages(response.body.map { it.toPresentation() }.toViewItems())
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.LoadMessages(ErrorType.FAILURE(response.error))
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.LoadMessages(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.LoadMessages(ErrorType.UNEXPECTED)
+ }
}
isMessageLoading = false
}
@@ -77,11 +99,23 @@ class MessageRoomViewModel(
_messages.value = _messages.value?.plus(messages) ?: messages
}
- private fun MessageModel.toViewItem(): MessageViewItem {
+ private fun List.toViewItems(): List {
+ var previousSendDate = _messages.value?.lastOrNull()?.createdDateTime?.toLocalDate()
+ return map { messageModel ->
+ val sendDate = messageModel.createdDateTime.toLocalDate()
+ val isFirstAtDate = (sendDate == previousSendDate).not()
+ previousSendDate = sendDate
+ messageModel.toViewItem(isFirstAtDate)
+ }
+ }
+
+ private fun MessageModel.toViewItem(
+ isFirstAtDate: Boolean,
+ ): MessageViewItem {
return if (isMyMessage) {
- MessageViewItem.MyMessageViewItem(id, createdDateTime, contents)
+ MessageViewItem.MyMessageViewItem(id, createdDateTime, contents, isFirstAtDate)
} else {
- MessageViewItem.PartnerMessageViewItem(id, createdDateTime, contents)
+ MessageViewItem.PartnerMessageViewItem(id, createdDateTime, contents, isFirstAtDate)
}
}
@@ -98,9 +132,20 @@ class MessageRoomViewModel(
loadMessages()
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.SendMessage(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.SendMessage(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ MessageRoomEvent.FailureEvent.SendMessage(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -111,7 +156,11 @@ class MessageRoomViewModel(
}
fun setReportEvent() {
- _messageRoomInfo.value?.let { _event.value = MessageRoomEvent.Report(it.auctionId) }
+ _messageRoomInfo.value?.let { _event.value = MessageRoomEvent.Report(it.roomId) }
+ }
+
+ fun setRateEvent() {
+ _event.value = MessageRoomEvent.Rate
}
fun setNavigateToAuctionDetailEvent() {
@@ -123,7 +172,12 @@ class MessageRoomViewModel(
sealed class MessageRoomEvent {
object Exit : MessageRoomEvent()
data class Report(val roomId: Long) : MessageRoomEvent()
+ object Rate : MessageRoomEvent()
data class NavigateToAuctionDetail(val auctionId: Long) : MessageRoomEvent()
- object LoadRoomInfoFailed : MessageRoomEvent()
+ sealed class FailureEvent(val type: ErrorType) : MessageRoomEvent() {
+ class LoadRoomInfo(type: ErrorType) : FailureEvent(type)
+ class LoadMessages(type: ErrorType) : FailureEvent(type)
+ class SendMessage(type: ErrorType) : FailureEvent(type)
+ }
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewHolder.kt
index 64a6eca58..31bdadcdf 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewHolder.kt
@@ -6,11 +6,19 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.databinding.ItemMyMessageBinding
import com.ddangddangddang.android.databinding.ItemPartnerMessageBinding
+import java.time.format.DateTimeFormatter
sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class MyMessageViewHolder(
private val binding: ItemMyMessageBinding,
+ dateFormatter: DateTimeFormatter,
+ timeFormatter: DateTimeFormatter,
) : MessageViewHolder(binding.root) {
+ init {
+ binding.dateFormatter = dateFormatter
+ binding.timeFormatter = timeFormatter
+ }
+
fun bind(item: MessageViewItem.MyMessageViewItem) {
binding.item = item
}
@@ -18,7 +26,14 @@ sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class PartnerMessageViewHolder(
private val binding: ItemPartnerMessageBinding,
+ dateFormatter: DateTimeFormatter,
+ timeFormatter: DateTimeFormatter,
) : MessageViewHolder(binding.root) {
+ init {
+ binding.dateFormatter = dateFormatter
+ binding.timeFormatter = timeFormatter
+ }
+
fun bind(item: MessageViewItem.PartnerMessageViewItem) {
binding.item = item
}
@@ -28,15 +43,21 @@ sealed class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun of(
parent: ViewGroup,
type: MessageViewType,
+ dateFormatter: DateTimeFormatter,
+ timeFormatter: DateTimeFormatter,
): MessageViewHolder {
val view = LayoutInflater.from(parent.context).inflate(type.id, parent, false)
return when (type) {
MessageViewType.MY_MESSAGE -> MyMessageViewHolder(
ItemMyMessageBinding.bind(view),
+ dateFormatter,
+ timeFormatter,
)
MessageViewType.PARTNER_MESSAGE -> PartnerMessageViewHolder(
ItemPartnerMessageBinding.bind(view),
+ dateFormatter,
+ timeFormatter,
)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewItem.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewItem.kt
index 239d7c0ca..c653fa992 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewItem.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/MessageViewItem.kt
@@ -1,23 +1,28 @@
package com.ddangddangddang.android.feature.messageRoom
+import java.time.LocalDateTime
+
sealed interface MessageViewItem {
val id: Long
val type: MessageViewType
- val createdDateTime: String
+ val createdDateTime: LocalDateTime
val contents: String
+ val isFirstAtDate: Boolean
data class MyMessageViewItem(
override val id: Long,
- override val createdDateTime: String,
+ override val createdDateTime: LocalDateTime,
override val contents: String,
+ override val isFirstAtDate: Boolean,
) : MessageViewItem {
override val type: MessageViewType = MessageViewType.MY_MESSAGE
}
data class PartnerMessageViewItem(
override val id: Long,
- override val createdDateTime: String,
+ override val createdDateTime: LocalDateTime,
override val contents: String,
+ override val isFirstAtDate: Boolean,
) : MessageViewItem {
override val type: MessageViewType = MessageViewType.PARTNER_MESSAGE
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewDialog.kt
new file mode 100644
index 000000000..533801abe
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewDialog.kt
@@ -0,0 +1,110 @@
+package com.ddangddangddang.android.feature.messageRoom.review
+
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.FragmentUserReviewDialogBinding
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.feature.messageRoom.MessageRoomViewModel
+import com.ddangddangddang.android.util.view.Toaster
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class UserReviewDialog : DialogFragment() {
+ private var _binding: FragmentUserReviewDialogBinding? = null
+ private val binding: FragmentUserReviewDialogBinding
+ get() = _binding!!
+
+ private val viewModel: UserReviewViewModel by viewModels()
+ private val activityViewModel: MessageRoomViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setupObserve()
+ loadPartnerInfo()
+ }
+
+ private fun setupObserve() {
+ viewModel.event.observe(this) { event ->
+ handleEvent(event)
+ }
+ }
+
+ private fun handleEvent(event: UserReviewViewModel.ReviewEvent) {
+ when (event) {
+ UserReviewViewModel.ReviewEvent.ReviewSuccess -> {
+ notifySubmitSuccess()
+ dismiss()
+ }
+
+ is UserReviewViewModel.ReviewEvent.ReviewFailure -> {
+ requireActivity().notifyFailureMessage(event.error, R.string.user_review_failure)
+ dismiss()
+ }
+
+ is UserReviewViewModel.ReviewEvent.ReviewLoadFailure -> {
+ requireActivity().notifyFailureMessage(
+ event.error,
+ R.string.user_review_load_failure,
+ )
+ dismiss()
+ }
+ }
+ }
+
+ private fun notifySubmitSuccess() {
+ Toaster.showShort(requireContext(), getString(R.string.user_review_success))
+ }
+
+ private fun loadPartnerInfo() {
+ activityViewModel.messageRoomInfo.value?.let { info ->
+ viewModel.setPartnerInfo(info)
+ return
+ }
+ dismiss()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentUserReviewDialogBinding.inflate(inflater, container, false)
+ .apply {
+ lifecycleOwner = viewLifecycleOwner
+ viewModel = this@UserReviewDialog.viewModel
+ }
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ setupListener()
+ }
+
+ private fun setupListener() {
+ binding.btnReviewCancel.setOnClickListener { dismiss() }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ companion object {
+ private const val USER_REVIEW_DIALOG_TAG = "user_review_dialog_tag"
+
+ fun show(fragmentManager: FragmentManager) {
+ UserReviewDialog().show(fragmentManager, USER_REVIEW_DIALOG_TAG)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewViewModel.kt
new file mode 100644
index 000000000..f07b2092a
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/messageRoom/review/UserReviewViewModel.kt
@@ -0,0 +1,114 @@
+package com.ddangddangddang.android.feature.messageRoom.review
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.MessageRoomDetailModel
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.ReviewRequest
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.ReviewRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class UserReviewViewModel @Inject constructor(private val reviewRepository: ReviewRepository) :
+ ViewModel() {
+ private var partnerId: Long? = null
+ private var auctionId: Long? = null
+
+ val ratingGrade = MutableLiveData(0f)
+ val reviewDetailContent = MutableLiveData("")
+
+ private var _isCompletedAlready = MutableLiveData(false)
+ val isCompletedAlready: LiveData
+ get() = _isCompletedAlready
+
+ private var _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private var isLoading = false
+
+ fun setPartnerInfo(detail: MessageRoomDetailModel) {
+ partnerId = detail.messagePartnerId
+ auctionId = detail.auctionId
+
+ fetchReviewWritten()
+ }
+
+ private fun fetchReviewWritten() {
+ viewModelScope.launch {
+ if (isLoading) return@launch
+ isLoading = true
+ val auctionId = auctionId ?: return@launch
+ when (val response = reviewRepository.getUserReview(auctionId)) {
+ is ApiResponse.Success -> {
+ val score = response.body.score
+ val content = response.body.content
+ if (score != null && content != null) {
+ ratingGrade.value = score
+ reviewDetailContent.value = content
+ _isCompletedAlready.value = true
+ }
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = ReviewEvent.ReviewLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = ReviewEvent.ReviewLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = ReviewEvent.ReviewLoadFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ isLoading = false
+ }
+ }
+
+ fun submitReview() {
+ val auctionId = auctionId ?: return
+ val partnerId = partnerId ?: return
+
+ val request = ReviewRequest(
+ auctionId,
+ partnerId,
+ ratingGrade.value ?: 0f,
+ reviewDetailContent.value ?: "",
+ )
+ viewModelScope.launch {
+ if (isLoading) return@launch
+ isLoading = true
+ when (val response = reviewRepository.reviewUser(request)) {
+ is ApiResponse.Success -> {
+ _event.value = ReviewEvent.ReviewSuccess
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = ReviewEvent.ReviewFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = ReviewEvent.ReviewFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = ReviewEvent.ReviewFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ isLoading = false
+ }
+ }
+
+ sealed class ReviewEvent {
+ object ReviewSuccess : ReviewEvent()
+ data class ReviewFailure(val error: ErrorType) : ReviewEvent()
+ data class ReviewLoadFailure(val error: ErrorType) : ReviewEvent()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt
new file mode 100644
index 000000000..d58b54f9a
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionActivity.kt
@@ -0,0 +1,102 @@
+package com.ddangddangddang.android.feature.myAuction
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ActivityMyAuctionBinding
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.home.AuctionAdapter
+import com.ddangddangddang.android.feature.home.AuctionSpaceItemDecoration
+import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MyAuctionActivity : BindingActivity(R.layout.activity_my_auction) {
+ private val viewModel: MyAuctionViewModel by viewModels()
+ private val auctionScrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (viewModel.isLast.value == true) return
+
+ if (!viewModel.loadingAuctionInProgress) {
+ val lastVisibleItemPosition =
+ (binding.rvMyAuction.layoutManager as GridLayoutManager).findLastCompletelyVisibleItemPosition()
+ val auctionsSize = viewModel.auctions.value?.size ?: 0
+ if (lastVisibleItemPosition + 10 >= auctionsSize) {
+ viewModel.loadMyAuctions()
+ }
+ }
+ }
+ }
+ private val auctionAdapter = AuctionAdapter { auctionId ->
+ viewModel.navigateToAuctionDetail(auctionId)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.viewModel = viewModel
+ if (viewModel.page == 0) viewModel.loadMyAuctions()
+ setupViewMdoel()
+ setupAuctionRecyclerView()
+ setupReloadAuctions()
+ }
+
+ private fun setupViewMdoel() {
+ viewModel.auctions.observe(this) {
+ auctionAdapter.setAuctions(it)
+ }
+ viewModel.event.observe(this) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: MyAuctionViewModel.Event) {
+ when (event) {
+ is MyAuctionViewModel.Event.Exit -> finish()
+ is MyAuctionViewModel.Event.NavigateToAuctionDetail -> {
+ navigateToAuctionDetail(event.auctionId)
+ }
+
+ is MyAuctionViewModel.Event.FailureLoadEvent -> {
+ handleErrorEvent(event.type)
+ }
+ }
+ }
+
+ private fun handleErrorEvent(errorType: ErrorType) {
+ val defaultMessage = getString(R.string.my_auction_load_failed_title)
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = errorType.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
+ }
+
+ private fun navigateToAuctionDetail(auctionId: Long) {
+ val intent = AuctionDetailActivity.getIntent(this, auctionId)
+ intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ startActivity(intent)
+ }
+
+ private fun setupAuctionRecyclerView() {
+ with(binding.rvMyAuction) {
+ adapter = auctionAdapter
+
+ val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
+ addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
+ addOnScrollListener(auctionScrollListener)
+ }
+ }
+
+ private fun setupReloadAuctions() {
+ binding.srlReloadAuctions.setOnRefreshListener {
+ viewModel.reloadAuctions()
+ binding.srlReloadAuctions.isRefreshing = false
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionViewModel.kt
new file mode 100644
index 000000000..3098990e4
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/myAuction/MyAuctionViewModel.kt
@@ -0,0 +1,106 @@
+package com.ddangddangddang.android.feature.myAuction
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.AuctionHomeModel
+import com.ddangddangddang.android.model.mapper.AuctionHomeModelMapper.toPresentation
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.response.AuctionPreviewsResponse
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MyAuctionViewModel @Inject constructor(
+ private val userRepository: UserRepository,
+) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private val _auctions: MutableLiveData> = MutableLiveData()
+ val auctions: LiveData>
+ get() = _auctions
+
+ private var _loadingAuctionsInProgress: Boolean = false
+ val loadingAuctionInProgress: Boolean
+ get() = _loadingAuctionsInProgress
+
+ private var _page = 0
+ val page: Int
+ get() = _page
+
+ private val _isLast = MutableLiveData(false)
+ val isLast: LiveData
+ get() = _isLast
+
+ fun setExitEvent() {
+ _event.value = Event.Exit
+ }
+
+ fun navigateToAuctionDetail(auctionId: Long) {
+ _event.value = Event.NavigateToAuctionDetail(auctionId)
+ }
+
+ fun loadMyAuctions() {
+ fetchAuctions(_page + 1)
+ }
+
+ fun reloadAuctions() {
+ fetchAuctions(DEFAULT_PAGE)
+ }
+
+ private fun fetchAuctions(newPage: Int) {
+ if (loadingAuctionInProgress) return
+ _loadingAuctionsInProgress = true
+ viewModelScope.launch {
+ when (val response = userRepository.getMyAuctionPreviews(newPage, SIZE_AUCTION_LOAD)) {
+ is ApiResponse.Success -> {
+ updateAuctions(response.body, newPage)
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.UNEXPECTED)
+ }
+ }
+ _loadingAuctionsInProgress = false
+ }
+ }
+
+ private fun updateAuctions(response: AuctionPreviewsResponse, newPage: Int) {
+ val newItems = response.auctions.map { it.toPresentation() }
+ _auctions.value = if (newPage == DEFAULT_PAGE) {
+ newItems
+ } else {
+ (_auctions.value ?: emptyList()) + newItems
+ }
+
+ _isLast.value = response.isLast
+ _page = newPage
+ }
+
+ sealed class Event {
+ object Exit : Event()
+ data class NavigateToAuctionDetail(val auctionId: Long) : Event()
+
+ data class FailureLoadEvent(val type: ErrorType) : Event()
+ }
+
+ companion object {
+ private const val SIZE_AUCTION_LOAD = 20
+ private const val DEFAULT_PAGE = 1
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageFragment.kt
index 0379d7704..95ff04794 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageFragment.kt
@@ -5,25 +5,47 @@ import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.net.Uri
import android.os.Bundle
+import android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS
+import android.provider.Settings.EXTRA_APP_PACKAGE
import android.view.View
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.viewModels
import com.ddangddangddang.android.BuildConfig
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentMyPageBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.login.LoginActivity
+import com.ddangddangddang.android.feature.myAuction.MyAuctionActivity
+import com.ddangddangddang.android.feature.participateAuction.ParticipateAuctionActivity
+import com.ddangddangddang.android.feature.profile.ProfileChangeActivity
+import com.ddangddangddang.android.model.ProfileModel
import com.ddangddangddang.android.util.binding.BindingFragment
import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.observeLoadingWithDialog
+import com.ddangddangddang.android.util.view.showDialog
import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class MyPageFragment : BindingFragment(R.layout.fragment_my_page) {
- private val viewModel: MyPageViewModel by viewModels { viewModelFactory }
+ private val viewModel: MyPageViewModel by viewModels()
+ private val profileChangeActivityLauncher = setupChangeProfileLauncher()
+
+ private fun setupChangeProfileLauncher(): ActivityResultLauncher {
+ return registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == AppCompatActivity.RESULT_OK) {
+ viewModel.loadProfile()
+ }
+ }
+ }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
- setupObserve()
if (viewModel.profile.value == null) viewModel.loadProfile()
+ setupObserve()
}
override fun onHiddenChanged(hidden: Boolean) {
@@ -33,20 +55,67 @@ class MyPageFragment : BindingFragment(R.layout.fragment_
private fun setupObserve() {
viewModel.event.observe(viewLifecycleOwner) { handleEvent(it) }
+ requireContext().observeLoadingWithDialog(viewLifecycleOwner, viewModel.isLoading)
}
private fun handleEvent(event: MyPageViewModel.MyPageEvent) {
when (event) {
+ is MyPageViewModel.MyPageEvent.LoadProfileFailed -> {
+ val defaultMessage = getString(R.string.mypage_snackbar_load_profile_failed_title)
+ notifyRequestFailed(event.type, defaultMessage)
+ }
+
+ MyPageViewModel.MyPageEvent.ProfileChange -> {
+ viewModel.profile.value?.let { navigateToUserInfoChange(it) }
+ }
+
+ MyPageViewModel.MyPageEvent.NavigateToMyAuctions -> {
+ navigateToMyAuction()
+ }
+
+ MyPageViewModel.MyPageEvent.NavigateToMyParticipateAuctions -> {
+ navigateToMyParticipateAuction()
+ }
+
+ MyPageViewModel.MyPageEvent.NavigateToNotificationSettings -> {
+ navigateToNotificationSettings()
+ }
+
+ MyPageViewModel.MyPageEvent.NavigateToAnnouncement -> {
+ }
+
+ MyPageViewModel.MyPageEvent.ContactUs -> contactUs()
+
+ MyPageViewModel.MyPageEvent.NavigateToPrivacyPolicy -> showPrivacyPolicy()
+
MyPageViewModel.MyPageEvent.LogoutSuccessfully -> {
notifyLogoutSuccessfully()
navigateToLogin()
}
- MyPageViewModel.MyPageEvent.LogoutFailed -> notifyLogoutFailed()
- MyPageViewModel.MyPageEvent.ShowPrivacyPolicy -> showPrivacyPolicy()
+ is MyPageViewModel.MyPageEvent.LogoutFailed -> {
+ val defaultMessage = getString(R.string.mypage_snackbar_logout_failed_title)
+ notifyRequestFailed(event.type, defaultMessage)
+ }
+
+ MyPageViewModel.MyPageEvent.AskWithdrawal -> askWithdrawal()
+
+ MyPageViewModel.MyPageEvent.WithdrawalSuccessfully -> {
+ notifyWithdrawalSuccessfully()
+ navigateToLogin()
+ }
+
+ is MyPageViewModel.MyPageEvent.WithdrawalFailed -> {
+ val defaultMessage = getString(R.string.mypage_snackbar_withdrawal_failed_title)
+ notifyRequestFailed(event.type, defaultMessage)
+ }
}
}
+ private fun navigateToMyAuction() {
+ startActivity(Intent(requireContext(), MyAuctionActivity::class.java))
+ }
+
private fun notifyLogoutSuccessfully() {
Toaster.showShort(
requireContext(),
@@ -60,12 +129,60 @@ class MyPageFragment : BindingFragment(R.layout.fragment_
startActivity(intent)
}
- private fun notifyLogoutFailed() {
- binding.root.showSnackbar(R.string.mypage_snackbar_logout_failed_title)
+ private fun navigateToUserInfoChange(profileModel: ProfileModel) {
+ profileChangeActivityLauncher.launch(
+ ProfileChangeActivity.getIntent(
+ requireContext(),
+ profileModel,
+ ),
+ )
+ }
+
+ private fun navigateToMyParticipateAuction() {
+ startActivity(Intent(requireContext(), ParticipateAuctionActivity::class.java))
+ }
+
+ private fun navigateToNotificationSettings() {
+ val intent = Intent(ACTION_APP_NOTIFICATION_SETTINGS)
+ intent.putExtra(EXTRA_APP_PACKAGE, requireContext().packageName)
+ startActivity(intent)
+ }
+
+ private fun contactUs() {
+ val address = BuildConfig.DDANG_EMAIL_ADDRESS
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = Uri.parse("mailto:$address")
+ }
+ startActivity(intent)
}
private fun showPrivacyPolicy() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.PRIVACY_POLICY_URL))
startActivity(intent)
}
+
+ private fun askWithdrawal() {
+ showDialog(
+ titleId = R.string.mypage_dialog_withdrawal_title,
+ messageId = R.string.mypage_dialog_withdrawal_message,
+ negativeStringId = R.string.all_dialog_default_negative_button,
+ positiveStringId = R.string.mypage_dialog_withdrawal_positive_button,
+ actionPositive = viewModel::withdrawal,
+ )
+ }
+
+ private fun notifyWithdrawalSuccessfully() {
+ Toaster.showShort(
+ requireContext(),
+ getString(R.string.mypage_toast_withdrawal_successfully_message),
+ )
+ }
+
+ private fun notifyRequestFailed(type: ErrorType, defaultMessage: String) {
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = type.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageViewModel.kt
index 883d75e31..9753edaf6 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/mypage/MyPageViewModel.kt
@@ -4,18 +4,26 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.ProfileModel
import com.ddangddangddang.android.model.mapper.ProfileModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuthRepository
import com.ddangddangddang.data.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class MyPageViewModel(
+@HiltViewModel
+class MyPageViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
) : ViewModel() {
+ private val _isLoading: MutableLiveData = MutableLiveData(false)
+ val isLoading: LiveData
+ get() = _isLoading
+
private val _profile: MutableLiveData = MutableLiveData()
val profile: LiveData
get() = _profile
@@ -25,43 +33,119 @@ class MyPageViewModel(
get() = _event
fun loadProfile() {
+ if (_isLoading.value == true) return
+ _isLoading.value = true
viewModelScope.launch {
when (val response = userRepository.getProfile()) {
is ApiResponse.Success -> {
_profile.value = response.body.toPresentation()
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value = MyPageEvent.LoadProfileFailed(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = MyPageEvent.LoadProfileFailed(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = MyPageEvent.LoadProfileFailed(ErrorType.UNEXPECTED)
+ }
}
+ _isLoading.value = false
}
}
+ fun changeProfile() {
+ _event.value = MyPageEvent.ProfileChange
+ }
+
+ fun navigateToMyAuctions() {
+ _event.value = MyPageEvent.NavigateToMyAuctions
+ }
+
+ fun navigateToMyParticipateAuctions() {
+ _event.value = MyPageEvent.NavigateToMyParticipateAuctions
+ }
+
+ fun navigateToNotificationSettings() {
+ _event.value = MyPageEvent.NavigateToNotificationSettings
+ }
+
+ fun navigateToAnnouncement() {
+ _event.value = MyPageEvent.NavigateToAnnouncement
+ }
+
+ fun contactUs() {
+ _event.value = MyPageEvent.ContactUs
+ }
+
+ fun navigateToPrivacyPolicy() {
+ _event.value = MyPageEvent.NavigateToPrivacyPolicy
+ }
+
fun logout() {
viewModelScope.launch {
- when (authRepository.logout()) {
+ when (val response = authRepository.logout()) {
is ApiResponse.Success -> {
_event.value = MyPageEvent.LogoutSuccessfully
}
is ApiResponse.Failure -> {
- _event.value = MyPageEvent.LogoutFailed
+ _event.value = MyPageEvent.LogoutFailed(ErrorType.FAILURE(response.error))
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value = MyPageEvent.WithdrawalFailed(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = MyPageEvent.WithdrawalFailed(ErrorType.UNEXPECTED)
+ }
}
}
}
- fun showPrivacyPolicy() {
- _event.value = MyPageEvent.ShowPrivacyPolicy
+ fun askWithdrawal() {
+ _event.value = MyPageEvent.AskWithdrawal
+ }
+
+ fun withdrawal() {
+ viewModelScope.launch {
+ when (val response = authRepository.withdrawal()) {
+ is ApiResponse.Success -> {
+ _event.value = MyPageEvent.WithdrawalSuccessfully
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = MyPageEvent.WithdrawalFailed(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = MyPageEvent.WithdrawalFailed(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = MyPageEvent.WithdrawalFailed(ErrorType.UNEXPECTED)
+ }
+ }
+ }
}
sealed class MyPageEvent {
+ data class LoadProfileFailed(val type: ErrorType) : MyPageEvent()
+ object ProfileChange : MyPageEvent()
+ object NavigateToMyAuctions : MyPageEvent()
+ object NavigateToMyParticipateAuctions : MyPageEvent()
+ object NavigateToNotificationSettings : MyPageEvent()
+ object NavigateToAnnouncement : MyPageEvent()
+ object ContactUs : MyPageEvent()
+ object NavigateToPrivacyPolicy : MyPageEvent()
object LogoutSuccessfully : MyPageEvent()
- object LogoutFailed : MyPageEvent()
- object ShowPrivacyPolicy : MyPageEvent()
+ data class LogoutFailed(val type: ErrorType) : MyPageEvent()
+ object AskWithdrawal : MyPageEvent()
+ object WithdrawalSuccessfully : MyPageEvent()
+ data class WithdrawalFailed(val type: ErrorType) : MyPageEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt
new file mode 100644
index 000000000..9db98a34d
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionActivity.kt
@@ -0,0 +1,103 @@
+package com.ddangddangddang.android.feature.participateAuction
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ActivityParticipateAuctionBinding
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.home.AuctionAdapter
+import com.ddangddangddang.android.feature.home.AuctionSpaceItemDecoration
+import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ParticipateAuctionActivity :
+ BindingActivity(R.layout.activity_participate_auction) {
+ private val viewModel: ParticipateAuctionViewModel by viewModels()
+ private val auctionScrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (viewModel.isLast.value == true) return
+
+ if (!viewModel.loadingAuctionInProgress) {
+ val lastVisibleItemPosition =
+ (binding.rvMyParticipateAuction.layoutManager as GridLayoutManager).findLastCompletelyVisibleItemPosition()
+ val auctionsSize = viewModel.auctions.value?.size ?: 0
+ if (lastVisibleItemPosition + 10 >= auctionsSize) {
+ viewModel.loadMyParticipateAuctions()
+ }
+ }
+ }
+ }
+ private val auctionAdapter = AuctionAdapter { auctionId ->
+ viewModel.navigateToAuctionDetail(auctionId)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.viewModel = viewModel
+ if (viewModel.page == 0) viewModel.loadMyParticipateAuctions()
+ setupViewMdoel()
+ setupAuctionRecyclerView()
+ setupReloadAuctions()
+ }
+
+ private fun setupViewMdoel() {
+ viewModel.auctions.observe(this) {
+ auctionAdapter.setAuctions(it)
+ }
+ viewModel.event.observe(this) {
+ handleEvent(it)
+ }
+ }
+
+ private fun handleEvent(event: ParticipateAuctionViewModel.Event) {
+ when (event) {
+ is ParticipateAuctionViewModel.Event.Exit -> finish()
+ is ParticipateAuctionViewModel.Event.NavigateToAuctionDetail -> {
+ navigateToAuctionDetail(event.auctionId)
+ }
+
+ is ParticipateAuctionViewModel.Event.FailureLoadEvent -> {
+ handleErrorEvent(event.type)
+ }
+ }
+ }
+
+ private fun handleErrorEvent(errorType: ErrorType) {
+ val defaultMessage = getString(R.string.my_participate_auction_load_failed_title)
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = errorType.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
+ }
+
+ private fun navigateToAuctionDetail(auctionId: Long) {
+ val intent = AuctionDetailActivity.getIntent(this, auctionId)
+ intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ startActivity(intent)
+ }
+
+ private fun setupAuctionRecyclerView() {
+ with(binding.rvMyParticipateAuction) {
+ adapter = auctionAdapter
+
+ val space = resources.getDimensionPixelSize(R.dimen.margin_side_layout)
+ addItemDecoration(AuctionSpaceItemDecoration(spanCount = 2, space = space))
+ addOnScrollListener(auctionScrollListener)
+ }
+ }
+
+ private fun setupReloadAuctions() {
+ binding.srlReloadAuctions.setOnRefreshListener {
+ viewModel.reloadAuctions()
+ binding.srlReloadAuctions.isRefreshing = false
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionViewModel.kt
new file mode 100644
index 000000000..1b6ff5386
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/participateAuction/ParticipateAuctionViewModel.kt
@@ -0,0 +1,108 @@
+package com.ddangddangddang.android.feature.participateAuction
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.AuctionHomeModel
+import com.ddangddangddang.android.model.mapper.AuctionHomeModelMapper.toPresentation
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.response.AuctionPreviewsResponse
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ParticipateAuctionViewModel @Inject constructor(
+ private val userRepository: UserRepository,
+) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private val _auctions: MutableLiveData> = MutableLiveData()
+ val auctions: LiveData>
+ get() = _auctions
+
+ private var _loadingAuctionsInProgress: Boolean = false
+ val loadingAuctionInProgress: Boolean
+ get() = _loadingAuctionsInProgress
+
+ private var _page = 0
+ val page: Int
+ get() = _page
+
+ private val _isLast = MutableLiveData(false)
+ val isLast: LiveData
+ get() = _isLast
+
+ fun setExitEvent() {
+ _event.value = Event.Exit
+ }
+
+ fun navigateToAuctionDetail(auctionId: Long) {
+ _event.value = Event.NavigateToAuctionDetail(auctionId)
+ }
+
+ fun loadMyParticipateAuctions() {
+ fetchAuctions(_page + 1)
+ }
+
+ fun reloadAuctions() {
+ fetchAuctions(DEFAULT_PAGE)
+ }
+
+ private fun fetchAuctions(newPage: Int) {
+ if (loadingAuctionInProgress) return
+ _loadingAuctionsInProgress = true
+ viewModelScope.launch {
+ when (
+ val response =
+ userRepository.getMyParticipateAuctionPreviews(newPage, SIZE_AUCTION_LOAD)
+ ) {
+ is ApiResponse.Success -> {
+ updateAuctions(response.body, newPage)
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.FailureLoadEvent(ErrorType.UNEXPECTED)
+ }
+ }
+ _loadingAuctionsInProgress = false
+ }
+ }
+
+ private fun updateAuctions(response: AuctionPreviewsResponse, newPage: Int) {
+ val newItems = response.auctions.map { it.toPresentation() }
+ _auctions.value = if (newPage == 1) {
+ newItems
+ } else {
+ (_auctions.value ?: emptyList()) + newItems
+ }
+
+ _isLast.value = response.isLast
+ _page = newPage
+ }
+
+ sealed class Event {
+ object Exit : Event()
+ data class NavigateToAuctionDetail(val auctionId: Long) : Event()
+ data class FailureLoadEvent(val type: ErrorType) : Event()
+ }
+
+ companion object {
+ private const val SIZE_AUCTION_LOAD = 20
+ private const val DEFAULT_PAGE = 1
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeActivity.kt
new file mode 100644
index 000000000..5275316ec
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeActivity.kt
@@ -0,0 +1,96 @@
+package com.ddangddangddang.android.feature.profile
+
+import android.app.Activity
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ActivityProfileChangeBinding
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.ProfileModel
+import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.compat.getParcelableCompat
+import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ProfileChangeActivity :
+ BindingActivity(R.layout.activity_profile_change) {
+ private val viewModel: ProfileChangeViewModel by viewModels()
+
+ private val launcher =
+ registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri != null) viewModel.setProfileImageUri(uri)
+ }
+
+ private val defaultUri by lazy {
+ Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(resources.getResourcePackageName(R.drawable.img_default_profile))
+ .appendPath(resources.getResourceTypeName(R.drawable.img_default_profile))
+ .appendPath(resources.getResourceEntryName(R.drawable.img_default_profile))
+ .build()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.viewModel = viewModel
+ val profileModel =
+ intent.getParcelableCompat(PROFILE_MODEL_KEY) ?: return finish()
+ if (viewModel.profile.value == null) viewModel.setupProfile(profileModel, defaultUri)
+ setupViewModel()
+ }
+
+ private fun setupViewModel() {
+ viewModel.event.observe(this) { handleEvent(it) }
+ }
+
+ private fun handleEvent(event: ProfileChangeViewModel.Event) {
+ when (event) {
+ ProfileChangeViewModel.Event.Exit -> finish()
+ ProfileChangeViewModel.Event.NavigateToSelectProfileImage -> {
+ launcher.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
+ )
+ }
+
+ is ProfileChangeViewModel.Event.SuccessProfileChange -> {
+ changeSuccessProfile()
+ }
+
+ is ProfileChangeViewModel.Event.FailureChangeProfileEvent -> {
+ notifyProfileChangeFailed(event.errorType)
+ }
+ }
+ }
+
+ private fun changeSuccessProfile() {
+ Toaster.showShort(this, getString(R.string.profile_change_success))
+ setResult(Activity.RESULT_OK)
+ finish()
+ }
+
+ private fun notifyProfileChangeFailed(type: ErrorType) {
+ val defaultMessage = getString(R.string.profile_change_failed)
+ val actionMessage = getString(R.string.all_snackbar_default_action)
+ binding.root.showSnackbar(
+ message = type.message ?: defaultMessage,
+ actionMessage = actionMessage,
+ )
+ }
+
+ companion object {
+ private const val PROFILE_MODEL_KEY = "profile_model_key"
+
+ fun getIntent(context: Context, profileModel: ProfileModel): Intent =
+ Intent(context, ProfileChangeActivity::class.java).apply {
+ putExtra(PROFILE_MODEL_KEY, profileModel)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt
new file mode 100644
index 000000000..7b70e6a83
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/profile/ProfileChangeViewModel.kt
@@ -0,0 +1,86 @@
+package com.ddangddangddang.android.feature.profile
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.ProfileModel
+import com.ddangddangddang.android.util.image.toAdjustImageFile
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.request.ProfileUpdateRequest
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.UserRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ProfileChangeViewModel @Inject constructor(
+ private val userRepository: UserRepository,
+) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ private lateinit var originalProfileUri: Uri
+
+ private val _profile: MutableLiveData = MutableLiveData()
+ val profile: LiveData
+ get() = _profile
+
+ val userNickname: MutableLiveData = MutableLiveData()
+
+ fun setupProfile(profileModel: ProfileModel, defaultUri: Uri) {
+ val originProfileUri = profileModel.profileImage?.let { Uri.parse(it) } ?: defaultUri
+ _profile.value = originProfileUri
+ originalProfileUri = originProfileUri
+ userNickname.value = profileModel.name
+ }
+
+ fun setExitEvent() {
+ _event.value = Event.Exit
+ }
+
+ fun selectProfileImage() {
+ _event.value = Event.NavigateToSelectProfileImage
+ }
+
+ fun setProfileImageUri(uri: Uri) {
+ _profile.value = uri
+ }
+
+ fun submitProfile(context: Context) {
+ val name = userNickname.value ?: return
+ val profileImageUri = profile.value?.takeIf { it.path != originalProfileUri.path }
+ viewModelScope.launch {
+ val file = runCatching { profileImageUri?.toAdjustImageFile(context) }.getOrNull()
+ when (val response = userRepository.updateProfile(file, ProfileUpdateRequest(name))) {
+ is ApiResponse.Success -> {
+ _event.value = Event.SuccessProfileChange
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = Event.FailureChangeProfileEvent(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = Event.FailureChangeProfileEvent(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = Event.FailureChangeProfileEvent(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ }
+
+ sealed class Event {
+ object Exit : Event()
+ object NavigateToSelectProfileImage : Event()
+ object SuccessProfileChange : Event()
+ data class FailureChangeProfileEvent(val errorType: ErrorType) : Event()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt
new file mode 100644
index 000000000..65857654e
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/DefaultTextWatcher.kt
@@ -0,0 +1,14 @@
+package com.ddangddangddang.android.feature.register
+
+import android.text.Editable
+import android.text.TextWatcher
+
+class DefaultTextWatcher(private val onAfterChanged: (String) -> Unit) : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+
+ override fun afterTextChanged(s: Editable?) {
+ s?.let { onAfterChanged(s.toString()) }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
index 109e330be..c7be9b40b 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionActivity.kt
@@ -5,13 +5,14 @@ import android.app.TimePickerDialog
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.widget.EditText
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityRegisterAuctionBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
import com.ddangddangddang.android.feature.register.category.SelectCategoryActivity
import com.ddangddangddang.android.feature.register.region.SelectRegionsActivity
@@ -25,17 +26,21 @@ import com.ddangddangddang.android.util.compat.getParcelableCompat
import com.ddangddangddang.android.util.compat.getSerializableExtraCompat
import com.ddangddangddang.android.util.view.showDialog
import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
import java.time.LocalDateTime
import java.time.LocalTime
+@AndroidEntryPoint
class RegisterAuctionActivity :
BindingActivity(R.layout.activity_register_auction),
AnalyticsDelegate by AnalyticsDelegateImpl() {
- private val viewModel by viewModels { viewModelFactory }
+ private val viewModel: RegisterAuctionViewModel by viewModels()
private val imageAdapter = RegisterAuctionImageAdapter { viewModel.setDeleteImageEvent(it) }
private val pickMultipleMediaLaunchers = setupMultipleMediaLaunchers()
private val categoryActivityLauncher = setupCategoryLauncher()
private val regionActivityLauncher = setupRegionLauncher()
+ private val startPriceWatcher by lazy { DefaultTextWatcher(viewModel::setStartPrice) }
+ private val bidUnitWatcher by lazy { DefaultTextWatcher(viewModel::setBidUnit) }
private fun setupMultipleMediaLaunchers(): List> {
return List(RegisterAuctionViewModel.MAXIMUM_IMAGE_SIZE) { index ->
@@ -88,11 +93,19 @@ class RegisterAuctionActivity :
setupViewModel()
setupLinearLayoutRegisterImage()
setupImageRecyclerView()
+ setupStartPriceTextWatcher()
+ setupBidUnitTextWatcher()
}
private fun setupViewModel() {
viewModel.images.observe(this) { imageAdapter.setImages(it) }
viewModel.event.observe(this) { handleEvent(it) }
+ viewModel.startPrice.observe(this) {
+ setPrice(binding.etStartPrice, startPriceWatcher, it.toInt())
+ }
+ viewModel.bidUnit.observe(this) {
+ setPrice(binding.etBidUnit, bidUnitWatcher, it.toInt())
+ }
}
private fun handleEvent(event: RegisterAuctionViewModel.RegisterAuctionEvent) {
@@ -106,7 +119,7 @@ class RegisterAuctionActivity :
}
is RegisterAuctionViewModel.RegisterAuctionEvent.SubmitError -> {
- showErrorSubmitMessage(event.message)
+ showErrorSubmitMessage(event.errorType)
}
is RegisterAuctionViewModel.RegisterAuctionEvent.SubmitResult -> {
@@ -184,9 +197,9 @@ class RegisterAuctionActivity :
)
}
- private fun showErrorSubmitMessage(message: String) {
+ private fun showErrorSubmitMessage(errorType: ErrorType) {
binding.root.showSnackbar(
- message,
+ errorType.message ?: getString(R.string.register_autcion_default_error_message),
getString(R.string.all_snackbar_default_action),
)
}
@@ -207,6 +220,14 @@ class RegisterAuctionActivity :
regionActivityLauncher.launch(SelectRegionsActivity.getIntent(this))
}
+ private fun setPrice(editText: EditText, watcher: DefaultTextWatcher, price: Int) {
+ val displayPrice = getString(R.string.detail_auction_bid_dialog_input_price, price)
+ editText.removeTextChangedListener(watcher)
+ editText.setText(displayPrice)
+ editText.setSelection(getCursorPositionFrontSuffix(displayPrice)) // " 원" 앞으로 커서 이동
+ editText.addTextChangedListener(watcher)
+ }
+
private fun showDeleteImageDialog(image: RegisterImageModel) {
showDialog(
messageId = R.string.register_auction_dialog_delete_image_message,
@@ -233,6 +254,18 @@ class RegisterAuctionActivity :
}
}
+ private fun setupStartPriceTextWatcher() {
+ binding.etStartPrice.addTextChangedListener(startPriceWatcher)
+ }
+
+ private fun setupBidUnitTextWatcher() {
+ binding.etBidUnit.addTextChangedListener(bidUnitWatcher)
+ }
+
+ private fun getCursorPositionFrontSuffix(content: String): Int {
+ return content.length - RegisterAuctionViewModel.SUFFIX_INPUT_PRICE.length
+ }
+
companion object {
const val CATEGORY_RESULT = "category_result"
const val REGIONS_RESULT = "region_result"
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
index 11cbe349f..d984a513c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/RegisterAuctionViewModel.kt
@@ -1,37 +1,41 @@
package com.ddangddangddang.android.feature.register
import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.net.Uri
-import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.CategoryModel
import com.ddangddangddang.android.model.RegionSelectionModel
import com.ddangddangddang.android.model.RegisterImageModel
+import com.ddangddangddang.android.util.image.toAdjustImageFile
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.model.request.RegisterAuctionRequest
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
-import org.json.JSONObject
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.io.FileOutputStream
+import java.math.BigInteger
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import javax.inject.Inject
-class RegisterAuctionViewModel(private val repository: AuctionRepository) : ViewModel() {
+@HiltViewModel
+class RegisterAuctionViewModel @Inject constructor(private val repository: AuctionRepository) :
+ ViewModel() {
// EditText Values - Two Way Binding
val title: MutableLiveData = MutableLiveData("")
val description: MutableLiveData = MutableLiveData("")
- val startPrice: MutableLiveData = MutableLiveData()
- val bidUnit: MutableLiveData = MutableLiveData()
+ private val _startPrice: MutableLiveData = MutableLiveData()
+ val startPrice: LiveData
+ get() = _startPrice
+ private val _bidUnit: MutableLiveData = MutableLiveData()
+ val bidUnit: LiveData
+ get() = _bidUnit
// Images
private val _images: MutableLiveData> = MutableLiveData()
@@ -93,17 +97,17 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
}
is ApiResponse.Failure -> {
- if (response.responseCode == 400) {
- response.error?.let {
- val jsonObject = JSONObject(it)
- val message = jsonObject.getString("message")
- _event.value = RegisterAuctionEvent.SubmitError(message)
- }
- }
+ _event.value =
+ RegisterAuctionEvent.SubmitError(ErrorType.FAILURE(response.error))
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.NetworkError -> {
+ _event.value = RegisterAuctionEvent.SubmitError(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = RegisterAuctionEvent.SubmitError(ErrorType.UNEXPECTED)
+ }
}
isLoading = false
}
@@ -114,7 +118,7 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
val title = title.value
val category = _category.value?.id
val description = description.value
- val startPrice = startPrice.value
+ val startPrice = _startPrice.value
val bidUnit = bidUnit.value
val closingTime = closingTime.value
val directRegion = _directRegion.value?.size ?: 0
@@ -123,19 +127,14 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
title.isNullOrBlank() ||
category == null ||
description.isNullOrBlank() ||
- startPrice.isNullOrBlank() ||
- bidUnit.isNullOrBlank() ||
+ startPrice == null ||
+ bidUnit == null ||
closingTime == null ||
directRegion == 0
) {
setBlankExistEvent()
return false
}
-
- if (startPrice.toIntOrNull() == null || bidUnit.toIntOrNull() == null) {
- setInvalidValueInputEvent()
- return false
- }
return true
}
@@ -143,9 +142,10 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
val title = title.value ?: ""
val category = _category.value?.id ?: -1
val description = description.value ?: ""
- val startPrice = startPrice.value?.toInt() ?: 0
+ val startPrice = _startPrice.value?.toInt() ?: 0
val bidUnit = bidUnit.value?.toInt() ?: 0
- val closingTime = closingTime.value.toString() + ":00" // seconds
+ val closingTime =
+ closingTime.value?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm':00'")) ?: ""
val regions = _directRegion.value?.map { it.id } ?: emptyList()
return RegisterAuctionRequest(
@@ -159,46 +159,6 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
)
}
- private fun Uri.toAdjustImageFile(context: Context): File? {
- val bitmap = toBitmap(context) ?: return null
-
- val file = createAdjustImageFile(bitmap, context.cacheDir)
-
- val orientation = context.contentResolver
- .openInputStream(this)?.use {
- ExifInterface(it)
- }?.getAttribute(ExifInterface.TAG_ORIENTATION)
- orientation?.let { file.setOrientation(it) }
-
- return file
- }
-
- private fun Uri.toBitmap(context: Context): Bitmap? {
- return context.contentResolver
- .openInputStream(this)?.use {
- BitmapFactory.decodeStream(it)
- }
- }
-
- private fun createAdjustImageFile(bitmap: Bitmap, directory: File): File {
- val byteArrayOutputStream = ByteArrayOutputStream()
- Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true)
- .compress(Bitmap.CompressFormat.JPEG, 90, byteArrayOutputStream)
-
- val tempFile = File.createTempFile("resized_image", ".jpg", directory)
- FileOutputStream(tempFile).use {
- it.write(byteArrayOutputStream.toByteArray())
- }
- return tempFile
- }
-
- private fun File.setOrientation(orientation: String) {
- ExifInterface(this.path).apply {
- setAttribute(ExifInterface.TAG_ORIENTATION, orientation)
- saveAttributes()
- }
- }
-
fun addImages(images: List) {
_images.value = _images.value?.plus(images) ?: images
}
@@ -216,6 +176,25 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
RegisterAuctionEvent.ClosingTimePicker(_closingTime.value ?: LocalDateTime.now())
}
+ fun setStartPrice(text: String) {
+ val convertedPrice = convertStringPriceToInt(text)
+ _startPrice.value = convertedPrice
+ }
+
+ fun setBidUnit(text: String) {
+ val convertedPrice = convertStringPriceToInt(text)
+ _bidUnit.value = convertedPrice
+ }
+
+ private fun convertStringPriceToInt(text: String): BigInteger {
+ val originalValue = text.replace(",", "") // 문자열 내 들어있는 콤마를 모두 제거
+ val priceValue = originalValue.substringBefore(SUFFIX_INPUT_PRICE.trim()).trim() // " 원"
+ val parsedValue =
+ priceValue.toBigIntegerOrNull() ?: return ZERO.toBigInteger() // 입력에 문자가 섞인 경우
+ if (parsedValue > MAX_PRICE.toBigInteger()) return MAX_PRICE.toBigInteger()
+ return parsedValue
+ }
+
fun setExitEvent() {
_event.value = RegisterAuctionEvent.Exit
}
@@ -232,10 +211,6 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
_event.value = RegisterAuctionEvent.InputErrorEvent.BlankExistEvent
}
- private fun setInvalidValueInputEvent() {
- _event.value = RegisterAuctionEvent.InputErrorEvent.InvalidValueInputEvent
- }
-
fun setDeleteImageEvent(image: RegisterImageModel) {
_event.value = RegisterAuctionEvent.DeleteImage(image)
}
@@ -250,7 +225,7 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
sealed class RegisterAuctionEvent {
object Exit : RegisterAuctionEvent()
- data class SubmitError(val message: String) : RegisterAuctionEvent()
+ data class SubmitError(val errorType: ErrorType) : RegisterAuctionEvent()
class ClosingTimePicker(val dateTime: LocalDateTime) : RegisterAuctionEvent()
class SubmitResult(val id: Long) : RegisterAuctionEvent()
sealed class InputErrorEvent : RegisterAuctionEvent() {
@@ -267,5 +242,8 @@ class RegisterAuctionViewModel(private val repository: AuctionRepository) : View
companion object {
const val MAXIMUM_IMAGE_SIZE = 10
+ const val SUFFIX_INPUT_PRICE = " 원"
+ private const val ZERO = 0
+ private const val MAX_PRICE = 2100000000
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/MainCategoryViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/MainCategoryViewHolder.kt
index 210060532..2fedaf9e3 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/MainCategoryViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/MainCategoryViewHolder.kt
@@ -3,6 +3,7 @@ package com.ddangddangddang.android.feature.register.category
import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ItemSelectMainCategoryBinding
@@ -21,12 +22,12 @@ class MainCategoryViewHolder(
binding.category = category
if (category.isChecked) {
binding.clMainCategoryItem.isSelected = true
- binding.tvCategory.setTextColor(binding.root.context.getColor(R.color.white))
- binding.tvCategory.setTypeface(null, Typeface.BOLD)
+ binding.tvCategory.setTextColor(binding.root.context.getColor(R.color.grey_50))
+ binding.tvCategory.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 700, false)
} else {
binding.clMainCategoryItem.isSelected = false
- binding.tvCategory.setTextColor(binding.root.context.getColor(R.color.black_600))
- binding.tvCategory.setTypeface(null, Typeface.NORMAL)
+ binding.tvCategory.setTextColor(binding.root.context.getColor(R.color.grey_700))
+ binding.tvCategory.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 400, false)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryActivity.kt
index e6a0ae46a..e5caaaefd 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryActivity.kt
@@ -8,14 +8,16 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivitySelectCategoryBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.register.RegisterAuctionActivity
import com.ddangddangddang.android.model.CategoryModel
import com.ddangddangddang.android.util.binding.BindingActivity
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class SelectCategoryActivity :
BindingActivity(R.layout.activity_select_category) {
- private val viewModel by viewModels { viewModelFactory }
+ private val viewModel: SelectCategoryViewModel by viewModels()
private val mainAdapter by lazy {
MainCategoryAdapter { id ->
viewModel.setMainCategorySelection(id)
@@ -66,9 +68,18 @@ class SelectCategoryActivity :
is SelectCategoryViewModel.SelectCategoryEvent.Exit -> {
finish()
}
+
is SelectCategoryViewModel.SelectCategoryEvent.Submit -> {
submit(event.category)
}
+
+ is SelectCategoryViewModel.SelectCategoryEvent.MainCategoriesLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.select_category_main_load_failure)
+ }
+
+ is SelectCategoryViewModel.SelectCategoryEvent.SubCategoriesLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.select_category_sub_load_failure)
+ }
}
}
@@ -79,6 +90,7 @@ class SelectCategoryActivity :
}
companion object {
- fun getIntent(context: Context): Intent = Intent(context, SelectCategoryActivity::class.java)
+ fun getIntent(context: Context): Intent =
+ Intent(context, SelectCategoryActivity::class.java)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryViewModel.kt
index 8767ad9e8..62461ef5a 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/category/SelectCategoryViewModel.kt
@@ -4,15 +4,21 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.CategoryModel
import com.ddangddangddang.android.model.mapper.CategoryModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.CategoryRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
private typealias MainId = Long
-class SelectCategoryViewModel(private val categoryRepository: CategoryRepository) : ViewModel() {
+
+@HiltViewModel
+class SelectCategoryViewModel @Inject constructor(private val categoryRepository: CategoryRepository) :
+ ViewModel() {
private val _event: SingleLiveEvent = SingleLiveEvent()
val event: LiveData
get() = _event
@@ -35,9 +41,20 @@ class SelectCategoryViewModel(private val categoryRepository: CategoryRepository
_mainCategories.value = mainCategories
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ SelectCategoryEvent.MainCategoriesLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ SelectCategoryEvent.MainCategoriesLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ SelectCategoryEvent.MainCategoriesLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -68,9 +85,20 @@ class SelectCategoryViewModel(private val categoryRepository: CategoryRepository
subCategoriesCache[mainCategoryId] = subCategories // 캐시 저장
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ SelectCategoryEvent.SubCategoriesLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ SelectCategoryEvent.SubCategoriesLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ SelectCategoryEvent.SubCategoriesLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -95,5 +123,7 @@ class SelectCategoryViewModel(private val categoryRepository: CategoryRepository
sealed class SelectCategoryEvent {
object Exit : SelectCategoryEvent()
data class Submit(val category: CategoryModel) : SelectCategoryEvent()
+ data class MainCategoriesLoadFailure(val error: ErrorType) : SelectCategoryEvent()
+ data class SubCategoriesLoadFailure(val error: ErrorType) : SelectCategoryEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/FirstRegionViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/FirstRegionViewHolder.kt
index 3091d6f4d..5f32e6c5c 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/FirstRegionViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/FirstRegionViewHolder.kt
@@ -3,6 +3,7 @@ package com.ddangddangddang.android.feature.register.region
import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ItemSelectRegionFirstBinding
@@ -20,12 +21,12 @@ class FirstRegionViewHolder(
binding.region = region
if (region.isChecked) {
binding.clFirstRegionItem.isSelected = true
- binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.white))
- binding.tvRegion.setTypeface(null, Typeface.BOLD)
+ binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.grey_50))
+ binding.tvRegion.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 700, false)
} else {
binding.clFirstRegionItem.isSelected = false
- binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.black_600))
- binding.tvRegion.setTypeface(null, Typeface.NORMAL)
+ binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.grey_700))
+ binding.tvRegion.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 400, false)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SecondRegionViewHolder.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SecondRegionViewHolder.kt
index 93f0e868b..6a3f96356 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SecondRegionViewHolder.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SecondRegionViewHolder.kt
@@ -3,6 +3,7 @@ package com.ddangddangddang.android.feature.register.region
import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ItemSelectRegionSecondBinding
@@ -20,12 +21,12 @@ class SecondRegionViewHolder(
binding.region = region
if (region.isChecked) {
binding.clSecondRegionItem.isSelected = true
- binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.red_100))
- binding.tvRegion.setTypeface(null, Typeface.BOLD)
+ binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.selected_second_region_text))
+ binding.tvRegion.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 700, false)
} else {
binding.clSecondRegionItem.isSelected = false
- binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.black_600))
- binding.tvRegion.setTypeface(null, Typeface.NORMAL)
+ binding.tvRegion.setTextColor(binding.root.context.getColor(R.color.grey_700))
+ binding.tvRegion.typeface = Typeface.create(ResourcesCompat.getFont(binding.root.context, R.font.pretendard), 400, false)
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
index fb6afbc05..a35ae75fb 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsActivity.kt
@@ -6,14 +6,16 @@ import android.os.Bundle
import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivitySelectRegionsBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
import com.ddangddangddang.android.feature.register.RegisterAuctionActivity
import com.ddangddangddang.android.model.RegionSelectionModel
import com.ddangddangddang.android.util.binding.BindingActivity
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class SelectRegionsActivity :
BindingActivity(R.layout.activity_select_regions) {
- private val viewModel by viewModels { viewModelFactory }
+ private val viewModel: SelectRegionsViewModel by viewModels()
private val firstRegionsAdapter by lazy {
FirstRegionsAdapter {
viewModel.setFirstRegionSelection(it)
@@ -75,6 +77,18 @@ class SelectRegionsActivity :
is SelectRegionsViewModel.SelectRegionsEvent.Submit -> {
submit(event.regions)
}
+
+ is SelectRegionsViewModel.SelectRegionsEvent.FirstRegionsLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.select_regions_first_failure)
+ }
+
+ is SelectRegionsViewModel.SelectRegionsEvent.SecondRegionsLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.select_regions_second_failure)
+ }
+
+ is SelectRegionsViewModel.SelectRegionsEvent.ThirdRegionsLoadFailure -> {
+ notifyFailureMessage(event.error, R.string.select_regions_third_failure)
+ }
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
index 3018403ab..f60805b13 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/register/region/SelectRegionsViewModel.kt
@@ -4,17 +4,22 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.model.RegionSelectionModel
import com.ddangddangddang.android.model.mapper.RegionModelMapper.toPresentation
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.RegionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
private typealias FirstId = Long
private typealias SecondId = Long
-class SelectRegionsViewModel(private val regionRepository: RegionRepository) : ViewModel() {
+@HiltViewModel
+class SelectRegionsViewModel @Inject constructor(private val regionRepository: RegionRepository) :
+ ViewModel() {
private val _event: SingleLiveEvent =
SingleLiveEvent()
val event: LiveData
@@ -46,9 +51,21 @@ class SelectRegionsViewModel(private val regionRepository: RegionRepository) : V
val regions = response.body.map { it.toPresentation() }
_firstRegions.value = regions
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+
+ is ApiResponse.Failure -> {
+ _event.value =
+ SelectRegionsEvent.FirstRegionsLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ SelectRegionsEvent.FirstRegionsLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ SelectRegionsEvent.FirstRegionsLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -81,9 +98,19 @@ class SelectRegionsViewModel(private val regionRepository: RegionRepository) : V
secondRegionsCache[firstId] = regions
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ SelectRegionsEvent.SecondRegionsLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ SelectRegionsEvent.SecondRegionsLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = SelectRegionsEvent.SecondRegionsLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -113,9 +140,19 @@ class SelectRegionsViewModel(private val regionRepository: RegionRepository) : V
thirdRegionsCache[secondId] = regions
}
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ SelectRegionsEvent.ThirdRegionsLoadFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ SelectRegionsEvent.ThirdRegionsLoadFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = SelectRegionsEvent.ThirdRegionsLoadFailure(ErrorType.UNEXPECTED)
+ }
}
}
}
@@ -162,5 +199,8 @@ class SelectRegionsViewModel(private val regionRepository: RegionRepository) : V
sealed class SelectRegionsEvent {
object Exit : SelectRegionsEvent()
data class Submit(val regions: List) : SelectRegionsEvent()
+ data class FirstRegionsLoadFailure(val error: ErrorType) : SelectRegionsEvent()
+ data class SecondRegionsLoadFailure(val error: ErrorType) : SelectRegionsEvent()
+ data class ThirdRegionsLoadFailure(val error: ErrorType) : SelectRegionsEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
index ad7c2bea6..bf8ff8959 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportActivity.kt
@@ -6,34 +6,45 @@ import android.os.Bundle
import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivityReportBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.notifyFailureMessage
+import com.ddangddangddang.android.model.ReportType
import com.ddangddangddang.android.util.binding.BindingActivity
import com.ddangddangddang.android.util.view.Toaster
import com.ddangddangddang.android.util.view.showSnackbar
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class ReportActivity : BindingActivity(R.layout.activity_report) {
- private val viewModel: ReportViewModel by viewModels { viewModelFactory }
+ private val viewModel: ReportViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.viewModel = viewModel
+ getReportInfo()
setupViewModel()
}
private fun setupViewModel() {
- loadAuctionId()
viewModel.event.observe(this) { event ->
when (event) {
ReportViewModel.ReportEvent.ExitEvent -> finish()
ReportViewModel.ReportEvent.SubmitEvent -> submit()
ReportViewModel.ReportEvent.BlankContentsEvent -> notifyBlankContents()
+ is ReportViewModel.ReportEvent.ReportArticleFailure -> {
+ notifyFailureMessage(event.error, R.string.report_submit_failure)
+ }
+
+ is ReportViewModel.ReportEvent.ReportMessageRoomFailure -> {
+ notifyFailureMessage(event.error, R.string.report_submit_failure)
+ }
}
}
}
- private fun loadAuctionId() {
- val id = intent.getLongExtra(AUCTION_ID_KEY, DEFAULT_VALUE)
- if (id == DEFAULT_VALUE) notifyAuctionIdNotDelivered()
- viewModel.setAuctionId(id)
+ private fun getReportInfo() {
+ val typeIndex: Int = intent.getIntExtra(REPORT_TYPE_KEY, DEFAULT_VALUE.toInt())
+ val id = intent.getLongExtra(REPORT_ID_KEY, DEFAULT_VALUE)
+ if (id == DEFAULT_VALUE || typeIndex == DEFAULT_VALUE.toInt()) notifyNavigateToReportPageFailed()
+ viewModel.setReportInfo(ReportType.values()[typeIndex], id)
}
private fun submit() {
@@ -45,17 +56,19 @@ class ReportActivity : BindingActivity(R.layout.activity_
binding.root.showSnackbar(textId = R.string.report_snackbar_blank_contents)
}
- private fun notifyAuctionIdNotDelivered() {
- Toaster.showShort(this, getString(R.string.report_snackbar_auction_id_not_delivered))
+ private fun notifyNavigateToReportPageFailed() {
+ Toaster.showShort(this, getString(R.string.report_snackbar_navigate_to_report_page_failed))
finish()
}
companion object {
private const val DEFAULT_VALUE = -1L
- private const val AUCTION_ID_KEY = "auction_id_key"
- fun getIntent(context: Context, auctionId: Long): Intent =
+ private const val REPORT_TYPE_KEY = "report_type_key"
+ private const val REPORT_ID_KEY = "report_id_key"
+ fun getIntent(context: Context, reportTypeIndex: Int, reportId: Long): Intent =
Intent(context, ReportActivity::class.java).apply {
- putExtra(AUCTION_ID_KEY, auctionId)
+ putExtra(REPORT_TYPE_KEY, reportTypeIndex)
+ putExtra(REPORT_ID_KEY, reportId)
}
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
index 79a9e1226..e23d3f95b 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/report/ReportViewModel.kt
@@ -4,20 +4,30 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.ReportType
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class ReportViewModel(private val repository: AuctionRepository) : ViewModel() {
+@HiltViewModel
+class ReportViewModel @Inject constructor(private val repository: AuctionRepository) : ViewModel() {
private val _event = SingleLiveEvent()
val event: LiveData
get() = _event
- private var auctionId: Long? = null
+ private lateinit var reportType: ReportType
+ private var reportId: Long? = null
val reportContents = MutableLiveData()
- fun setAuctionId(id: Long) {
- auctionId = id
+
+ private var isLoading: Boolean = false
+
+ fun setReportInfo(type: ReportType, id: Long) {
+ reportType = type
+ reportId = id
}
fun setExitEvent() {
@@ -29,22 +39,74 @@ class ReportViewModel(private val repository: AuctionRepository) : ViewModel() {
}
fun submit() {
+ val reportId: Long = reportId ?: return
+ when (reportType) {
+ ReportType.ArticleReport -> reportAuctionArticle(reportId)
+ ReportType.MessageRoomReport -> reportMessageRoom(reportId)
+ }
+ }
+
+ private fun reportAuctionArticle(id: Long) {
+ if (isLoading) return
+ isLoading = true
viewModelScope.launch {
reportContents.value?.let { contents ->
if (contents.isEmpty()) return@launch setBlankContentsEvent() // 내용이 비어있는 경우
- val response = repository.reportAuction(auctionId ?: return@launch, contents)
- when (response) {
+ when (val response = repository.reportAuction(id, contents)) {
is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
- is ApiResponse.Failure -> {}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure -> {
+ _event.value =
+ ReportEvent.ReportArticleFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ ReportEvent.ReportArticleFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ ReportEvent.ReportArticleFailure(ErrorType.UNEXPECTED)
+ }
}
}
+ isLoading = false
}
}
+
+ private fun reportMessageRoom(id: Long) {
+ if (isLoading) return
+ isLoading = true
+ viewModelScope.launch {
+ reportContents.value?.let { contents ->
+ if (contents.isEmpty()) return@launch setBlankContentsEvent() // 내용이 비어있는 경우
+ when (val response = repository.reportMessageRoom(id, contents)) {
+ is ApiResponse.Success -> _event.value = ReportEvent.SubmitEvent // 정상적인 신고 접수
+ is ApiResponse.Failure -> {
+ _event.value =
+ ReportEvent.ReportMessageRoomFailure(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value =
+ ReportEvent.ReportMessageRoomFailure(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value =
+ ReportEvent.ReportMessageRoomFailure(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+ isLoading = false
+ }
+ }
+
sealed class ReportEvent {
object ExitEvent : ReportEvent()
object SubmitEvent : ReportEvent()
object BlankContentsEvent : ReportEvent()
+ data class ReportArticleFailure(val error: ErrorType) : ReportEvent()
+ data class ReportMessageRoomFailure(val error: ErrorType) : ReportEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
index e999ec006..91ab9a3e4 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchFragment.kt
@@ -1,7 +1,113 @@
package com.ddangddangddang.android.feature.search
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import androidx.fragment.app.viewModels
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.FragmentSearchBinding
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.home.AuctionAdapter
+import com.ddangddangddang.android.feature.home.AuctionSpaceItemDecoration
+import com.ddangddangddang.android.model.AuctionHomeModel
import com.ddangddangddang.android.util.binding.BindingFragment
+import com.ddangddangddang.android.util.view.Toaster
+import dagger.hilt.android.AndroidEntryPoint
-class SearchFragment : BindingFragment(R.layout.fragment_search)
+@AndroidEntryPoint
+class SearchFragment : BindingFragment(R.layout.fragment_search) {
+ private val viewModel: SearchViewModel by viewModels()
+ private val auctionAdapter = AuctionAdapter { auctionId ->
+ navigateToAuctionDetail(auctionId)
+ }
+ private val auctionScrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (viewModel.isLast) return
+
+ if (!viewModel.loadingAuctionInProgress) {
+ val lastVisibleItemPosition =
+ (binding.rvSearchAuctions.layoutManager as GridLayoutManager).findLastCompletelyVisibleItemPosition()
+ val auctionsSize = viewModel.auctions.value?.size ?: 0
+ if (lastVisibleItemPosition + 10 >= auctionsSize) {
+ viewModel.loadAuctions()
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setupAuctionRecyclerView()
+ setupKeyboard()
+ setupViewModel()
+ }
+
+ private fun setupAuctionRecyclerView() {
+ with(binding.rvSearchAuctions) {
+ adapter = auctionAdapter
+ addItemDecoration(
+ AuctionSpaceItemDecoration(
+ 2,
+ resources.getDimensionPixelSize(R.dimen.margin_side_layout),
+ ),
+ )
+ addOnScrollListener(auctionScrollListener)
+ }
+ }
+
+ private fun setupKeyboard() {
+ binding.etSearchKeyword.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_SEARCH) {
+ viewModel.submitKeyword()
+ return@setOnEditorActionListener true
+ }
+ return@setOnEditorActionListener false
+ }
+ }
+
+ private fun setupViewModel() {
+ binding.viewModel = viewModel
+ viewModel.auctions.observe(viewLifecycleOwner) {
+ changeAuctions(it)
+ }
+ viewModel.event.observe(viewLifecycleOwner) { event ->
+ when (event) {
+ SearchViewModel.SearchEvent.KeywordLimit -> notifyFailureMessage(
+ ErrorType.FAILURE(getString(R.string.search_notice_keyword_limit)),
+ )
+
+ is SearchViewModel.SearchEvent.LoadFailureNotice -> notifyFailureMessage(event.error)
+ }
+ }
+ }
+
+ private fun changeAuctions(auctions: List) {
+ auctionAdapter.submitList(auctions)
+ hideKeyboard()
+ }
+
+ private fun hideKeyboard() {
+ val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ imm.hideSoftInputFromWindow(binding.etSearchKeyword.windowToken, 0)
+ }
+
+ private fun notifyFailureMessage(type: ErrorType) {
+ Toaster.showShort(
+ requireContext(),
+ type.message ?: getString(R.string.search_notice_default_error),
+ )
+ }
+
+ private fun navigateToAuctionDetail(auctionId: Long) {
+ val intent = AuctionDetailActivity.getIntent(requireContext(), auctionId)
+ intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ startActivity(intent)
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchViewModel.kt
new file mode 100644
index 000000000..e75d95032
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/search/SearchViewModel.kt
@@ -0,0 +1,137 @@
+package com.ddangddangddang.android.feature.search
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
+import com.ddangddangddang.android.model.AuctionHomeModel
+import com.ddangddangddang.android.model.mapper.AuctionHomeModelMapper.toPresentation
+import com.ddangddangddang.android.util.livedata.SingleLiveEvent
+import com.ddangddangddang.data.model.response.AuctionPreviewsResponse
+import com.ddangddangddang.data.remote.ApiResponse
+import com.ddangddangddang.data.repository.AuctionRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SearchViewModel @Inject constructor(private val repository: AuctionRepository) : ViewModel() {
+ private val _event: SingleLiveEvent = SingleLiveEvent()
+ val event: LiveData
+ get() = _event
+
+ val keyword: MutableLiveData = MutableLiveData("")
+
+ private val _auctions: MutableLiveData> = MutableLiveData(emptyList())
+ val auctions: LiveData>
+ get() = _auctions
+
+ private var _loadingAuctionInProgress = false
+ val loadingAuctionInProgress: Boolean
+ get() = _loadingAuctionInProgress
+
+ private var _isLast = false
+ val isLast: Boolean
+ get() = _isLast
+
+ private var _page: Int = 0
+
+ private val _searchStatus: MutableLiveData =
+ MutableLiveData(SearchStatus.BeforeSearch)
+ val searchStatus: LiveData
+ get() = _searchStatus
+
+ fun loadAuctions() { // 무한 스크롤
+ if (!_loadingAuctionInProgress) fetchAuctions(_page + 1)
+ }
+
+ fun submitKeyword() { // 검색
+ if (!checkKeywordLimit()) return
+ if (!_loadingAuctionInProgress) fetchAuctions(DEFAULT_PAGE)
+ }
+
+ private fun checkKeywordLimit(): Boolean {
+ keyword.value?.let {
+ if (it.length !in KEYWORD_LENGTH_RANGE_MIN..KEYWORD_LENGTH_RANGE_MAX) {
+ _event.value = SearchEvent.KeywordLimit
+ return false
+ }
+ }
+ return true
+ }
+
+ private fun fetchAuctions(newPage: Int) {
+ viewModelScope.launch {
+ keyword.value?.let {
+ _loadingAuctionInProgress = true
+ when (
+ val response =
+ repository.getAuctionPreviewsByTitle(
+ page = newPage,
+ size = SIZE_AUCTION_LOAD,
+ title = it,
+ )
+ ) {
+ is ApiResponse.Success -> {
+ updateAuctions(response.body)
+ _page = newPage
+ }
+
+ is ApiResponse.Failure -> {
+ _event.value = SearchEvent.LoadFailureNotice(ErrorType.FAILURE(response.error))
+ }
+
+ is ApiResponse.NetworkError -> {
+ _event.value = SearchEvent.LoadFailureNotice(ErrorType.NETWORK_ERROR)
+ }
+
+ is ApiResponse.Unexpected -> {
+ _event.value = SearchEvent.LoadFailureNotice(ErrorType.UNEXPECTED)
+ }
+ }
+ _loadingAuctionInProgress = false
+
+ if (newPage == DEFAULT_PAGE) changeStatus() // 첫 검색인 경우 검색 상태 변경
+ }
+ }
+ }
+
+ private fun changeStatus() {
+ if (_auctions.value.isNullOrEmpty()) {
+ _searchStatus.value = SearchStatus.NoData
+ return
+ }
+ _searchStatus.value = SearchStatus.ExistData
+ }
+
+ private fun updateAuctions(response: AuctionPreviewsResponse) {
+ _auctions.value?.let { items ->
+ val newItems = response.auctions.map { it.toPresentation() }
+ _auctions.value = if (_page == DEFAULT_PAGE) {
+ newItems
+ } else {
+ items + newItems
+ }
+ _isLast = response.isLast
+ }
+ }
+
+ sealed class SearchEvent {
+ object KeywordLimit : SearchEvent()
+ class LoadFailureNotice(val error: ErrorType) : SearchEvent()
+ }
+
+ sealed class SearchStatus {
+ object BeforeSearch : SearchStatus()
+ object NoData : SearchStatus()
+ object ExistData : SearchStatus()
+ }
+
+ companion object {
+ private const val SIZE_AUCTION_LOAD = 20
+ private const val DEFAULT_PAGE = 1
+ private const val KEYWORD_LENGTH_RANGE_MIN = 2
+ private const val KEYWORD_LENGTH_RANGE_MAX = 20
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
index b1427e7ba..47c34ffb0 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashActivity.kt
@@ -1,21 +1,41 @@
package com.ddangddangddang.android.feature.splash
import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import com.ddangddangddang.android.R
import com.ddangddangddang.android.databinding.ActivitySplashBinding
-import com.ddangddangddang.android.feature.common.viewModelFactory
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.feature.login.LoginActivity
import com.ddangddangddang.android.feature.main.MainActivity
import com.ddangddangddang.android.util.binding.BindingActivity
+import com.ddangddangddang.android.util.view.Toaster
+import com.ddangddangddang.android.util.view.showDialog
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.install.model.UpdateAvailability
+import dagger.hilt.android.AndroidEntryPoint
+@AndroidEntryPoint
class SplashActivity : BindingActivity(R.layout.activity_splash) {
- private val viewModel: SplashViewModel by viewModels { viewModelFactory }
+ private val viewModel: SplashViewModel by viewModels()
+
+ private val appUpdateManager by lazy {
+ AppUpdateManagerFactory.create(this)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupObserve()
- viewModel.checkTokenExist()
+ appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
+ if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
+ requestUpdate()
+ } else {
+ viewModel.checkTokenExist()
+ }
+ }.addOnFailureListener {
+ viewModel.checkTokenExist()
+ }
}
private fun setupObserve() {
@@ -27,6 +47,10 @@ class SplashActivity : BindingActivity(R.layout.activity_
SplashViewModel.SplashEvent.AutoLoginSuccess -> navigateToMain()
SplashViewModel.SplashEvent.RefreshTokenExpired -> navigateToLogin()
SplashViewModel.SplashEvent.TokenNotExist -> navigateToLogin()
+ is SplashViewModel.SplashEvent.FailureStartDdangDdangDdang -> {
+ showErrorMessage(event.errorType)
+ finish()
+ }
}
}
@@ -39,4 +63,33 @@ class SplashActivity : BindingActivity(R.layout.activity_
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
+
+ private fun showErrorMessage(errorType: ErrorType) {
+ Toaster.showShort(
+ this,
+ errorType.message ?: getString(R.string.splash_app_default_error_message),
+ )
+ }
+
+ private fun requestUpdate() {
+ showDialog(
+ titleId = R.string.splash_app_update_request_dialog_title,
+ messageId = R.string.splash_app_update_request_dialog_message,
+ negativeStringId = R.string.all_dialog_default_negative_button,
+ positiveStringId = R.string.all_dialog_default_positive_button,
+ actionPositive = {
+ navigateToPlayStore()
+ finish()
+ },
+ actionNegative = {
+ Toaster.showShort(this, getString(R.string.splash_app_update_denied))
+ finish()
+ },
+ isCancelable = false,
+ )
+ }
+
+ private fun navigateToPlayStore() {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")))
+ }
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashViewModel.kt b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashViewModel.kt
index da73d0fae..175c37e75 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashViewModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/feature/splash/SplashViewModel.kt
@@ -3,12 +3,16 @@ package com.ddangddangddang.android.feature.splash
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ddangddangddang.android.feature.common.ErrorType
import com.ddangddangddang.android.util.livedata.SingleLiveEvent
import com.ddangddangddang.data.remote.ApiResponse
import com.ddangddangddang.data.repository.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
+import javax.inject.Inject
-class SplashViewModel(
+@HiltViewModel
+class SplashViewModel @Inject constructor(
private val repository: AuthRepository,
) : ViewModel() {
private val _event: SingleLiveEvent = SingleLiveEvent()
@@ -26,14 +30,50 @@ class SplashViewModel(
private fun verifyToken() {
viewModelScope.launch {
when (val response = repository.verifyToken()) {
- is ApiResponse.Success -> _event.value = SplashEvent.AutoLoginSuccess
- is ApiResponse.Failure -> {
- if (response.responseCode == 401) _event.value = SplashEvent.RefreshTokenExpired
+ is ApiResponse.Success -> {
+ if (response.body.validated) {
+ _event.value = SplashEvent.AutoLoginSuccess
+ } else {
+ refreshToken()
+ }
}
- is ApiResponse.NetworkError -> {}
- is ApiResponse.Unexpected -> {}
+ is ApiResponse.Failure ->
+ _event.value =
+ SplashEvent.FailureStartDdangDdangDdang(ErrorType.FAILURE(null))
+
+ is ApiResponse.NetworkError ->
+ _event.value =
+ SplashEvent.FailureStartDdangDdangDdang(ErrorType.NETWORK_ERROR)
+
+ is ApiResponse.Unexpected ->
+ _event.value =
+ SplashEvent.FailureStartDdangDdangDdang(ErrorType.UNEXPECTED)
+ }
+ }
+ }
+
+ private suspend fun refreshToken() {
+ when (val response = repository.refreshToken()) {
+ is ApiResponse.Success -> {
+ _event.value = SplashEvent.AutoLoginSuccess
}
+
+ is ApiResponse.Failure -> {
+ if (response.responseCode == 401) {
+ _event.value = SplashEvent.RefreshTokenExpired
+ } else {
+ _event.value = SplashEvent.FailureStartDdangDdangDdang(ErrorType.FAILURE(null))
+ }
+ }
+
+ is ApiResponse.NetworkError ->
+ _event.value =
+ SplashEvent.FailureStartDdangDdangDdang(ErrorType.NETWORK_ERROR)
+
+ is ApiResponse.Unexpected ->
+ _event.value =
+ SplashEvent.FailureStartDdangDdangDdang(ErrorType.UNEXPECTED)
}
}
@@ -41,5 +81,6 @@ class SplashViewModel(
object TokenNotExist : SplashEvent()
object RefreshTokenExpired : SplashEvent()
object AutoLoginSuccess : SplashEvent()
+ data class FailureStartDdangDdangDdang(val errorType: ErrorType) : SplashEvent()
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/global/DdangDdangDdang.kt b/android/app/src/main/java/com/ddangddangddang/android/global/DdangDdangDdang.kt
index ad4cad55c..e861bbe30 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/global/DdangDdangDdang.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/global/DdangDdangDdang.kt
@@ -1,37 +1,33 @@
package com.ddangddangddang.android.global
import android.app.Application
+import android.content.res.Resources
import com.ddangddangddang.android.BuildConfig
-import com.ddangddangddang.data.remote.AuctionRetrofit
-import com.ddangddangddang.data.remote.AuthRetrofit
-import com.ddangddangddang.data.repository.AuthRepositoryImpl
+import com.ddangddangddang.android.notification.createNotificationChannel
import com.google.firebase.analytics.FirebaseAnalytics
import com.kakao.sdk.common.KakaoSdk
+import dagger.hilt.android.HiltAndroidApp
+@HiltAndroidApp
class DdangDdangDdang : Application() {
+ var activeMessageRoomId: Long? = null
+
override fun onCreate() {
super.onCreate()
_firebaseAnalytics = FirebaseAnalytics.getInstance(this)
-
- _authRepository =
- AuthRepositoryImpl.getInstance(this, AuthRetrofit.getInstance().service)
-
- _auctionRetrofit = AuctionRetrofit.getInstance(authRepository)
+ _resources = resources
KakaoSdk.init(this, BuildConfig.KEY_KAKAO)
+
+ createNotificationChannel(this)
}
companion object {
- private var _firebaseAnalytics: FirebaseAnalytics? = null
- val firebaseAnalytics: FirebaseAnalytics?
+ private lateinit var _firebaseAnalytics: FirebaseAnalytics
+ val firebaseAnalytics: FirebaseAnalytics
get() = _firebaseAnalytics
-
- private var _authRepository: AuthRepositoryImpl? = null
- val authRepository: AuthRepositoryImpl
- get() = _authRepository ?: throw NullPointerException("AuthRepository가 존재하지 않습니다.")
-
- private var _auctionRetrofit: AuctionRetrofit? = null
- val auctionRetrofit: AuctionRetrofit
- get() = _auctionRetrofit ?: throw NullPointerException("AunctionRetrofit이 존재하지 않습니다.")
+ private lateinit var _resources: Resources
+ val resources: Resources
+ get() = _resources
}
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
index 6ff6c4f39..b81b95378 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailModel.kt
@@ -1,7 +1,6 @@
package com.ddangddangddang.android.model
import java.time.LocalDateTime
-import java.util.GregorianCalendar
data class AuctionDetailModel(
val id: Long,
@@ -21,36 +20,4 @@ data class AuctionDetailModel(
val sellerModel: SellerModel,
val chatAuctionDetailModel: ChatAuctionDetailModel,
val isOwner: Boolean,
-) {
- val remainTime: String
- get() {
- val nowCalendar = LocalDateTime.now().toCalendar()
- val nowDT = nowCalendar.time
-
- val closingCalendar = closingTime.toCalendar()
- val closingDT = closingCalendar.time
-
- val differenceInMills = closingDT.time - nowDT.time
-
- val days = (differenceInMills / (24 * 60 * 60 * 1000L)) % 365
- val hours = (differenceInMills / (60 * 60 * 1000L)) % 24
- val minutes = (differenceInMills / (60 * 1000L)) % 60
-
- return buildString {
- if (days > 0L) append("${days}일")
- if (hours > 0L) append(" ${hours}시간")
- if (minutes > 0L) append(" ${minutes}분")
- }.trim()
- }
-
- private fun LocalDateTime.toCalendar(): GregorianCalendar {
- return GregorianCalendar(
- year,
- monthValue,
- dayOfMonth,
- hour,
- minute,
- second,
- )
- }
-}
+)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailStatusModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailStatusModel.kt
index cf6beba85..f21537cad 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailStatusModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionDetailStatusModel.kt
@@ -8,10 +8,10 @@ enum class AuctionDetailStatusModel(
val progressStatus: String,
@ColorRes val colorId: Int,
) {
- ONGOING("현재가", "경매중", R.color.red_100),
+ ONGOING("현재가", "경매중", R.color.red_300),
UNBIDDEN("입찰전", "경매중", R.color.green),
- SUCCESS("낙찰가", "낙찰 완료", R.color.black_600),
- FAILURE("입찰전", "경매 유찰", R.color.black_600),
+ SUCCESS("낙찰가", "낙찰 완료", R.color.grey_700),
+ FAILURE("입찰전", "경매 유찰", R.color.grey_700),
;
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionHomeStatusModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionHomeStatusModel.kt
index 7e6f24605..50e166fba 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/AuctionHomeStatusModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/AuctionHomeStatusModel.kt
@@ -9,10 +9,10 @@ enum class AuctionHomeStatusModel(
@StringRes val progressStatusId: Int,
@ColorRes val colorId: Int,
) {
- ONGOING(R.string.all_current_price, R.string.all_auction_ongoing, R.color.red_100),
+ ONGOING(R.string.all_current_price, R.string.all_auction_ongoing, R.color.red_300),
UNBIDDEN(R.string.all_start_price, R.string.all_auction_ongoing, R.color.green),
- SUCCESS(R.string.all_winning_bid_price, R.string.all_auction_success, R.color.black_600),
- FAILURE(R.string.all_start_price, R.string.all_auction_failure, R.color.black_600),
+ SUCCESS(R.string.all_winning_bid_price, R.string.all_auction_success, R.color.grey_700),
+ FAILURE(R.string.all_start_price, R.string.all_auction_failure, R.color.grey_700),
;
companion object {
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/MessageModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/MessageModel.kt
index d1ac23981..47a5cafea 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/MessageModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/MessageModel.kt
@@ -1,8 +1,10 @@
package com.ddangddangddang.android.model
+import java.time.LocalDateTime
+
data class MessageModel(
val id: Long,
- val createdDateTime: String,
+ val createdDateTime: LocalDateTime,
val isMyMessage: Boolean,
val contents: String,
)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/ProfileModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/ProfileModel.kt
index ce14c8105..8a8eca98b 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/ProfileModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/ProfileModel.kt
@@ -1,7 +1,11 @@
package com.ddangddangddang.android.model
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
data class ProfileModel(
val name: String,
val profileImage: String?,
- val reliability: Double,
-)
+ val reliability: Float?,
+) : Parcelable
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt b/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt
new file mode 100644
index 000000000..04ffc5304
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/ReportType.kt
@@ -0,0 +1,5 @@
+package com.ddangddangddang.android.model
+
+enum class ReportType {
+ ArticleReport, MessageRoomReport
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt b/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
index 9f1248038..27ec59546 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/SellerModel.kt
@@ -4,5 +4,5 @@ data class SellerModel(
val id: Long,
val profileUrl: String,
val nickname: String,
- val reliability: Double,
+ val reliability: Float?,
)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/model/mapper/MessageModelMapper.kt b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/MessageModelMapper.kt
index ca3d8a33c..3fee9bf88 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/model/mapper/MessageModelMapper.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/model/mapper/MessageModelMapper.kt
@@ -3,14 +3,12 @@ package com.ddangddangddang.android.model.mapper
import com.ddangddangddang.android.model.MessageModel
import com.ddangddangddang.data.model.response.ChatMessageResponse
import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
object MessageModelMapper : Mapper {
- private val formatter = DateTimeFormatter.ofPattern("h:mm a")
override fun ChatMessageResponse.toPresentation(): MessageModel {
return MessageModel(
id,
- LocalDateTime.parse(createdAt).format(formatter),
+ LocalDateTime.parse(createdAt),
isMyMessage,
contents,
)
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/ActiveNotificationManager.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/ActiveNotificationManager.kt
new file mode 100644
index 000000000..a332b25dc
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/ActiveNotificationManager.kt
@@ -0,0 +1,17 @@
+package com.ddangddangddang.android.notification
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.Context
+
+internal fun Context.getActiveNotification(tag: String, id: Int): Notification? {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ return notificationManager.activeNotifications.firstOrNull {
+ it.tag == tag && it.id == id
+ }?.notification
+}
+
+internal fun Context.cancelActiveNotification(tag: String, id: Int) {
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.cancel(tag, id)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/DdangDdangDdangFirebaseMessagingService.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/DdangDdangDdangFirebaseMessagingService.kt
new file mode 100644
index 000000000..590b842d7
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/DdangDdangDdangFirebaseMessagingService.kt
@@ -0,0 +1,184 @@
+package com.ddangddangddang.android.notification
+
+import android.Manifest
+import android.app.Notification
+import android.app.Notification.EXTRA_TEXT_LINES
+import android.app.Notification.InboxStyle
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_IMMUTABLE
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Build
+import com.bumptech.glide.Glide
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.feature.detail.AuctionDetailActivity
+import com.ddangddangddang.android.feature.messageRoom.MessageRoomActivity
+import com.ddangddangddang.android.global.DdangDdangDdang
+import com.ddangddangddang.android.reciever.MessageReceiver
+import com.ddangddangddang.data.model.request.UpdateDeviceTokenRequest
+import com.ddangddangddang.data.repository.UserRepository
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class DdangDdangDdangFirebaseMessagingService : FirebaseMessagingService() {
+ @Inject
+ lateinit var userRepository: UserRepository
+
+ private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
+
+ private val defaultImage: Bitmap by lazy {
+ BitmapFactory.decodeResource(
+ resources,
+ R.drawable.img_default_profile,
+ )
+ }
+
+ override fun onNewToken(token: String) {
+ runBlocking {
+ withContext(Dispatchers.IO) {
+ val deviceTokenRequest = UpdateDeviceTokenRequest(token)
+ userRepository.updateDeviceToken(deviceTokenRequest)
+ }
+ }
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ if (remoteMessage.data.isNotEmpty()) {
+ notifyNotification(remoteMessage)
+ }
+ }
+
+ private fun notifyNotification(remoteMessage: RemoteMessage) {
+ if (checkNotificationPermission()) {
+ val type = NotificationType.of(remoteMessage.data["type"] ?: "") ?: return
+ val tag = type.name
+ val id = remoteMessage.data["redirectUrl"]?.split("/")?.last()?.toLong() ?: -1
+ when (type) {
+ NotificationType.MESSAGE -> {
+ val activeRoomId = (application as DdangDdangDdang).activeMessageRoomId
+ if (activeRoomId == id) {
+ sendBroadcastToMessageReceiver(id)
+ } else {
+ val notification = createMessageNotification(tag, id, remoteMessage)
+ notificationManager.notify(tag, id.toInt(), notification)
+ }
+ }
+
+ NotificationType.BID -> {
+ val notification = createBidNotification(id, remoteMessage)
+ notificationManager.notify(tag, id.toInt(), notification)
+ }
+ }
+ }
+ }
+
+ private fun checkNotificationPermission(): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+ }
+ return notificationManager.areNotificationsEnabled()
+ }
+
+ private fun createMessageNotification(
+ tag: String,
+ id: Long,
+ remoteMessage: RemoteMessage,
+ ): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val activeNotification = getActiveNotification(tag, id.toInt())
+ val currentLine = remoteMessage.data["body"] ?: ""
+ val pendingIntent =
+ activeNotification?.contentIntent ?: getMessageRoomPendingIntent(id)
+
+ Notification.Builder(applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setShowWhen(true)
+ setContentTitle(remoteMessage.data["title"])
+ setContentText(currentLine)
+ style = getMessageInboxStyle(activeNotification, currentLine)
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+
+ private suspend fun getBitmapFromUrl(url: String): Bitmap {
+ return withContext(Dispatchers.IO) {
+ Glide.with(applicationContext)
+ .asBitmap()
+ .load(url)
+ .submit()
+ .get()
+ }
+ }
+
+ private fun getMessageInboxStyle(
+ activeNotification: Notification?,
+ currentLine: String,
+ ): InboxStyle {
+ val previousLines =
+ activeNotification?.extras?.getCharSequenceArray(EXTRA_TEXT_LINES) ?: emptyArray()
+ val lines = previousLines.plus(currentLine)
+
+ return InboxStyle().apply {
+ lines.forEach { addLine(it) }
+ setSummaryText("${lines.size}개의 메시지")
+ }
+ }
+
+ private fun getMessageRoomPendingIntent(id: Long): PendingIntent? {
+ val intent = MessageRoomActivity.getIntent(applicationContext, id)
+ return intent.getPendingIntent(id.toInt())
+ }
+
+ private fun Intent.getPendingIntent(requestCode: Int): PendingIntent? {
+ return PendingIntent.getActivity(
+ applicationContext,
+ requestCode,
+ this,
+ FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun sendBroadcastToMessageReceiver(roomId: Long) {
+ val intent = MessageReceiver.getIntent(roomId)
+ sendBroadcast(intent)
+ }
+
+ private fun createBidNotification(id: Long, remoteMessage: RemoteMessage): Notification {
+ return runBlocking {
+ val image = runCatching {
+ getBitmapFromUrl(remoteMessage.data["image"] ?: "")
+ }.getOrDefault(defaultImage)
+ val pendingIntent = getAuctionDetailPendingIntent(id)
+
+ Notification.Builder(applicationContext, CHANNEL_ID).apply {
+ setSmallIcon(R.drawable.img_logo)
+ setLargeIcon(image)
+ setContentTitle(remoteMessage.data["title"])
+ setContentText(remoteMessage.data["body"])
+ setContentIntent(pendingIntent)
+ setAutoCancel(true)
+ }.build()
+ }
+ }
+
+ private fun getAuctionDetailPendingIntent(id: Long): PendingIntent? {
+ val intent = AuctionDetailActivity.getIntent(applicationContext, id)
+ return intent.getPendingIntent(id.toInt())
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationChannel.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationChannel.kt
new file mode 100644
index 000000000..6de9a4ea6
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationChannel.kt
@@ -0,0 +1,19 @@
+package com.ddangddangddang.android.notification
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Context.NOTIFICATION_SERVICE
+import com.ddangddangddang.android.R
+
+const val CHANNEL_ID = "DDANGDDANGDDANG_CHANNEL_ID"
+
+fun createNotificationChannel(context: Context) {
+ val name = context.getString(R.string.alarm_channel_name)
+ val descriptionText = context.getString(R.string.alarm_channel_description)
+ val importance = NotificationManager.IMPORTANCE_HIGH
+ val mChannel = NotificationChannel(CHANNEL_ID, name, importance)
+ mChannel.description = descriptionText
+ val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(mChannel)
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt b/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt
new file mode 100644
index 000000000..5c66943c2
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/notification/NotificationType.kt
@@ -0,0 +1,11 @@
+package com.ddangddangddang.android.notification
+
+enum class NotificationType(private val value: String) {
+ MESSAGE("message"), BID("bid");
+
+ companion object {
+ fun of(value: String): NotificationType? {
+ return values().find { it.value == value }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/reciever/MessageReceiver.kt b/android/app/src/main/java/com/ddangddangddang/android/reciever/MessageReceiver.kt
new file mode 100644
index 000000000..d6a96f5fb
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/reciever/MessageReceiver.kt
@@ -0,0 +1,28 @@
+package com.ddangddangddang.android.reciever
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+
+class MessageReceiver(private val onReceive: (messageRoomId: Long) -> Unit) : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (MessageAction == intent?.action) {
+ val messageRoomId = intent.getLongExtra(MessageRoomId, -1L)
+ onReceive(messageRoomId)
+ }
+ }
+
+ companion object {
+ private const val MessageAction = "com.ddangddangddang.android.message.receive.action"
+ private const val MessageRoomId = "message_room_id"
+
+ fun getIntent(messageRoomId: Long): Intent {
+ return Intent(MessageAction).apply { putExtra(MessageRoomId, messageRoomId) }
+ }
+
+ fun getIntentFilter(): IntentFilter {
+ return IntentFilter().apply { addAction(MessageAction) }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/compat/ParcelableCompat.kt b/android/app/src/main/java/com/ddangddangddang/android/util/compat/ParcelableCompat.kt
index 792ed8338..7cb2d1226 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/util/compat/ParcelableCompat.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/compat/ParcelableCompat.kt
@@ -4,10 +4,12 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
+import androidx.core.os.BundleCompat
inline fun Intent.getParcelableCompat(key: String): T? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- return getParcelableExtra(key, T::class.java)
+ val bundle = extras ?: return null
+ return BundleCompat.getParcelable(bundle, key, T::class.java)
}
@Suppress("DEPRECATION")
return getParcelableExtra(key) as? T
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/image/UriOptimizeUtil.kt b/android/app/src/main/java/com/ddangddangddang/android/util/image/UriOptimizeUtil.kt
new file mode 100644
index 000000000..232e56199
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/image/UriOptimizeUtil.kt
@@ -0,0 +1,50 @@
+package com.ddangddangddang.android.util.image
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
+
+fun Uri.toAdjustImageFile(context: Context): File? {
+ val bitmap = toBitmap(context) ?: return null
+
+ val file = createAdjustImageFile(bitmap, context.cacheDir)
+
+ val orientation = context.contentResolver
+ .openInputStream(this)?.use {
+ ExifInterface(it)
+ }?.getAttribute(ExifInterface.TAG_ORIENTATION)
+ orientation?.let { file.setOrientation(it) }
+
+ return file
+}
+
+private fun Uri.toBitmap(context: Context): Bitmap? {
+ return context.contentResolver
+ .openInputStream(this)?.use {
+ BitmapFactory.decodeStream(it)
+ }
+}
+
+private fun createAdjustImageFile(bitmap: Bitmap, directory: File): File {
+ val byteArrayOutputStream = ByteArrayOutputStream()
+ Bitmap.createScaledBitmap(bitmap, bitmap.width / 2, bitmap.height / 2, true)
+ .compress(Bitmap.CompressFormat.JPEG, 90, byteArrayOutputStream)
+
+ val tempFile = File.createTempFile("resized_image", ".jpg", directory)
+ FileOutputStream(tempFile).use {
+ it.write(byteArrayOutputStream.toByteArray())
+ }
+ return tempFile
+}
+
+private fun File.setOrientation(orientation: String) {
+ ExifInterface(this.path).apply {
+ setAttribute(ExifInterface.TAG_ORIENTATION, orientation)
+ saveAttributes()
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/view/BackKeyHandler.kt b/android/app/src/main/java/com/ddangddangddang/android/util/view/BackKeyHandler.kt
new file mode 100644
index 000000000..2f2f9fb21
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/view/BackKeyHandler.kt
@@ -0,0 +1,16 @@
+package com.ddangddangddang.android.util.view
+
+import android.app.Activity
+import com.ddangddangddang.android.R
+
+class BackKeyHandler(val activity: Activity) {
+ private var backPressedTime = 0L
+ fun onBackPressed() {
+ if ((System.currentTimeMillis() - backPressedTime) > 2000L) {
+ backPressedTime = System.currentTimeMillis()
+ Toaster.showShort(activity, activity.getString(R.string.all_back_key_check_message))
+ } else {
+ activity.finish()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/view/Converter.kt b/android/app/src/main/java/com/ddangddangddang/android/util/view/Converter.kt
new file mode 100644
index 000000000..bd778c899
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/view/Converter.kt
@@ -0,0 +1,8 @@
+package com.ddangddangddang.android.util.view
+
+import android.content.res.Resources
+
+fun convertDpToPx(dp: Float): Int {
+ val density = Resources.getSystem().displayMetrics.density
+ return (dp * density + 0.5f).toInt()
+}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/view/DialogFactory.kt b/android/app/src/main/java/com/ddangddangddang/android/util/view/DialogFactory.kt
index 048a993e1..6816cb0df 100644
--- a/android/app/src/main/java/com/ddangddangddang/android/util/view/DialogFactory.kt
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/view/DialogFactory.kt
@@ -10,7 +10,7 @@ fun Activity.showDialog(
@StringRes
titleId: Int? = null,
@StringRes
- messageId: Int,
+ messageId: Int? = null,
@StringRes
negativeStringId: Int? = null,
@StringRes
@@ -21,7 +21,7 @@ fun Activity.showDialog(
) {
AlertDialog.Builder(this).apply {
titleId?.let { setTitle(getString(it)) }
- setMessage(getString(messageId))
+ messageId?.let { setMessage(getString(messageId)) }
negativeStringId?.let {
setNegativeButton(negativeStringId) { _, _ ->
actionNegative()
@@ -36,24 +36,28 @@ fun Activity.showDialog(
fun Fragment.showDialog(
@StringRes
- titleId: Int,
+ titleId: Int? = null,
@StringRes
- messageId: Int,
+ messageId: Int? = null,
@StringRes
- negativeStringId: Int,
+ negativeStringId: Int? = null,
@StringRes
- positiveStringId: Int,
+ positiveStringId: Int = R.string.all_dialog_default_positive_button,
actionNegative: () -> Unit = {},
actionPositive: () -> Unit = {},
+ isCancelable: Boolean = true,
) {
- AlertDialog.Builder(requireContext())
- .setTitle(getString(titleId))
- .setMessage(getString(messageId))
- .setNegativeButton(negativeStringId) { _, _ ->
- actionNegative()
+ AlertDialog.Builder(requireContext()).apply {
+ titleId?.let { setTitle(getString(titleId)) }
+ messageId?.let { setMessage(getString(messageId)) }
+ negativeStringId?.let {
+ setNegativeButton(negativeStringId) { _, _ ->
+ actionNegative()
+ }
}
- .setPositiveButton(positiveStringId) { _, _ ->
+ setPositiveButton(positiveStringId) { _, _ ->
actionPositive()
}
- .show()
+ setCancelable(isCancelable)
+ }.show()
}
diff --git a/android/app/src/main/java/com/ddangddangddang/android/util/view/LoadingDialog.kt b/android/app/src/main/java/com/ddangddangddang/android/util/view/LoadingDialog.kt
new file mode 100644
index 000000000..4f3c23550
--- /dev/null
+++ b/android/app/src/main/java/com/ddangddangddang/android/util/view/LoadingDialog.kt
@@ -0,0 +1,50 @@
+package com.ddangddangddang.android.util.view
+
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.View
+import android.view.ViewGroup
+import android.view.Window
+import android.view.WindowManager
+import androidx.annotation.RawRes
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import com.ddangddangddang.android.R
+import com.ddangddangddang.android.databinding.ViewLoadingDialogBinding
+
+class LoadingDialog(context: Context, @RawRes rawResId: Int) : Dialog(context) {
+ private val binding: ViewLoadingDialogBinding = ViewLoadingDialogBinding.inflate(layoutInflater)
+
+ init {
+ window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) // dialog의 dim 처리 배경 제거
+ requestWindowFeature(Window.FEATURE_NO_TITLE)
+ setContentView(binding.root)
+ setCancelable(false)
+ binding.lavLoading.setAnimation(rawResId)
+ }
+}
+
+fun Context.observeLoadingWithDialog(
+ lifecycleOwner: LifecycleOwner,
+ loadingLiveData: LiveData,
+ containerViewGroup: ViewGroup? = null,
+ @RawRes loadingAnimationResId: Int = R.raw.default_loading,
+ onLoadingStarted: () -> Unit = {},
+ onLoadingFinished: () -> Unit = {},
+) {
+ val loadingDialog = LoadingDialog(this, loadingAnimationResId)
+ loadingLiveData.observe(lifecycleOwner) { isLoading ->
+ if (isLoading) {
+ loadingDialog.show()
+ containerViewGroup?.visibility = View.GONE
+ onLoadingStarted()
+ } else {
+ containerViewGroup?.visibility = View.VISIBLE
+ loadingDialog.dismiss()
+ onLoadingFinished()
+ }
+ }
+}
diff --git a/android/app/src/main/res/color-night/grey_500_to_primary_checked.xml b/android/app/src/main/res/color-night/grey_500_to_primary_checked.xml
new file mode 100644
index 000000000..1d1183422
--- /dev/null
+++ b/android/app/src/main/res/color-night/grey_500_to_primary_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/white_to_red_60_selected.xml b/android/app/src/main/res/color-night/grey_50_to_primary_checked.xml
similarity index 54%
rename from android/app/src/main/res/color/white_to_red_60_selected.xml
rename to android/app/src/main/res/color-night/grey_50_to_primary_checked.xml
index 70650ee1c..eb54ed940 100644
--- a/android/app/src/main/res/color/white_to_red_60_selected.xml
+++ b/android/app/src/main/res/color-night/grey_50_to_primary_checked.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/android/app/src/main/res/color-night/grey_50_to_primary_selected.xml b/android/app/src/main/res/color-night/grey_50_to_primary_selected.xml
new file mode 100644
index 000000000..25bc7d361
--- /dev/null
+++ b/android/app/src/main/res/color-night/grey_50_to_primary_selected.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color-night/grey_50_to_selected_second_region_bg_selected.xml b/android/app/src/main/res/color-night/grey_50_to_selected_second_region_bg_selected.xml
new file mode 100644
index 000000000..266685c42
--- /dev/null
+++ b/android/app/src/main/res/color-night/grey_50_to_selected_second_region_bg_selected.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color-night/grey_800_to_navigation_active_checked.xml b/android/app/src/main/res/color-night/grey_800_to_navigation_active_checked.xml
new file mode 100644
index 000000000..d15e7ce0c
--- /dev/null
+++ b/android/app/src/main/res/color-night/grey_800_to_navigation_active_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color-night/grey_900_to_chip_text_active_checked.xml b/android/app/src/main/res/color-night/grey_900_to_chip_text_active_checked.xml
new file mode 100644
index 000000000..5008c3244
--- /dev/null
+++ b/android/app/src/main/res/color-night/grey_900_to_chip_text_active_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/black_400_to_red_100_checked.xml b/android/app/src/main/res/color/black_400_to_red_100_checked.xml
deleted file mode 100644
index 1643e2f83..000000000
--- a/android/app/src/main/res/color/black_400_to_red_100_checked.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/color/black_to_white_checked.xml b/android/app/src/main/res/color/black_to_white_checked.xml
deleted file mode 100644
index 4d75670b7..000000000
--- a/android/app/src/main/res/color/black_to_white_checked.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/color/grey_500_to_primary_checked.xml b/android/app/src/main/res/color/grey_500_to_primary_checked.xml
new file mode 100644
index 000000000..1d1183422
--- /dev/null
+++ b/android/app/src/main/res/color/grey_500_to_primary_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/white_to_red_100_selected.xml b/android/app/src/main/res/color/grey_50_to_primary_checked.xml
similarity index 50%
rename from android/app/src/main/res/color/white_to_red_100_selected.xml
rename to android/app/src/main/res/color/grey_50_to_primary_checked.xml
index 9e522d204..eb54ed940 100644
--- a/android/app/src/main/res/color/white_to_red_100_selected.xml
+++ b/android/app/src/main/res/color/grey_50_to_primary_checked.xml
@@ -1,5 +1,5 @@
-
-
+
+
diff --git a/android/app/src/main/res/color/grey_50_to_primary_selected.xml b/android/app/src/main/res/color/grey_50_to_primary_selected.xml
new file mode 100644
index 000000000..25bc7d361
--- /dev/null
+++ b/android/app/src/main/res/color/grey_50_to_primary_selected.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/grey_50_to_selected_second_region_bg_selected.xml b/android/app/src/main/res/color/grey_50_to_selected_second_region_bg_selected.xml
new file mode 100644
index 000000000..266685c42
--- /dev/null
+++ b/android/app/src/main/res/color/grey_50_to_selected_second_region_bg_selected.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/grey_800_to_navigation_active_checked.xml b/android/app/src/main/res/color/grey_800_to_navigation_active_checked.xml
new file mode 100644
index 000000000..d15e7ce0c
--- /dev/null
+++ b/android/app/src/main/res/color/grey_800_to_navigation_active_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/grey_900_to_chip_text_active_checked.xml b/android/app/src/main/res/color/grey_900_to_chip_text_active_checked.xml
new file mode 100644
index 000000000..5008c3244
--- /dev/null
+++ b/android/app/src/main/res/color/grey_900_to_chip_text_active_checked.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/white_to_red_100_checked.xml b/android/app/src/main/res/color/white_to_red_100_checked.xml
deleted file mode 100644
index dcb479b2d..000000000
--- a/android/app/src/main/res/color/white_to_red_100_checked.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable-night/img_logo.png b/android/app/src/main/res/drawable-night/img_logo.png
new file mode 100644
index 000000000..917620087
Binary files /dev/null and b/android/app/src/main/res/drawable-night/img_logo.png differ
diff --git a/android/app/src/main/res/drawable/bg_detail_gray_normal_dot_to_white_selected_dot.xml b/android/app/src/main/res/drawable/bg_detail_gray_normal_dot_to_white_selected_dot.xml
new file mode 100644
index 000000000..f7cc56a20
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_detail_gray_normal_dot_to_white_selected_dot.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_gray_radius_24dp.xml b/android/app/src/main/res/drawable/bg_gray_radius_24dp.xml
new file mode 100644
index 000000000..c33b03a42
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_gray_radius_24dp.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_black_400_radius_25dp.xml b/android/app/src/main/res/drawable/bg_grey_500_radius_25dp.xml
similarity index 75%
rename from android/app/src/main/res/drawable/bg_black_400_radius_25dp.xml
rename to android/app/src/main/res/drawable/bg_grey_500_radius_25dp.xml
index ba13b6e56..17cbb0d0d 100644
--- a/android/app/src/main/res/drawable/bg_black_400_radius_25dp.xml
+++ b/android/app/src/main/res/drawable/bg_grey_500_radius_25dp.xml
@@ -1,5 +1,5 @@
-
+
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/drawable/bg_red_radius_24dp.xml b/android/app/src/main/res/drawable/bg_red_radius_24dp.xml
new file mode 100644
index 000000000..9f6f2c78c
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_red_radius_24dp.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_stroke_gray_radius_1dp.xml b/android/app/src/main/res/drawable/bg_stroke_gray_radius_1dp.xml
index 3b84fc1e3..bd48bf66f 100644
--- a/android/app/src/main/res/drawable/bg_stroke_gray_radius_1dp.xml
+++ b/android/app/src/main/res/drawable/bg_stroke_gray_radius_1dp.xml
@@ -3,6 +3,6 @@
android:shape="rectangle">
+ android:color="@color/grey_500" />
diff --git a/android/app/src/main/res/drawable/bg_white_grey_radius_10dp.xml b/android/app/src/main/res/drawable/bg_white_grey_radius_10dp.xml
new file mode 100644
index 000000000..69190eecd
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_white_grey_radius_10dp.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/bg_white_black_radius_24dp.xml b/android/app/src/main/res/drawable/bg_white_grey_radius_24dp.xml
similarity index 68%
rename from android/app/src/main/res/drawable/bg_white_black_radius_24dp.xml
rename to android/app/src/main/res/drawable/bg_white_grey_radius_24dp.xml
index 476115ab5..44f72403f 100644
--- a/android/app/src/main/res/drawable/bg_white_black_radius_24dp.xml
+++ b/android/app/src/main/res/drawable/bg_white_grey_radius_24dp.xml
@@ -1,9 +1,9 @@
-
+
-
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/drawable/ic_alarm_24.xml b/android/app/src/main/res/drawable/ic_alarm_24.xml
new file mode 100644
index 000000000..d4d562a7b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_alarm_24.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_camera_20_18.xml b/android/app/src/main/res/drawable/ic_camera_20_18.xml
new file mode 100644
index 000000000..11d5d2eb8
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_camera_20_18.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_delete_24.xml b/android/app/src/main/res/drawable/ic_delete_24.xml
index 46d7f095f..31b28a72e 100644
--- a/android/app/src/main/res/drawable/ic_delete_24.xml
+++ b/android/app/src/main/res/drawable/ic_delete_24.xml
@@ -1,4 +1,4 @@
-
diff --git a/android/app/src/main/res/drawable/ic_detail_dot_gray_normal.xml b/android/app/src/main/res/drawable/ic_detail_dot_gray_normal.xml
new file mode 100644
index 000000000..e1338d3c5
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_detail_dot_gray_normal.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_detail_dot_normal.xml b/android/app/src/main/res/drawable/ic_detail_dot_normal.xml
index cb206cc4b..1023ff682 100644
--- a/android/app/src/main/res/drawable/ic_detail_dot_normal.xml
+++ b/android/app/src/main/res/drawable/ic_detail_dot_normal.xml
@@ -4,5 +4,5 @@
android:shape="ring"
android:thickness="4dp"
android:useLevel="false">
-
+
diff --git a/android/app/src/main/res/drawable/ic_detail_dot_selected.xml b/android/app/src/main/res/drawable/ic_detail_dot_selected.xml
index 3ffce7fb9..f6f4e46aa 100644
--- a/android/app/src/main/res/drawable/ic_detail_dot_selected.xml
+++ b/android/app/src/main/res/drawable/ic_detail_dot_selected.xml
@@ -4,5 +4,5 @@
android:shape="ring"
android:thickness="4dp"
android:useLevel="false">
-
+
diff --git a/android/app/src/main/res/drawable/ic_detail_dot_white_selected.xml b/android/app/src/main/res/drawable/ic_detail_dot_white_selected.xml
new file mode 100644
index 000000000..bdca039e1
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_detail_dot_white_selected.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_edittext_cursor.xml b/android/app/src/main/res/drawable/ic_edittext_cursor.xml
index 168b9a1b4..0855dc2e8 100644
--- a/android/app/src/main/res/drawable/ic_edittext_cursor.xml
+++ b/android/app/src/main/res/drawable/ic_edittext_cursor.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/android/app/src/main/res/drawable/ic_rate_24.xml b/android/app/src/main/res/drawable/ic_rate_24.xml
new file mode 100644
index 000000000..7465b7321
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_rate_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_star_18.xml b/android/app/src/main/res/drawable/ic_star_18.xml
index e621897b7..a9bc2ebb4 100644
--- a/android/app/src/main/res/drawable/ic_star_18.xml
+++ b/android/app/src/main/res/drawable/ic_star_18.xml
@@ -5,5 +5,5 @@
android:viewportHeight="17">
+ android:fillColor="@color/yellow"/>
diff --git a/android/app/src/main/res/drawable/img_logo.png b/android/app/src/main/res/drawable/img_logo.png
index ce3419c1a..9a3459585 100644
Binary files a/android/app/src/main/res/drawable/img_logo.png and b/android/app/src/main/res/drawable/img_logo.png differ
diff --git a/android/app/src/main/res/font/pretendard.xml b/android/app/src/main/res/font/pretendard.xml
new file mode 100644
index 000000000..52b84ef57
--- /dev/null
+++ b/android/app/src/main/res/font/pretendard.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/font/pretendard_bold.otf b/android/app/src/main/res/font/pretendard_bold.otf
new file mode 100644
index 000000000..e6d6ce882
Binary files /dev/null and b/android/app/src/main/res/font/pretendard_bold.otf differ
diff --git a/android/app/src/main/res/font/pretendard_regular.otf b/android/app/src/main/res/font/pretendard_regular.otf
new file mode 100644
index 000000000..858cdd306
Binary files /dev/null and b/android/app/src/main/res/font/pretendard_regular.otf differ
diff --git a/android/app/src/main/res/layout/activity_auction_detail.xml b/android/app/src/main/res/layout/activity_auction_detail.xml
index 7d8dbaa07..fed1cb02a 100644
--- a/android/app/src/main/res/layout/activity_auction_detail.xml
+++ b/android/app/src/main/res/layout/activity_auction_detail.xml
@@ -15,8 +15,10 @@
+ app:tint="@color/grey_900" />
@@ -125,21 +130,23 @@
+ android:drawablePadding="4dp"
+ android:text="@{AuctionDetailFormatter.INSTANCE.formatClosingRemainDateText(context,viewModel.auctionDetailModel.closingTime)}"
+ android:textColor="@color/primary"
+ android:textSize="16dp"
+ app:drawableStartCompat="@drawable/ic_alarm_24"
+ app:layout_constraintBottom_toBottomOf="@id/tv_auction_closingTime"
+ app:layout_constraintEnd_toEndOf="@id/gl_end"
+ app:layout_constraintTop_toTopOf="@id/tv_auction_closingTime"
+ tools:text="1시간 35분" />
+ app:layout_constraintStart_toStartOf="@id/gl_begin"
+ app:layout_constraintTop_toBottomOf="@id/tv_auction_status"
+ tools:text="현재가 400,000원" />
@@ -215,150 +235,43 @@
android:id="@+id/iv_auctioneer_count_icon"
android:layout_width="16dp"
android:layout_height="16dp"
+ android:layout_marginTop="12dp"
android:contentDescription="@string/detail_auctioneer_count_icon_description"
android:src="@drawable/ic_people_alt_24"
app:layout_constraintStart_toStartOf="@id/gl_begin"
- app:layout_constraintTop_toBottomOf="@id/tv_start_price" />
+ app:layout_constraintTop_toBottomOf="@id/tv_start_price"
+ app:tint="@color/grey_700" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-