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