diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index 3d02ab19..a1f20b31 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -26,10 +26,12 @@ jobs: env: BASE_URL: ${{ secrets.BASE_URL }} KAKAO_NATIVE_KEY: ${{ secrets.KAKAO_NATIVE_KEY }} + AMPLITUDE_KEY: ${{ secrets.AMPLITUDE_KEY }} run: | echo base.url=\"$BASE_URL\" >> ./local.properties echo kakao.native.key=\"$KAKAO_NATIVE_KEY\" >> ./local.properties echo kakaoNativeKey=$KAKAO_NATIVE_KEY >> ./local.properties + echo amplitude.key=\"$AMPLITUDE_KEY\" >> ./local.properties - name: Create Google Services JSON File env: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9fe5c94b..f0d4aa53 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,4 +58,5 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) + implementation(libs.amplitude.analytics) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12762fed..af711630 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + @SetValue(KEY_POPUP_VISIBILITY) + suspend fun setPopupVisibility(boolean: Boolean) + + @GetValue(KEY_MARKET_UPDATE) + fun flowMarketUpdate(): Flow + + @SetValue(KEY_MARKET_UPDATE) + suspend fun setMarketUpdate(boolean: Boolean) + @ClearValues suspend fun clearAll() + companion object { const val KEY_ACCESSTOKEN = "key-accesstoken" const val KEY_REFRESHTOKEN = "key-refreshtoken" const val KEY_DEVICETOKEN = "key-devicetoken" const val KEY_AUTOLOGIN = "key-autologin" const val KEY_FCM_ALLOWED = "key-fcm-allowed" + const val KEY_POPUP_VISIBILITY = "key-popup-dialog" + const val KEY_MARKET_UPDATE = "key-market-update" } } diff --git a/core/designsystem/src/main/res/drawable/shape_white_fill_2_rect.xml b/core/designsystem/src/main/res/drawable/shape_white_fill_2_rect.xml new file mode 100644 index 00000000..ea574595 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/shape_white_fill_2_rect.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index a22fe03d..c4f23723 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -155,6 +155,11 @@ 님의 클립 이주의 링크 이주의 추천 사이트 + 1분 설문조사 참여하고\n스타벅스 기프티콘 받기 + 토스터 사용 피드백을 남겨주시면\n추첨을 통해 기프티콘을 드려요! + 참여하기 + 일주일간 보지 않기 + 클립의 이름은 최대 15자까지 입력 가능해요 diff --git a/core/model/src/main/java/org/sopt/model/home/PopupInfo.kt b/core/model/src/main/java/org/sopt/model/home/PopupInfo.kt new file mode 100644 index 00000000..41dc9e5a --- /dev/null +++ b/core/model/src/main/java/org/sopt/model/home/PopupInfo.kt @@ -0,0 +1,9 @@ +package org.sopt.model.home + +data class PopupInfo( + val popupId: Int, + val popupImage: String, + val popupActiveStartDate: String, + val popupActiveEndDate: String, + val popupLinkUrl: String, +) diff --git a/core/model/src/main/java/org/sopt/model/home/PopupInvisible.kt b/core/model/src/main/java/org/sopt/model/home/PopupInvisible.kt new file mode 100644 index 00000000..66c43e34 --- /dev/null +++ b/core/model/src/main/java/org/sopt/model/home/PopupInvisible.kt @@ -0,0 +1,6 @@ +package org.sopt.model.home + +data class PopupInvisible( + val popupId: Int, + val popupHideUntil: String, +) diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/api/PopupService.kt b/data-remote/home/src/main/java/org/sopt/remote/home/api/PopupService.kt new file mode 100644 index 00000000..a3f23894 --- /dev/null +++ b/data-remote/home/src/main/java/org/sopt/remote/home/api/PopupService.kt @@ -0,0 +1,25 @@ +package org.sopt.remote.home.api + +import org.sopt.network.model.response.base.BaseResponse +import org.sopt.remote.home.request.RequestPopupInvisibleDto +import org.sopt.remote.home.response.ResponsePopupInfoDto +import org.sopt.remote.home.response.ResponsePopupInvisibleDto +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH + +interface PopupService { + companion object { + const val API = "api" + const val V2 = "v2" + const val POPUP = "popup" + } + + @PATCH("/$API/$V2/$POPUP") + suspend fun patchPopupInvisible( + @Body requestPopupInvisibleDto: RequestPopupInvisibleDto, + ): BaseResponse + + @GET("/$API/$V2/$POPUP") + suspend fun getPopupInfo(): BaseResponse +} diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/datasource/RemotePopupDatasourceImpl.kt b/data-remote/home/src/main/java/org/sopt/remote/home/datasource/RemotePopupDatasourceImpl.kt new file mode 100644 index 00000000..d6536f35 --- /dev/null +++ b/data-remote/home/src/main/java/org/sopt/remote/home/datasource/RemotePopupDatasourceImpl.kt @@ -0,0 +1,19 @@ +package org.sopt.remote.home.datasource + +import org.sopt.home.datasource.RemotePopupDataSource +import org.sopt.model.home.PopupInfo +import org.sopt.model.home.PopupInvisible +import org.sopt.remote.home.api.PopupService +import org.sopt.remote.home.request.RequestPopupInvisibleDto +import org.sopt.remote.home.response.toCoreModel +import javax.inject.Inject + +class RemotePopupDatasourceImpl @Inject constructor( + private val popupService: PopupService, +) : RemotePopupDataSource { + override suspend fun patchPopupInvisible(popupId: Long, hideDate: Long): PopupInvisible = + popupService.patchPopupInvisible(RequestPopupInvisibleDto(popupId, hideDate)).data!!.toCoreModel() + + override suspend fun getPopupInfo(): List = + popupService.getPopupInfo().data!!.popupList.map { it.toCoreModel() } +} diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeDataSourceModule.kt b/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeDataSourceModule.kt index 741e3551..2ea85376 100644 --- a/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeDataSourceModule.kt +++ b/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeDataSourceModule.kt @@ -5,7 +5,9 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.sopt.home.datasource.RemoteHomeDataSource +import org.sopt.home.datasource.RemotePopupDataSource import org.sopt.remote.home.datasource.RemoteHomeDataSourceImpl +import org.sopt.remote.home.datasource.RemotePopupDatasourceImpl import javax.inject.Singleton @Module @@ -16,4 +18,10 @@ abstract class HomeDataSourceModule { abstract fun bindRemoteHomeDatasource( remoteHomeDataSourceImpl: RemoteHomeDataSourceImpl, ): RemoteHomeDataSource + + @Singleton + @Binds + abstract fun bindRemotePopupDataSource( + remotePopupDataSourceImpl: RemotePopupDatasourceImpl, + ): RemotePopupDataSource } diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeServiceModule.kt b/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeServiceModule.kt index b750cfaa..e7f289c7 100644 --- a/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeServiceModule.kt +++ b/data-remote/home/src/main/java/org/sopt/remote/home/di/HomeServiceModule.kt @@ -6,8 +6,10 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.sopt.network.di.AuthLinkMindRetrofit import org.sopt.remote.home.api.HomeService +import org.sopt.remote.home.api.PopupService import retrofit2.Retrofit import javax.inject.Singleton + @Module @InstallIn(SingletonComponent::class) object HomeServiceModule { @@ -16,4 +18,9 @@ object HomeServiceModule { @Provides fun provideHomeService(@AuthLinkMindRetrofit retrofit: Retrofit): HomeService = retrofit.create(HomeService::class.java) + + @Singleton + @Provides + fun providePopupService(@AuthLinkMindRetrofit retrofit: Retrofit): PopupService = + retrofit.create(PopupService::class.java) } diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/request/RequestPopupInvisibleDto.kt b/data-remote/home/src/main/java/org/sopt/remote/home/request/RequestPopupInvisibleDto.kt new file mode 100644 index 00000000..a7e19640 --- /dev/null +++ b/data-remote/home/src/main/java/org/sopt/remote/home/request/RequestPopupInvisibleDto.kt @@ -0,0 +1,12 @@ +package org.sopt.remote.home.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestPopupInvisibleDto( + @SerialName("popupId") + val popupId: Long, + @SerialName("hideDate") + val hideDate: Long, +) diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInfoDto.kt b/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInfoDto.kt new file mode 100644 index 00000000..441edb2b --- /dev/null +++ b/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInfoDto.kt @@ -0,0 +1,37 @@ +package org.sopt.remote.home.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.sopt.model.home.PopupInfo + +@Serializable +data class ResponsePopupInfoDto( + @SerialName("popupList") + val popupList: List, +) + +@Serializable +data class ResponsePopupInfo( + @SerialName("id") + val id: Int, + @SerialName("image") + val image: String, + @SerialName("activeStartDate") + val activeStartDate: String, + @SerialName("activeEndDate") + val activeEndDate: String, + @SerialName("linkUrl") + val linkUrl: String, +) + +internal fun ResponsePopupInfoDto.toCoreModel() = popupList.map { + it.toCoreModel() +} + +internal fun ResponsePopupInfo.toCoreModel() = PopupInfo( + popupId = id, + popupImage = image, + popupActiveStartDate = activeStartDate, + popupActiveEndDate = activeEndDate, + popupLinkUrl = linkUrl, +) diff --git a/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInvisibleDto.kt b/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInvisibleDto.kt new file mode 100644 index 00000000..a475497f --- /dev/null +++ b/data-remote/home/src/main/java/org/sopt/remote/home/response/ResponsePopupInvisibleDto.kt @@ -0,0 +1,18 @@ +package org.sopt.remote.home.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.sopt.model.home.PopupInvisible + +@Serializable +data class ResponsePopupInvisibleDto( + @SerialName("popupId") + val popupId: Int, + @SerialName("hideUntil") + val hideUntil: String, +) + +internal fun ResponsePopupInvisibleDto.toCoreModel() = PopupInvisible( + popupId = popupId, + popupHideUntil = hideUntil, +) diff --git a/data/home/src/main/java/org/sopt/home/datasource/RemotePopupDataSource.kt b/data/home/src/main/java/org/sopt/home/datasource/RemotePopupDataSource.kt new file mode 100644 index 00000000..a6f76163 --- /dev/null +++ b/data/home/src/main/java/org/sopt/home/datasource/RemotePopupDataSource.kt @@ -0,0 +1,9 @@ +package org.sopt.home.datasource + +import org.sopt.model.home.PopupInfo +import org.sopt.model.home.PopupInvisible + +interface RemotePopupDataSource { + suspend fun patchPopupInvisible(popupId: Long, hideDate: Long): PopupInvisible + suspend fun getPopupInfo(): List +} diff --git a/data/home/src/main/java/org/sopt/home/di/RepositoryModule.kt b/data/home/src/main/java/org/sopt/home/di/RepositoryModule.kt index 397f2b75..9137c2a7 100644 --- a/data/home/src/main/java/org/sopt/home/di/RepositoryModule.kt +++ b/data/home/src/main/java/org/sopt/home/di/RepositoryModule.kt @@ -6,6 +6,8 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import org.sopt.home.repository.HomeRepoImpl import org.sopt.home.repository.HomeRepository +import org.sopt.home.repository.PopupRepoImpl +import org.sopt.home.repository.PopupRepository import javax.inject.Singleton @Module @@ -16,4 +18,10 @@ abstract class RepositoryModule { abstract fun bindHomeRepository( homeRepoImpl: HomeRepoImpl, ): HomeRepository + + @Singleton + @Binds + abstract fun bindPopupRepository( + popupRepoImpl: PopupRepoImpl, + ): PopupRepository } diff --git a/data/home/src/main/java/org/sopt/home/repository/PopupRepoImpl.kt b/data/home/src/main/java/org/sopt/home/repository/PopupRepoImpl.kt new file mode 100644 index 00000000..ef1388af --- /dev/null +++ b/data/home/src/main/java/org/sopt/home/repository/PopupRepoImpl.kt @@ -0,0 +1,16 @@ +package org.sopt.home.repository + +import org.sopt.home.datasource.RemotePopupDataSource +import org.sopt.model.home.PopupInfo +import org.sopt.model.home.PopupInvisible +import javax.inject.Inject + +class PopupRepoImpl @Inject constructor( + private val remotePopupDataSource: RemotePopupDataSource, +) : PopupRepository { + override suspend fun patchPopupInvisible(popupId: Long, hideDate: Long): Result = + runCatching { remotePopupDataSource.patchPopupInvisible(popupId, hideDate) } + + override suspend fun getPopupInfo(): Result> = + runCatching { remotePopupDataSource.getPopupInfo() } +} diff --git a/domain/home/src/main/java/org/sopt/home/repository/PopupRepository.kt b/domain/home/src/main/java/org/sopt/home/repository/PopupRepository.kt new file mode 100644 index 00000000..9b921c84 --- /dev/null +++ b/domain/home/src/main/java/org/sopt/home/repository/PopupRepository.kt @@ -0,0 +1,9 @@ +package org.sopt.home.repository + +import org.sopt.model.home.PopupInfo +import org.sopt.model.home.PopupInvisible + +interface PopupRepository { + suspend fun patchPopupInvisible(popupId: Long, hideDate: Long): Result + suspend fun getPopupInfo(): Result> +} diff --git a/domain/home/src/main/java/org/sopt/home/usecase/GetPopupInfo.kt b/domain/home/src/main/java/org/sopt/home/usecase/GetPopupInfo.kt new file mode 100644 index 00000000..163db982 --- /dev/null +++ b/domain/home/src/main/java/org/sopt/home/usecase/GetPopupInfo.kt @@ -0,0 +1,11 @@ +package org.sopt.home.usecase + +import org.sopt.home.repository.PopupRepository +import org.sopt.model.home.PopupInfo +import javax.inject.Inject + +class GetPopupInfo @Inject constructor( + private val popupRepository: PopupRepository, +) { + suspend operator fun invoke(): Result> = popupRepository.getPopupInfo() +} diff --git a/domain/home/src/main/java/org/sopt/home/usecase/PatchPopupInvisible.kt b/domain/home/src/main/java/org/sopt/home/usecase/PatchPopupInvisible.kt new file mode 100644 index 00000000..65b069ec --- /dev/null +++ b/domain/home/src/main/java/org/sopt/home/usecase/PatchPopupInvisible.kt @@ -0,0 +1,12 @@ +package org.sopt.home.usecase + +import org.sopt.home.repository.PopupRepository +import org.sopt.model.home.PopupInvisible +import javax.inject.Inject + +class PatchPopupInvisible @Inject constructor( + private val popupRepository: PopupRepository, +) { + suspend operator fun invoke(popupId: Long, hideDate: Long): Result = + popupRepository.patchPopupInvisible(popupId, hideDate) +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index da8ce5d0..17332ef8 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -18,4 +18,6 @@ dependencies { implementation(libs.jsoup) implementation(libs.orbit.core) implementation(libs.orbit.viewmodel) + implementation(libs.google.play.core) + implementation(projects.core.datastore) } diff --git a/feature/home/src/main/java/org/sopt/home/HomeContract.kt b/feature/home/src/main/java/org/sopt/home/HomeContract.kt index 797a17cf..493dd45a 100644 --- a/feature/home/src/main/java/org/sopt/home/HomeContract.kt +++ b/feature/home/src/main/java/org/sopt/home/HomeContract.kt @@ -1,6 +1,8 @@ package org.sopt.home +import org.sopt.home.model.UpdatePriority import org.sopt.model.category.Category +import org.sopt.model.home.PopupInfo import org.sopt.model.home.RecommendLink import org.sopt.model.home.WeekBestLink @@ -14,6 +16,8 @@ data class HomeState( val url: String = "", val categoryId: Long? = 0, val categoryName: String? = "전체 클립", + val popupList: List = emptyList(), + val marketUpdate: UpdatePriority = UpdatePriority.EMPTY, ) { fun calculateProgress(): Int { if (readToastNum > allToastNum) return 0 @@ -31,4 +35,6 @@ sealed interface HomeSideEffect { data object NavigateClipLink : HomeSideEffect data object NavigateWebView : HomeSideEffect data object ShowBottomSheet : HomeSideEffect + data object ShowPopupInfo : HomeSideEffect + data object ShowUpdateDialog : HomeSideEffect } diff --git a/feature/home/src/main/java/org/sopt/home/HomeFragment.kt b/feature/home/src/main/java/org/sopt/home/HomeFragment.kt index 70926045..25fed57e 100644 --- a/feature/home/src/main/java/org/sopt/home/HomeFragment.kt +++ b/feature/home/src/main/java/org/sopt/home/HomeFragment.kt @@ -14,6 +14,8 @@ import org.sopt.home.adapter.HomeWeekLinkAdapter import org.sopt.home.adapter.HomeWeekRecommendLinkAdapter import org.sopt.home.adapter.ItemDecoration import org.sopt.home.databinding.FragmentHomeBinding +import org.sopt.home.model.UpdatePriority +import org.sopt.model.home.PopupInfo import org.sopt.ui.base.BindingFragment import org.sopt.ui.nav.DeepLinkUtil import org.sopt.ui.view.onThrottleClick @@ -27,6 +29,7 @@ class HomeFragment : BindingFragment({ FragmentHomeBinding. private lateinit var homeWeekLinkAdapter: HomeWeekLinkAdapter private lateinit var homeWeekRecommendLinkAdapter: HomeWeekRecommendLinkAdapter private val viewModel by viewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initView() @@ -62,6 +65,7 @@ class HomeFragment : BindingFragment({ FragmentHomeBinding. is HomeSideEffect.NavigateClipLink -> navigateToDestination( "featureSaveLink://ClipLinkFragment/${viewModel.container.stateFlow.value.categoryId}/${viewModel.container.stateFlow.value.categoryName}", ) + is HomeSideEffect.ShowBottomSheet -> showHomeBottomSheet() is HomeSideEffect.NavigateWebView -> { val encodedURL = URLEncoder.encode(viewModel.container.stateFlow.value.url, StandardCharsets.UTF_8.toString()) @@ -69,6 +73,9 @@ class HomeFragment : BindingFragment({ FragmentHomeBinding. "featureSaveLink://webViewFragment/${0}/${false}/${false}/$encodedURL", ) } + + is HomeSideEffect.ShowPopupInfo -> showPopupInfo(viewModel.container.stateFlow.value.popupList) + is HomeSideEffect.ShowUpdateDialog -> showUpdateDialog(viewModel.container.stateFlow.value.marketUpdate) } } @@ -80,8 +87,11 @@ class HomeFragment : BindingFragment({ FragmentHomeBinding. getMainPageUserClip() getRecommendSite() getWeekBestLink() + getPopupListInfo() + checkMarketUpdateState() } } + private fun navigateToSetting() { binding.ivHomeSetting.onThrottleClick { viewModel.navigateSetting() @@ -154,4 +164,29 @@ class HomeFragment : BindingFragment({ FragmentHomeBinding. } } } + + private fun showPopupInfo(popupList: List) { + popupList.forEach { + if (viewModel.checkPopupDate(it.popupActiveStartDate, it.popupActiveEndDate) + ) { + val surveyDialog = SurveyDialogFragment.newInstance( + it.popupImage, + { viewModel.navigateWebview(it.popupLinkUrl) }, + { viewModel.patchPopupInvisible(it.popupId.toLong(), 7) }, + { viewModel.setPopupVisible() }, + ) + surveyDialog.show(parentFragmentManager, this.tag) + } + } + } + + private fun showUpdateDialog(marketUpdate: UpdatePriority) { + if (marketUpdate != UpdatePriority.EMPTY) { + val marketUpdateDialog = MarketUpdateDialogFragment.newInstance( + marketUpdate, + { viewModel.setMarketUpdateVisible() }, + ) + marketUpdateDialog.show(parentFragmentManager, this.tag) + } + } } diff --git a/feature/home/src/main/java/org/sopt/home/HomeViewModel.kt b/feature/home/src/main/java/org/sopt/home/HomeViewModel.kt index 9326aa7f..57035cfb 100644 --- a/feature/home/src/main/java/org/sopt/home/HomeViewModel.kt +++ b/feature/home/src/main/java/org/sopt/home/HomeViewModel.kt @@ -1,9 +1,14 @@ package org.sopt.home +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.install.model.UpdateAvailability import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost @@ -13,19 +18,30 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import org.sopt.datastore.datastore.SecurityDataStore import org.sopt.domain.category.category.usecase.PostAddCategoryTitleUseCase +import org.sopt.home.model.UpdatePriority import org.sopt.home.usecase.GetMainPageUserClip +import org.sopt.home.usecase.GetPopupInfo import org.sopt.home.usecase.GetRecommendSite import org.sopt.home.usecase.GetWeekBestLink +import org.sopt.home.usecase.PatchPopupInvisible import org.sopt.model.category.Category +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val getMainPageUserClip: GetMainPageUserClip, private val getRecommendSite: GetRecommendSite, private val getWeekBestLink: GetWeekBestLink, private val postAddCategoryTitle: PostAddCategoryTitleUseCase, + private val patchPopupInvisible: PatchPopupInvisible, + private val getPopupInfo: GetPopupInfo, + private val dataStore: SecurityDataStore, ) : ContainerHost, ViewModel() { override val container: Container = container(HomeState()) @@ -86,6 +102,53 @@ class HomeViewModel @Inject constructor( } } + fun getPopupListInfo() = intent { + if (dataStore.flowPopupVisibility().first()) { + getPopupInfo.invoke().onSuccess { + postSideEffect(HomeSideEffect.ShowPopupInfo) + reduce { + state.copy(popupList = it) + } + }.onFailure { + Log.d("getPopupListInfo", "$it") + } + } + } + + fun patchPopupInvisible(popupId: Long, hideDate: Long) { + viewModelScope.launch { + patchPopupInvisible.invoke(popupId, hideDate) + .onSuccess { + Log.d("patchPopupInvisible", "$it") + } + .onFailure { + Log.d("patchPopupInvisible", "$it") + } + } + } + + fun checkMarketUpdateState() { + viewModelScope.launch { + if (dataStore.flowMarketUpdate().first()) { + val appUpdateManager = AppUpdateManagerFactory.create(context) + val appUpdateTask = appUpdateManager.appUpdateInfo + + appUpdateTask.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + intent { + postSideEffect(HomeSideEffect.ShowUpdateDialog) + reduce { + state.copy(marketUpdate = UpdatePriority.toUpdatePriority(appUpdateInfo.updatePriority())) + } + } + } + }.addOnFailureListener { appUpdateInfo -> + Log.d("appUpdateInfo", appUpdateInfo.message.toString()) + } + } + } + } + fun navigateSearch() = intent { postSideEffect(HomeSideEffect.NavigateSearch) } fun navigateSetting() = intent { postSideEffect(HomeSideEffect.NavigateSetting) } fun showBottomSheet() = intent { postSideEffect(HomeSideEffect.ShowBottomSheet) } @@ -106,4 +169,24 @@ class HomeViewModel @Inject constructor( reduce { state.copy(url = url) } postSideEffect(HomeSideEffect.NavigateWebView) } + + fun setPopupVisible() { + viewModelScope.launch { + dataStore.setPopupVisibility(false) + } + } + + fun setMarketUpdateVisible() { + viewModelScope.launch { + dataStore.setMarketUpdate(false) + } + } + + fun checkPopupDate(activeStartDate: String, activeEndDate: String): Boolean { + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val today = Calendar.getInstance().time + val startDate = dateFormat.parse(activeStartDate) + val endDate = dateFormat.parse(activeEndDate) + return today.after(startDate) && today.before(endDate) || today == startDate || today == endDate + } } diff --git a/feature/home/src/main/java/org/sopt/home/MarketUpdateDialogFragment.kt b/feature/home/src/main/java/org/sopt/home/MarketUpdateDialogFragment.kt new file mode 100644 index 00000000..2bb79c39 --- /dev/null +++ b/feature/home/src/main/java/org/sopt/home/MarketUpdateDialogFragment.kt @@ -0,0 +1,107 @@ +package org.sopt.home + +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.core.view.isInvisible +import org.sopt.home.databinding.FragmentMarketUpdateDialogBinding +import org.sopt.home.model.UpdatePriority +import org.sopt.ui.base.BindingDialogFragment +import org.sopt.ui.view.onThrottleClick +import kotlin.system.exitProcess + +class MarketUpdateDialogFragment : BindingDialogFragment( + { FragmentMarketUpdateDialogBinding.inflate(it) }, +) { + private var marketUpdatePriority: UpdatePriority? = null + private var marketUpdateVisible: () -> Unit = {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, android.R.style.Theme_Dialog) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + marketUpdateVisible.invoke() + checkUpdatePriority(marketUpdatePriority) + + binding.btnMarketUpdateDialogSkip.onThrottleClick { + dismiss() + } + binding.btnMarketUpdateDialogUpdate.onThrottleClick { + dismiss() + navigateMarket() + exitProcess(0) + } + } + + override fun onStart() { + super.onStart() + val dialog = dialog + if (dialog != null) { + dialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog.setCanceledOnTouchOutside(false) + } + } + + private fun checkUpdatePriority(marketUpdatePriority: UpdatePriority?) { + when (marketUpdatePriority) { + UpdatePriority.EMPTY -> { + binding.tvMarketUpdateDialogTitle.text = "업데이트 알림" + binding.tvMarketUpdateDialogSubtitle.text = "토스터의 사용성이 개선되었어요!\n지금 바로 업데이트해보세요" + binding.btnMarketUpdateDialogUpdate.text = "지금 업데이트" + binding.btnMarketUpdateDialogSkip.isInvisible = false + } + + UpdatePriority.MINOR -> { + binding.tvMarketUpdateDialogTitle.text = "업데이트 알림" + binding.tvMarketUpdateDialogSubtitle.text = "토스터의 사용성이 개선되었어요!\n지금 바로 업데이트해보세요" + binding.btnMarketUpdateDialogUpdate.text = "지금 업데이트" + binding.btnMarketUpdateDialogSkip.isInvisible = false + } + + UpdatePriority.MAJOR -> { + binding.tvMarketUpdateDialogTitle.text = "기능 업데이트 알림" + binding.tvMarketUpdateDialogSubtitle.text = "토스터의 기능이 추가되었어요!\n지금 바로 업데이트해보세요" + binding.btnMarketUpdateDialogUpdate.text = "지금 업데이트" + binding.btnMarketUpdateDialogSkip.isInvisible = false + } + + UpdatePriority.CRITICAL -> { + binding.tvMarketUpdateDialogTitle.text = "신규 기능 업데이트 알림" + binding.tvMarketUpdateDialogSubtitle.text = "토스터의 새로운 기능을 이용하기 위해서는\n업데이트가 필요해요!\n최신 버전으로 업데이트 하시겠어요?" + binding.btnMarketUpdateDialogUpdate.text = "업데이트 하기" + binding.btnMarketUpdateDialogSkip.isInvisible = true + } + + null -> {} + } + } + + private fun navigateMarket() { + val appPackageName = "org.sopt.linkmind" + runCatching { + context?.packageManager?.getPackageInfo("com.android.vending", 0) + }.onSuccess { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$appPackageName"))) + }.onFailure { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$appPackageName"))) + } + } + + companion object { + fun newInstance( + updatePriority: UpdatePriority, + setMarketUpdateVisible: () -> Unit, + ): MarketUpdateDialogFragment { + return MarketUpdateDialogFragment().apply { + marketUpdatePriority = updatePriority + marketUpdateVisible = setMarketUpdateVisible + } + } + } +} diff --git a/feature/home/src/main/java/org/sopt/home/SurveyDialogFragment.kt b/feature/home/src/main/java/org/sopt/home/SurveyDialogFragment.kt new file mode 100644 index 00000000..c770e8e5 --- /dev/null +++ b/feature/home/src/main/java/org/sopt/home/SurveyDialogFragment.kt @@ -0,0 +1,77 @@ +package org.sopt.home + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import coil.load +import dagger.hilt.android.AndroidEntryPoint +import org.sopt.home.databinding.FragmentSurveyDialogBinding +import org.sopt.ui.base.BindingDialogFragment +import org.sopt.ui.view.onThrottleClick + +@AndroidEntryPoint +class SurveyDialogFragment : BindingDialogFragment( + { FragmentSurveyDialogBinding.inflate(it) }, +) { + private var imageUrl: String? = null + private var linkUrl: () -> Unit = {} + private var handleSkip: () -> Unit = {} + private var popupVisibility: () -> Unit = {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + imageUrl = arguments?.getString("imageUrl") + setStyle(STYLE_NO_FRAME, android.R.style.Theme_Dialog) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + popupVisibility.invoke() + setSurveyImage(imageUrl) + + binding.ivSurveyDialogClose.onThrottleClick { + dismiss() + } + + binding.btnSurveyDialog.onThrottleClick { + linkUrl.invoke() + dismiss() + } + + binding.btnSurveyDialogSkip.onThrottleClick { + handleSkip.invoke() + dismiss() + } + } + + override fun onStart() { + super.onStart() + val dialog = dialog + if (dialog != null) { + dialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + + private fun setSurveyImage(text: String?) = + binding.ivSurveyDialog.load(text) + + companion object { + fun newInstance( + imageUrl: String, + onNavigateWebView: () -> Unit, + onNegativeButtonClick: () -> Unit, + setPopupVisibility: () -> Unit, + ): SurveyDialogFragment { + val args = Bundle().apply { + putString("imageUrl", imageUrl) + } + return SurveyDialogFragment().apply { + arguments = args + linkUrl = onNavigateWebView + handleSkip = onNegativeButtonClick + popupVisibility = setPopupVisibility + } + } + } +} diff --git a/feature/home/src/main/java/org/sopt/home/model/UpdatePriority.kt b/feature/home/src/main/java/org/sopt/home/model/UpdatePriority.kt new file mode 100644 index 00000000..533dc833 --- /dev/null +++ b/feature/home/src/main/java/org/sopt/home/model/UpdatePriority.kt @@ -0,0 +1,20 @@ +package org.sopt.home.model + +enum class UpdatePriority { + EMPTY, + MINOR, + MAJOR, + CRITICAL, + ; + + companion object { + fun toUpdatePriority(value: Int): UpdatePriority { + return when (value) { + in 0..1 -> MINOR + in 2..3 -> MAJOR + in 4..5 -> CRITICAL + else -> EMPTY + } + } + } +} diff --git a/feature/home/src/main/res/layout/fragment_market_update_dialog.xml b/feature/home/src/main/res/layout/fragment_market_update_dialog.xml new file mode 100644 index 00000000..d65351ff --- /dev/null +++ b/feature/home/src/main/res/layout/fragment_market_update_dialog.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + diff --git a/feature/home/src/main/res/layout/fragment_survey_dialog.xml b/feature/home/src/main/res/layout/fragment_survey_dialog.xml new file mode 100644 index 00000000..e152ce04 --- /dev/null +++ b/feature/home/src/main/res/layout/fragment_survey_dialog.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + diff --git a/feature/login/src/main/java/org/sopt/login/onboarding/LoginActivity.kt b/feature/login/src/main/java/org/sopt/login/onboarding/LoginActivity.kt index 8d719c0c..957c5df2 100644 --- a/feature/login/src/main/java/org/sopt/login/onboarding/LoginActivity.kt +++ b/feature/login/src/main/java/org/sopt/login/onboarding/LoginActivity.kt @@ -43,6 +43,8 @@ class LoginActivity : AppCompatActivity() { initCheckAutoLogin() initKakaoLoginBtnClickListener() initAuthStateObserver() + initPopupVisible() + initMarketUpdateVisible() binding.vpOnboarding.adapter = LoginViewPagerAdapter(this) binding.indicatorOnboarding.attachTo(binding.vpOnboarding) @@ -99,6 +101,18 @@ class LoginActivity : AppCompatActivity() { } } + private fun initPopupVisible() { + lifecycleScope.launch { + dataStore.setPopupVisibility(true) + } + } + + private fun initMarketUpdateVisible() { + lifecycleScope.launch { + dataStore.setMarketUpdate(true) + } + } + companion object { @JvmStatic fun newInstance(context: Context) = Intent(context, LoginActivity::class.java).apply { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a18d7f3..29a8e40e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ detekt = "1.23.1" kakao-login = "2.19.0" google-service = "4.4.0" +google-play-core = "1.10.2" process-pheonix = "2.1.2" preference = "1.2.1" core-splashscreen = "1.0.1" @@ -54,6 +55,8 @@ core-splashscreen = "1.0.1" viewpager-indicator = "5.0" activity = "1.8.0" +amplitude = "1.+" + [plugins] ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -152,10 +155,13 @@ viewpager-indicator = { group = "com.tbuonomo", name = "dotsindicator", version. detekt-plugin-formatting = { group = "io.gitlab.arturbosch.detekt", name = "detekt-formatting", version.ref = "detekt" } kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao-login" } +google-play-core = { group = "com.google.android.play", name = "core", version.ref = "google-play-core" } process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "process-pheonix" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +amplitude-analytics = { group = "com.amplitude", name = "analytics-android", version.ref = "amplitude" } + [bundles] firebase = ["firebase-analytics", "firebase-database", "firebase-messaging", "firebase-remoteConfig"] androidx-lifecycle = ["androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-ktx"]