diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/MemberPreferencesDataSource.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/MemberPreferencesDataSource.kt index 602718fb..416e8a12 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/MemberPreferencesDataSource.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/MemberPreferencesDataSource.kt @@ -5,6 +5,8 @@ import com.silvertown.android.dailyphrase.data.MemberPreferences import com.silvertown.android.dailyphrase.domain.model.Member import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.io.IOException import javax.inject.Inject class MemberPreferencesDataSource @Inject constructor( @@ -16,6 +18,7 @@ class MemberPreferencesDataSource @Inject constructor( id = preferences.id.takeIf { it.toInt() != 0 } ?: DEFAULT_ID.toLong(), name = preferences.name.takeIf { it.isNotBlank() } ?: DEFAULT_NAME, imageUrl = preferences.imageUrl.takeIf { it.isNotBlank() } ?: DEFAULT_IMAGE_URL, + sharedCount = preferences.sharedCount.takeIf { it >= 0 } ?: DEFAULT_SHARED_COUNT, email = DEFAULT_EMAIL, quitAt = DEFAULT_QUIT_AT ) @@ -66,11 +69,24 @@ class MemberPreferencesDataSource @Inject constructor( } } + suspend fun updateSharedCount(count: Int) { + try { + memberPreferences.updateData { currentPreferences -> + currentPreferences.toBuilder() + .setSharedCount(count) + .build() + } + } catch (ioException: IOException) { + Timber.tag("MemberPref").e(ioException, "Failed to update member preferences") + } + } + companion object { const val DEFAULT_ID = 0 const val DEFAULT_NAME = "User" const val DEFAULT_IMAGE_URL = "https://cdn.pixabay.com/photo/2015/06/25/04/50/hand-print-820913_1280.jpg" + const val DEFAULT_SHARED_COUNT = 0 const val DEFAULT_EMAIL = "" const val DEFAULT_QUIT_AT = "" } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSource.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSource.kt index f4251c16..99cc22af 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSource.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSource.kt @@ -3,11 +3,11 @@ package com.silvertown.android.dailyphrase.data.datastore.datasource import kotlinx.coroutines.flow.Flow interface TokenDataSource { - fun getAccessToken(): Flow + fun getAccessTokenFlow(): Flow fun getRefreshToken(): Flow suspend fun saveAccessToken(accessToken: String) suspend fun saveRefreshToken(refreshToken: String) - suspend fun getLoginState(): Boolean + suspend fun getAccessToken(): String? suspend fun deleteAccessToken() } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSourceImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSourceImpl.kt index c8d19d45..042c864c 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSourceImpl.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/datastore/datasource/TokenDataSourceImpl.kt @@ -16,12 +16,8 @@ import javax.inject.Inject class TokenDataSourceImpl @Inject constructor( private val tokenDataStore: DataStore, ) : TokenDataSource { - companion object { - private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") - private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") - } - override fun getAccessToken(): Flow { + override fun getAccessTokenFlow(): Flow { return tokenDataStore.data .catch { exception -> if (exception is IOException) { @@ -63,8 +59,8 @@ class TokenDataSourceImpl @Inject constructor( } } - override suspend fun getLoginState(): Boolean { - return getAccessToken().first() != null + override suspend fun getAccessToken(): String? { + return getAccessTokenFlow().first() } override suspend fun deleteAccessToken() { @@ -72,4 +68,9 @@ class TokenDataSourceImpl @Inject constructor( preferences.remove(ACCESS_TOKEN_KEY) } } + + companion object { + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") + } } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/AuthInterceptor.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/AuthInterceptor.kt index e55fad2c..9852cce3 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/AuthInterceptor.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/AuthInterceptor.kt @@ -15,12 +15,15 @@ class AuthInterceptor @Inject constructor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val accessToken = runBlocking(Dispatchers.IO) { - tokenDataSource.getAccessToken().first() + tokenDataSource.getAccessTokenFlow().first() } - val originalRequest = chain.request() - val requestBuilder = originalRequest.newBuilder() - requestBuilder.addHeader("Authorization", "Bearer $accessToken") - val request = requestBuilder.build() + val request = + chain + .request() + .newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + return chain.proceed(request) } } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSource.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSource.kt new file mode 100644 index 00000000..c0d20aa0 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSource.kt @@ -0,0 +1,12 @@ +package com.silvertown.android.dailyphrase.data.network.datasource + +import com.silvertown.android.dailyphrase.data.network.common.ApiResponse +import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardInfoResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardWrapperResponse + +interface RewardDataSource { + suspend fun getRewards(): ApiResponse> + suspend fun getRewardInfo(): ApiResponse> + +} diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSourceImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSourceImpl.kt new file mode 100644 index 00000000..67063fb3 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/RewardDataSourceImpl.kt @@ -0,0 +1,19 @@ +package com.silvertown.android.dailyphrase.data.network.datasource + +import com.silvertown.android.dailyphrase.data.network.common.ApiResponse +import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardInfoResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardWrapperResponse +import com.silvertown.android.dailyphrase.data.network.service.RewardApiService +import javax.inject.Inject + +class RewardDataSourceImpl @Inject constructor( + private val rewardApiService: RewardApiService, +) : RewardDataSource { + override suspend fun getRewards(): ApiResponse> = + rewardApiService.getRewards() + + override suspend fun getRewardInfo(): ApiResponse> = + rewardApiService.getRewardInfo() + +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSource.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSource.kt index 0a994ebe..d08625dd 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSource.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSource.kt @@ -4,9 +4,12 @@ import com.silvertown.android.dailyphrase.data.network.common.ApiResponse import com.silvertown.android.dailyphrase.data.network.model.request.ShareEventRequest import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse import com.silvertown.android.dailyphrase.data.network.model.response.ShareEventResponse +import com.silvertown.android.dailyphrase.data.network.model.response.SharedCountResponse interface ShareDataSource { suspend fun logShareEvent( data: ShareEventRequest, ): ApiResponse> + + suspend fun getSharedCount(): ApiResponse> } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSourceImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSourceImpl.kt index 340ae869..a8ce88e9 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSourceImpl.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/datasource/ShareDataSourceImpl.kt @@ -4,6 +4,7 @@ import com.silvertown.android.dailyphrase.data.network.common.ApiResponse import com.silvertown.android.dailyphrase.data.network.model.request.ShareEventRequest import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse import com.silvertown.android.dailyphrase.data.network.model.response.ShareEventResponse +import com.silvertown.android.dailyphrase.data.network.model.response.SharedCountResponse import com.silvertown.android.dailyphrase.data.network.service.ShareApiService import javax.inject.Inject @@ -14,4 +15,10 @@ class ShareDataSourceImpl @Inject constructor( override suspend fun logShareEvent(data: ShareEventRequest): ApiResponse> { return shareApiService.logShareEvent(data = data) } + + override suspend fun getSharedCount(): ApiResponse> { + return shareApiService.getSharedCount() + } + + } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/ApiServiceModule.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/ApiServiceModule.kt index 071a5217..271ade35 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/ApiServiceModule.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/ApiServiceModule.kt @@ -3,6 +3,7 @@ package com.silvertown.android.dailyphrase.data.network.di import com.silvertown.android.dailyphrase.data.network.service.MemberApiService import com.silvertown.android.dailyphrase.data.network.service.ModalApiService import com.silvertown.android.dailyphrase.data.network.service.PostApiService +import com.silvertown.android.dailyphrase.data.network.service.RewardApiService import com.silvertown.android.dailyphrase.data.network.service.ShareApiService import dagger.Module import dagger.Provides @@ -58,4 +59,15 @@ object ApiServiceModule { .client(okHttpclient) .build() .create(ModalApiService::class.java) + + @Singleton + @Provides + fun provideRewardApiService( + @AuthOkHttpClient okHttpclient: OkHttpClient, + retrofit: Retrofit.Builder, + ): RewardApiService = + retrofit + .client(okHttpclient) + .build() + .create(RewardApiService::class.java) } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/DataSourceModule.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/DataSourceModule.kt index 62475448..4bc4761f 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/DataSourceModule.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/di/DataSourceModule.kt @@ -6,11 +6,14 @@ import com.silvertown.android.dailyphrase.data.network.datasource.ModalDataSourc import com.silvertown.android.dailyphrase.data.network.datasource.ModalDataSourceImpl import com.silvertown.android.dailyphrase.data.network.datasource.PostDataSource import com.silvertown.android.dailyphrase.data.network.datasource.PostDataSourceImpl +import com.silvertown.android.dailyphrase.data.network.datasource.RewardDataSource +import com.silvertown.android.dailyphrase.data.network.datasource.RewardDataSourceImpl import com.silvertown.android.dailyphrase.data.network.datasource.ShareDataSource import com.silvertown.android.dailyphrase.data.network.datasource.ShareDataSourceImpl import com.silvertown.android.dailyphrase.data.network.service.MemberApiService import com.silvertown.android.dailyphrase.data.network.service.ModalApiService import com.silvertown.android.dailyphrase.data.network.service.PostApiService +import com.silvertown.android.dailyphrase.data.network.service.RewardApiService import com.silvertown.android.dailyphrase.data.network.service.ShareApiService import dagger.Module import dagger.Provides @@ -40,4 +43,9 @@ object DataSourceModule { @Singleton fun provideModalDataSource(modalApiService: ModalApiService): ModalDataSource = ModalDataSourceImpl(modalApiService) + + @Provides + @Singleton + fun provideRewardDataSource(rewardApiService: RewardApiService): RewardDataSource = + RewardDataSourceImpl(rewardApiService) } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardInfoResponse.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardInfoResponse.kt new file mode 100644 index 00000000..685be0b1 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardInfoResponse.kt @@ -0,0 +1,35 @@ +package com.silvertown.android.dailyphrase.data.network.model.response + +import com.google.gson.annotations.SerializedName +import com.silvertown.android.dailyphrase.data.util.DateTimeUtils +import com.silvertown.android.dailyphrase.domain.model.RewardInfo +import java.time.LocalDateTime + +data class RewardInfoResponse( + @SerializedName("eventId") + val eventId: Int?, + @SerializedName("name") + val name: String?, + @SerializedName("eventStartDateTime") + val eventStartDateTime: String?, + @SerializedName("eventEndDateTime") + val eventEndDateTime: String?, + @SerializedName("eventWinnerAnnouncementDateTime") + val eventWinnerAnnouncementDateTime: String?, +) + +fun RewardInfoResponse.toDomainModel(): RewardInfo { + return RewardInfo( + eventId = eventId, + name = name.orEmpty(), + eventStartDateTime = eventStartDateTime.toLocalDateTimeOrNull(), + eventEndDateTime = eventEndDateTime.toLocalDateTimeOrNull(), + eventWinnerAnnouncementDateTime = eventWinnerAnnouncementDateTime.toLocalDateTimeOrNull() + ) +} + +fun String?.toLocalDateTimeOrNull(): LocalDateTime? { + return this?.let { + LocalDateTime.parse(it, DateTimeUtils.localDateTimeFormatter) + } +} diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardResponse.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardResponse.kt new file mode 100644 index 00000000..61c9ead4 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardResponse.kt @@ -0,0 +1,45 @@ +package com.silvertown.android.dailyphrase.data.network.model.response + +import com.google.gson.annotations.SerializedName +import com.silvertown.android.dailyphrase.domain.model.RewardBanner + +data class RewardResponse( + @SerializedName("eventId") + val eventId: Int?, + @SerializedName("imageUrl") + val imageUrl: String?, + @SerializedName("manufacturer") + val manufacturer: String?, + @SerializedName("myEntryCount") + val myEntryCount: Int?, + @SerializedName("myTicketCount") + val myTicketCount: Int?, + @SerializedName("name") + val name: String?, + @SerializedName("prizeId") + val prizeId: Int?, + @SerializedName("requiredTicketCount") + val requiredTicketCount: Int?, + @SerializedName("totalParticipantCount") + val totalParticipantCount: Int?, + @SerializedName("shortName") + val shortName: String?, + @SerializedName("totalEntryCount") + val totalEntryCount: Int?, +) + +fun RewardResponse.toRewardBannerDomainModel(): RewardBanner { + return RewardBanner( + eventId = eventId ?: 0, + imageUrl = imageUrl.orEmpty(), + manufacturer = manufacturer.orEmpty(), + myEntryCount = myEntryCount ?: 0, + name = name.orEmpty(), + prizeId = prizeId ?: 0, + requiredTicketCount = requiredTicketCount ?: 0, + shortName = shortName.orEmpty(), + totalParticipantCount = totalParticipantCount ?: 0, + totalEntryCount = totalEntryCount ?: 0, + myTicketCount = myTicketCount ?: 0, + ) +} diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardWrapperResponse.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardWrapperResponse.kt new file mode 100644 index 00000000..cf8db193 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/RewardWrapperResponse.kt @@ -0,0 +1,10 @@ +package com.silvertown.android.dailyphrase.data.network.model.response + +import com.google.gson.annotations.SerializedName + +data class RewardWrapperResponse( + @SerializedName("prizeList") + val rewardList: List?, + @SerializedName("total") + val total: Int?, +) diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/SharedCountResponse.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/SharedCountResponse.kt new file mode 100644 index 00000000..2fb74cc2 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/model/response/SharedCountResponse.kt @@ -0,0 +1,17 @@ +package com.silvertown.android.dailyphrase.data.network.model.response + +import com.google.gson.annotations.SerializedName +import com.silvertown.android.dailyphrase.domain.model.SharedCountModel + +data class SharedCountResponse( + @SerializedName("shareCount") + val shareCount: Int?, + @SerializedName("date") + val date: String?, +) + +fun SharedCountResponse.toDomainModel(): SharedCountModel { + return SharedCountModel( + sharedCount = shareCount ?: 0 + ) +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/RewardApiService.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/RewardApiService.kt new file mode 100644 index 00000000..31d98e05 --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/RewardApiService.kt @@ -0,0 +1,15 @@ +package com.silvertown.android.dailyphrase.data.network.service + +import com.silvertown.android.dailyphrase.data.network.common.ApiResponse +import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardInfoResponse +import com.silvertown.android.dailyphrase.data.network.model.response.RewardWrapperResponse +import retrofit2.http.GET + +interface RewardApiService { + @GET("/api/v1/events/prizes") + suspend fun getRewards(): ApiResponse> + + @GET("/api/v1/events/info") + suspend fun getRewardInfo(): ApiResponse> +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/ShareApiService.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/ShareApiService.kt index 316d952f..20dfdf3e 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/ShareApiService.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/network/service/ShareApiService.kt @@ -4,12 +4,20 @@ import com.silvertown.android.dailyphrase.data.network.common.ApiResponse import com.silvertown.android.dailyphrase.data.network.model.request.ShareEventRequest import com.silvertown.android.dailyphrase.data.network.model.response.BaseResponse import com.silvertown.android.dailyphrase.data.network.model.response.ShareEventResponse +import com.silvertown.android.dailyphrase.data.network.model.response.SharedCountResponse import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Query interface ShareApiService { @POST("/api/v1/shares") suspend fun logShareEvent( @Body data: ShareEventRequest, ): ApiResponse> + + @GET("/api/v1/shares/me") + suspend fun getSharedCount( + @Query("date") date: String? = null + ): ApiResponse> } \ No newline at end of file diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/MemberRepositoryImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/MemberRepositoryImpl.kt index b1a704ab..6898288e 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/MemberRepositoryImpl.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/MemberRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.silvertown.android.dailyphrase.data.datastore.datasource.TokenDataSou import com.silvertown.android.dailyphrase.data.network.common.toResultModel import com.silvertown.android.dailyphrase.data.network.datasource.MemberDataSource import com.silvertown.android.dailyphrase.data.network.model.response.toDomainModel +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.domain.model.Member import com.silvertown.android.dailyphrase.domain.model.Result import com.silvertown.android.dailyphrase.domain.model.SignInToken @@ -56,16 +57,33 @@ class MemberRepositoryImpl @Inject constructor( .getSignInToken(token = token) .toResultModel { it.result?.toDomainModel() } - override suspend fun getLoginStatus(): Boolean = - tokenDataSource.getLoginState() - - override fun getLoginStateFlow(): Flow { - return tokenDataSource.getAccessToken().map { accessToken -> - !accessToken.isNullOrEmpty() + override suspend fun getLoginState(): LoginState { + val accessToken = tokenDataSource.getAccessToken() + return LoginState( + isLoggedIn = !accessToken.isNullOrEmpty(), + accessToken = accessToken.orEmpty() + ) + } + + override fun getLoginStateFlow(): Flow { + return tokenDataSource.getAccessTokenFlow().map { accessToken -> + LoginState( + !accessToken.isNullOrEmpty(), + accessToken.orEmpty() + ) } } override suspend fun deleteAccessToken() = tokenDataSource.deleteAccessToken() + override suspend fun updateSharedCount(count: Int) { + memberPreferencesDataSource.updateSharedCount(count) + } + + override fun getSharedCountFlow(): Flow { + return memberPreferencesDataSource.memberData.map { + it.sharedCount + } + } } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/RewardRepositoryImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/RewardRepositoryImpl.kt new file mode 100644 index 00000000..95103b6e --- /dev/null +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/RewardRepositoryImpl.kt @@ -0,0 +1,50 @@ +package com.silvertown.android.dailyphrase.data.repository + +import com.silvertown.android.dailyphrase.data.network.common.toResultModel +import com.silvertown.android.dailyphrase.data.network.datasource.RewardDataSource +import com.silvertown.android.dailyphrase.data.network.model.response.toDomainModel +import com.silvertown.android.dailyphrase.data.network.model.response.toRewardBannerDomainModel +import com.silvertown.android.dailyphrase.domain.model.RewardBanner +import com.silvertown.android.dailyphrase.domain.model.RewardInfo +import com.silvertown.android.dailyphrase.domain.model.onFailure +import com.silvertown.android.dailyphrase.domain.model.onSuccess +import com.silvertown.android.dailyphrase.domain.repository.RewardRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import timber.log.Timber +import javax.inject.Inject + +class RewardRepositoryImpl @Inject constructor( + private val rewardDataSource: RewardDataSource, +) : RewardRepository { + override fun getHomeRewardBanner(): Flow = flow { + rewardDataSource + .getRewards() + .toResultModel { rewardWrapper -> + rewardWrapper.result?.rewardList?.map { reward -> + reward.toRewardBannerDomainModel() + } + } + .onSuccess { rewardBannerList -> + rewardBannerList.randomOrNull()?.let { rewardBanner -> + emit(rewardBanner) + } + } + .onFailure { errorMessage, _ -> + Timber.e(errorMessage) + } + } + + override fun getRewardInfo(): Flow = flow { + rewardDataSource.getRewardInfo() + .toResultModel { + it.result?.toDomainModel() + } + .onSuccess { rewardInfo -> + emit(rewardInfo) + } + .onFailure { errorMessage, _ -> + Timber.e(errorMessage) + } + } +} diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/ShareRepositoryImpl.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/ShareRepositoryImpl.kt index 0f827c2e..ea63c7f4 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/ShareRepositoryImpl.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/ShareRepositoryImpl.kt @@ -5,7 +5,10 @@ import com.silvertown.android.dailyphrase.data.network.common.toResultModel import com.silvertown.android.dailyphrase.data.network.datasource.ShareDataSource import com.silvertown.android.dailyphrase.data.network.model.request.ShareEventRequest import com.silvertown.android.dailyphrase.data.network.model.response.toDomainModel +import com.silvertown.android.dailyphrase.domain.model.onFailure +import com.silvertown.android.dailyphrase.domain.model.onSuccess import com.silvertown.android.dailyphrase.domain.repository.ShareRepository +import timber.log.Timber import javax.inject.Inject class ShareRepositoryImpl @Inject constructor( @@ -20,4 +23,17 @@ class ShareRepositoryImpl @Inject constructor( ), ).toResultModel { it.result?.toDomainModel() } } + + override suspend fun updateSharedCount() { + shareDataSource.getSharedCount() + .toResultModel { + it.result?.toDomainModel() + } + .onSuccess { sharedCount -> + memberPreferencesDataSource.updateSharedCount(sharedCount.sharedCount) + } + .onFailure { errorMessage, _ -> + Timber.e(errorMessage) + } + } } diff --git a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/di/RepositoryModule.kt b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/di/RepositoryModule.kt index b3177782..8944475f 100644 --- a/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/di/RepositoryModule.kt +++ b/data/src/main/kotlin/com/silvertown/android/dailyphrase/data/repository/di/RepositoryModule.kt @@ -3,10 +3,12 @@ package com.silvertown.android.dailyphrase.data.repository.di import com.silvertown.android.dailyphrase.data.repository.MemberRepositoryImpl import com.silvertown.android.dailyphrase.data.repository.ModalRepositoryImpl import com.silvertown.android.dailyphrase.data.repository.PostRepositoryImpl +import com.silvertown.android.dailyphrase.data.repository.RewardRepositoryImpl import com.silvertown.android.dailyphrase.data.repository.ShareRepositoryImpl import com.silvertown.android.dailyphrase.domain.repository.MemberRepository import com.silvertown.android.dailyphrase.domain.repository.ModalRepository import com.silvertown.android.dailyphrase.domain.repository.PostRepository +import com.silvertown.android.dailyphrase.domain.repository.RewardRepository import com.silvertown.android.dailyphrase.domain.repository.ShareRepository import dagger.Binds import dagger.Module @@ -40,4 +42,10 @@ internal interface RepositoryModule { fun bindModalRepository( modalRepository: ModalRepositoryImpl, ): ModalRepository + + @Singleton + @Binds + fun bindRewardRepository( + rewardRepository: RewardRepositoryImpl, + ): RewardRepository } diff --git a/data/src/main/proto/member_preferences.proto b/data/src/main/proto/member_preferences.proto index 095a5b0a..25ef9cdc 100644 --- a/data/src/main/proto/member_preferences.proto +++ b/data/src/main/proto/member_preferences.proto @@ -7,4 +7,5 @@ message MemberPreferences { int64 id = 1; string name = 2; string imageUrl = 3; -} + int32 sharedCount = 4; +} \ No newline at end of file diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/LoginState.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/LoginState.kt new file mode 100644 index 00000000..d46fc692 --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/LoginState.kt @@ -0,0 +1,6 @@ +package com.silvertown.android.dailyphrase.domain.model + +data class LoginState( + val isLoggedIn: Boolean = false, + val accessToken: String = "", +) \ No newline at end of file diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/Member.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/Member.kt index 03dd94be..6c9f71d1 100644 --- a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/Member.kt +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/Member.kt @@ -6,6 +6,7 @@ data class Member( val imageUrl: String = DEFAULT_IMAGE_URL, val email: String = DEFAULT_EMAIL, val quitAt: String = DEFAULT_QUIT_AT, + val sharedCount: Int = 0, ) { companion object { const val DEFAULT_ID = 0 diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardBanner.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardBanner.kt new file mode 100644 index 00000000..ffddc3c5 --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardBanner.kt @@ -0,0 +1,24 @@ +package com.silvertown.android.dailyphrase.domain.model + +import java.time.LocalDateTime + +data class RewardBanner( + val eventId: Int, + val imageUrl: String, + val manufacturer: String, + val myEntryCount: Int, + val name: String, + val prizeId: Int, + val requiredTicketCount: Int, + val totalParticipantCount: Int, + val shortName: String, + val totalEntryCount: Int, + val myTicketCount: Int, +) + +data class HomeRewardState( + val rewardBanner: RewardBanner, + val name: String, + val eventEndDateTime: LocalDateTime?, + val shareCount: Int, +) diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardInfo.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardInfo.kt new file mode 100644 index 00000000..ecec7d28 --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/RewardInfo.kt @@ -0,0 +1,11 @@ +package com.silvertown.android.dailyphrase.domain.model + +import java.time.LocalDateTime + +data class RewardInfo( + val eventId: Int?, + val name: String, + val eventStartDateTime: LocalDateTime?, + val eventEndDateTime: LocalDateTime?, + val eventWinnerAnnouncementDateTime: LocalDateTime?, +) \ No newline at end of file diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/SharedCountModel.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/SharedCountModel.kt new file mode 100644 index 00000000..c8b0703c --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/model/SharedCountModel.kt @@ -0,0 +1,5 @@ +package com.silvertown.android.dailyphrase.domain.model + +data class SharedCountModel( + val sharedCount: Int, +) \ No newline at end of file diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/MemberRepository.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/MemberRepository.kt index f8d1f7bc..ce4b46cc 100644 --- a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/MemberRepository.kt +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/MemberRepository.kt @@ -1,5 +1,6 @@ package com.silvertown.android.dailyphrase.domain.repository +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.domain.model.Member import com.silvertown.android.dailyphrase.domain.model.SignInToken import com.silvertown.android.dailyphrase.domain.model.Result @@ -27,10 +28,13 @@ interface MemberRepository { token: String, ): Result - suspend fun getLoginStatus(): Boolean + suspend fun getLoginState(): LoginState - fun getLoginStateFlow(): Flow + fun getLoginStateFlow(): Flow suspend fun deleteAccessToken() + suspend fun updateSharedCount(count: Int) + + fun getSharedCountFlow(): Flow } diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/RewardRepository.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/RewardRepository.kt new file mode 100644 index 00000000..10fb10d3 --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/RewardRepository.kt @@ -0,0 +1,10 @@ +package com.silvertown.android.dailyphrase.domain.repository + +import com.silvertown.android.dailyphrase.domain.model.RewardBanner +import com.silvertown.android.dailyphrase.domain.model.RewardInfo +import kotlinx.coroutines.flow.Flow + +interface RewardRepository { + fun getHomeRewardBanner(): Flow + fun getRewardInfo(): Flow +} diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/ShareRepository.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/ShareRepository.kt index ed747f87..3dc2a21b 100644 --- a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/ShareRepository.kt +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/repository/ShareRepository.kt @@ -4,4 +4,6 @@ interface ShareRepository { suspend fun logShareEvent( phraseId: Long, ) + + suspend fun updateSharedCount() } diff --git a/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/usecase/GetHomeRewardStateUseCase.kt b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/usecase/GetHomeRewardStateUseCase.kt new file mode 100644 index 00000000..f2175e20 --- /dev/null +++ b/domain/src/main/kotlin/com/silvertown/android/dailyphrase/domain/usecase/GetHomeRewardStateUseCase.kt @@ -0,0 +1,48 @@ +package com.silvertown.android.dailyphrase.domain.usecase + +import com.silvertown.android.dailyphrase.domain.model.HomeRewardState +import com.silvertown.android.dailyphrase.domain.model.SharedCountModel +import com.silvertown.android.dailyphrase.domain.repository.MemberRepository +import com.silvertown.android.dailyphrase.domain.repository.RewardRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GetHomeRewardStateUseCase @Inject constructor( + private val rewardRepository: RewardRepository, + private val memberRepository: MemberRepository, +) { + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(isLoggedIn: Boolean): Flow { + val rewardInfoFlow = rewardRepository.getRewardInfo() + + val sharedCountModelFlow = if (isLoggedIn) { + memberRepository.getSharedCountFlow().map { + SharedCountModel(it) + } + } else { + flowOf(SharedCountModel(0)) + } + + return sharedCountModelFlow.flatMapLatest { sharedCountModel -> + val rewardBannerFlow = rewardRepository.getHomeRewardBanner() + + combine( + rewardBannerFlow, + rewardInfoFlow, + flowOf(sharedCountModel) + ) { banner, info, countModel -> + HomeRewardState( + rewardBanner = banner, + name = info.name, + eventEndDateTime = info.eventEndDateTime, + shareCount = countModel.sharedCount + ) + } + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ce3ca02..d263cd26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ protobufPlugin = "0.9.4" androidxDataStore = "1.0.0" lifecycleCompose = "2.7.0" firebase-messaging = "23.4.1" +balloonCompose = "1.6.5" flipper = "0.190.0" soloader = "0.10.4" @@ -69,6 +70,7 @@ androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fr androidx-navigation-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +balloon-compose = { group = "com.github.skydoves", name = "balloon-compose", version.ref = "balloonCompose" } converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index ec69fd9e..0e027547 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -22,6 +22,7 @@ android { defaultConfig { minSdk = Configuration.minSdk + multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -49,6 +50,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -100,6 +102,8 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.bundles.android.test) + implementation(libs.balloon.compose) + // cp implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) @@ -115,5 +119,6 @@ dependencies { debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) + coreLibraryDesugaring(libs.desugar.jdk.libs) implementation(libs.androidx.core.splashscreen) } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainActivity.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainActivity.kt index c88e1481..7915411f 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainActivity.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainActivity.kt @@ -14,7 +14,6 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController @@ -29,6 +28,7 @@ import com.silvertown.android.dailyphrase.presentation.databinding.ActivityMainB import com.silvertown.android.dailyphrase.presentation.util.LoginResultListener import com.silvertown.android.dailyphrase.presentation.util.Constants.PHRASE_ID import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber @@ -75,7 +75,7 @@ class MainActivity : AppCompatActivity() { this.loginResultListener = listener } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) navController.handleDeepLink(intent) @@ -105,11 +105,23 @@ class MainActivity : AppCompatActivity() { } } } + + lifecycleScope.launch { + viewModel.loginState.collectLatest { state -> + if (state.isLoggedIn) { + viewModel.updateSharedCount() + } + } + } } private fun setFragmentResultListeners() { - val navHostFragment = supportFragmentManager.findFragmentById(binding.fcvNavHost.id) as NavHostFragment - navHostFragment.childFragmentManager.setFragmentResultListener(REQUEST_KEY_MOVE_TO_UPDATE, this) { _, _ -> + val navHostFragment = + supportFragmentManager.findFragmentById(binding.fcvNavHost.id) as NavHostFragment + navHostFragment.childFragmentManager.setFragmentResultListener( + REQUEST_KEY_MOVE_TO_UPDATE, + this + ) { _, _ -> moveToUpdate() } } @@ -120,7 +132,10 @@ class MainActivity : AppCompatActivity() { startActivity(intent) } catch (e: Exception) { val webIntent = - Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")) + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName") + ) if (webIntent.resolveActivity(packageManager) != null) { startActivity(webIntent) } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainViewModel.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainViewModel.kt index 36701eaa..dd1ad362 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainViewModel.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/MainViewModel.kt @@ -3,6 +3,7 @@ package com.silvertown.android.dailyphrase.presentation import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.domain.model.onFailure import com.silvertown.android.dailyphrase.domain.model.onSuccess import com.silvertown.android.dailyphrase.domain.repository.FirebaseRemoteConfigRepository @@ -10,12 +11,16 @@ import com.silvertown.android.dailyphrase.domain.repository.FirebaseRemoteConfig import com.silvertown.android.dailyphrase.domain.repository.FirebaseRemoteConfigRepository.Companion.REMOTE_KEY_NEED_UPDATE_APP_VERSION import com.silvertown.android.dailyphrase.domain.repository.MemberRepository import com.silvertown.android.dailyphrase.domain.repository.ModalRepository +import com.silvertown.android.dailyphrase.domain.repository.ShareRepository import com.silvertown.android.dailyphrase.domain.usecase.CompareVersionUseCase import com.silvertown.android.dailyphrase.domain.usecase.GetSignInTokenUseCase import com.silvertown.android.dailyphrase.presentation.util.Constants.PHRASE_ID import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.zip import kotlinx.coroutines.launch import timber.log.Timber @@ -29,11 +34,21 @@ class MainViewModel @Inject constructor( private val firebaseRemoteConfigRepository: FirebaseRemoteConfigRepository, private val compareVersionUseCase: CompareVersionUseCase, private val modalRepository: ModalRepository, + private val shareRepository: ShareRepository ) : ViewModel() { private val _uiEvent = MutableSharedFlow() val uiEvent = _uiEvent.asSharedFlow() + val loginState: StateFlow = + memberRepository + .getLoginStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000L), + initialValue = LoginState() + ) + init { checkVersion() } @@ -103,6 +118,12 @@ class MainViewModel @Inject constructor( } } + fun updateSharedCount() { + viewModelScope.launch { + shareRepository.updateSharedCount() + } + } + sealed interface UiEvent { data class NeedUpdate( val imageUrl: String, diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Button.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Button.kt index 118f7f81..22479b05 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Button.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Button.kt @@ -1,6 +1,7 @@ package com.silvertown.android.dailyphrase.presentation.component import android.content.Context +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -39,6 +40,7 @@ import kotlinx.coroutines.launch @Composable fun KaKaoLoginButton( modifier: Modifier = Modifier, + @StringRes title: Int = R.string.kakao_login, onClickKaKaoLogin: () -> Unit, ) { Button( @@ -70,7 +72,7 @@ fun KaKaoLoginButton( Text( modifier = Modifier .align(Alignment.Center), - text = stringResource(id = R.string.kakao_login), + text = stringResource(id = title), style = TextStyle( fontSize = 16.sp, fontFamily = pretendardFamily, diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Dialog.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Dialog.kt index 4676bdc9..2dfa3b86 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Dialog.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/component/Dialog.kt @@ -74,10 +74,10 @@ fun DialogContent( @Composable fun KakaoLoginDialog( - modifier: Modifier = Modifier, @StringRes message: Int, onClickKaKaoLogin: () -> Unit, onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, ) { Column( modifier = modifier diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/ActionType.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/ActionType.kt deleted file mode 100644 index 1e16dc14..00000000 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/ActionType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.silvertown.android.dailyphrase.presentation.ui - -enum class ActionType { - LIKE, BOOKMARK, SHARE, NONE -} diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkFragment.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkFragment.kt index 80b0b09f..2f38705b 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkFragment.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkFragment.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.presentation.R import com.silvertown.android.dailyphrase.presentation.databinding.FragmentBookmarkBinding import com.silvertown.android.dailyphrase.presentation.base.BaseFragment @@ -22,7 +23,7 @@ import kotlinx.coroutines.launch class BookmarkFragment : BaseFragment(FragmentBookmarkBinding::inflate) { private lateinit var adapter: BookmarkAdapter private val viewModel by viewModels() - private var isLoggedIn: Boolean = false + private var loginState: LoginState = LoginState() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -37,7 +38,7 @@ class BookmarkFragment : BaseFragment(FragmentBookmarkB findNavController().popBackStack() // TODO: 주환 Dev 수정 예정 } binding.ivProfile.setOnClickListener { - if (isLoggedIn) { + if (loginState.isLoggedIn) { BookmarkFragmentDirections.moveToMyPageFragment() .also { findNavController().navigate(it) } } else { @@ -77,10 +78,10 @@ class BookmarkFragment : BaseFragment(FragmentBookmarkB } viewLifecycleOwner.lifecycleScope.launch { - viewModel.isLoggedIn + viewModel.loginState .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .collectLatest { state -> - isLoggedIn = state + loginState = state } } } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkViewModel.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkViewModel.kt index f75ade07..57f97cac 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkViewModel.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/bookmark/BookmarkViewModel.kt @@ -2,6 +2,7 @@ package com.silvertown.android.dailyphrase.presentation.ui.bookmark import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.domain.model.Post import com.silvertown.android.dailyphrase.domain.model.onFailure import com.silvertown.android.dailyphrase.domain.model.onSuccess @@ -28,13 +29,13 @@ class BookmarkViewModel @Inject constructor( private val _bookmarkList = MutableStateFlow>(emptyList()) val bookmarkList = _bookmarkList.asStateFlow() - val isLoggedIn: StateFlow = + val loginState: StateFlow = memberRepository .getLoginStateFlow() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(1000L), - initialValue = false + initialValue = LoginState() ) fun getBookmarks() = viewModelScope.launch { diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailScreen.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailScreen.kt index ff695de2..5afc6d49 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailScreen.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailScreen.kt @@ -1,7 +1,6 @@ package com.silvertown.android.dailyphrase.presentation.ui.detail import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Context import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,14 +24,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.AdView -import com.kakao.sdk.common.util.KakaoCustomTabsClient -import com.kakao.sdk.share.ShareClient -import com.kakao.sdk.share.WebSharerClient -import com.kakao.sdk.template.model.Button -import com.kakao.sdk.template.model.Content -import com.kakao.sdk.template.model.FeedTemplate -import com.kakao.sdk.template.model.Link -import com.kakao.sdk.template.model.Social import com.silvertown.android.dailyphrase.presentation.BuildConfig import com.silvertown.android.dailyphrase.presentation.MainActivity import com.silvertown.android.dailyphrase.presentation.R @@ -43,11 +34,11 @@ import com.silvertown.android.dailyphrase.presentation.component.DailyPhraseBase import com.silvertown.android.dailyphrase.presentation.component.DetailBottomAction import com.silvertown.android.dailyphrase.presentation.component.KakaoLoginDialog import com.silvertown.android.dailyphrase.presentation.component.baseSnackbar -import com.silvertown.android.dailyphrase.presentation.ui.ActionType +import com.silvertown.android.dailyphrase.presentation.util.ActionType +import com.silvertown.android.dailyphrase.presentation.util.sendKakaoLink import com.silvertown.android.dailyphrase.presentation.util.vibrateSingle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import timber.log.Timber object Url { const val webUrl = "https://www.daily-phrase.com/phrase-web/" @@ -86,6 +77,7 @@ fun DetailScreen( onClickBookmark = detailViewModel::onClickBookmark, logShareEvent = detailViewModel::logShareEvent, onClickShare = detailViewModel::onClickShare, + updateSharedCount = detailViewModel::updateSharedCount, showLoingDialog = detailViewModel::showLoginDialog, ) } @@ -101,6 +93,7 @@ fun DetailBody( onClickLike: () -> Unit, onClickBookmark: () -> Unit, logShareEvent: () -> Unit, + updateSharedCount: () -> Unit, onClickShare: () -> Unit, showLoingDialog: (Boolean) -> Unit, ) { @@ -169,7 +162,7 @@ fun DetailBody( sendKakaoLink( context = context, uiState = uiState, - logShareEvent = logShareEvent + logShareEvent = logShareEvent, ) } ) @@ -183,7 +176,7 @@ fun DetailBody( sendKakaoLink( context = context, uiState = uiState, - logShareEvent = logShareEvent + logShareEvent = logShareEvent, ) } } @@ -196,64 +189,19 @@ private fun sendKakaoLink( uiState: DetailUiState, logShareEvent: () -> Unit, ) { - val webUrl = Url.webUrl + uiState.phraseId - - val phraseFeed = FeedTemplate( - content = Content( - title = uiState.title, - description = uiState.content, - imageUrl = uiState.imageUrl, - link = Link( - webUrl = webUrl, - mobileWebUrl = webUrl - ) - ), - social = Social( - likeCount = uiState.likeCount, - commentCount = uiState.commentCount, - sharedCount = uiState.sharedCount, - viewCount = uiState.viewCount - ), - buttons = listOf( - Button( - title = context.resources.getString(R.string.more_see), - Link( - webUrl = webUrl, - mobileWebUrl = webUrl - ) - ) - ) + sendKakaoLink( + context = context, + phraseId = uiState.phraseId, + title = uiState.title, + description = uiState.content, + imageUrl = uiState.imageUrl, + likeCount = uiState.likeCount, + commentCount = uiState.commentCount, + sharedCount = uiState.sharedCount, + viewCount = uiState.viewCount, + accessToken = uiState.accessToken, + logShareEvent = logShareEvent, ) - - if (ShareClient.instance.isKakaoTalkSharingAvailable(context)) { - ShareClient.instance.shareDefault(context, phraseFeed) { sharingResult, error -> - if (error != null) { - Timber.e(error) - Timber.e("카카오톡 공유 실패", error) - } else if (sharingResult != null) { - Timber.d("카카오톡 공유 성공 ${sharingResult.intent}") - context.startActivity(sharingResult.intent) - - Timber.w("Warning Msg: ${sharingResult.warningMsg}") - Timber.w("Argument Msg: ${sharingResult.argumentMsg}") - logShareEvent() - } - } - } else { - val sharerUrl = WebSharerClient.instance.makeDefaultUrl(phraseFeed) - - try { - KakaoCustomTabsClient.openWithDefault(context, sharerUrl) - } catch (e: UnsupportedOperationException) { - // CustomTabsServiceConnection 지원 브라우저가 없을 때 예외처리 - } - - try { - KakaoCustomTabsClient.open(context, sharerUrl) - } catch (e: ActivityNotFoundException) { - // 디바이스에 설치된 인터넷 브라우저가 없을 때 예외처리 - } - } } @Composable diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailUiState.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailUiState.kt index f1e0eb23..b477d88a 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailUiState.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailUiState.kt @@ -13,4 +13,5 @@ data class DetailUiState( val viewCount: Int = 0, val isLike: Boolean = false, val isBookmark: Boolean = false, + val accessToken: String = "", ) diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailViewModel.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailViewModel.kt index 212ef9e8..b305dae9 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailViewModel.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/detail/DetailViewModel.kt @@ -182,9 +182,11 @@ class DetailViewModel @Inject constructor( private fun getLoginState() = _detailUiState.value.isLoggedIn fun updateLoginState() = viewModelScope.launch { + val loginState = memberRepository.getLoginState() _detailUiState.update { state -> state.copy( - isLoggedIn = memberRepository.getLoginStatus(), + isLoggedIn = loginState.isLoggedIn, + accessToken = loginState.accessToken ) } } @@ -224,4 +226,13 @@ class DetailViewModel @Inject constructor( shareRepository.logShareEvent(_detailUiState.value.phraseId) } } + + fun updateSharedCount() { + viewModelScope.launch { + val loginState = memberRepository.getLoginState() + if (loginState.isLoggedIn) { + shareRepository.updateSharedCount() + } + } + } } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeFragment.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeFragment.kt index 89aa0345..f49a52e3 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeFragment.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeFragment.kt @@ -5,7 +5,12 @@ import android.view.View import android.view.WindowManager import android.widget.Toast import androidx.annotation.ColorRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.view.isVisible @@ -16,17 +21,30 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.paging.LoadState +import androidx.recyclerview.widget.ConcatAdapter +import com.silvertown.android.dailyphrase.domain.model.HomeRewardState +import com.silvertown.android.dailyphrase.domain.model.LoginState +import com.silvertown.android.dailyphrase.domain.model.Post import com.silvertown.android.dailyphrase.presentation.MainActivity import com.silvertown.android.dailyphrase.presentation.R import com.silvertown.android.dailyphrase.presentation.databinding.FragmentHomeBinding import com.silvertown.android.dailyphrase.presentation.base.BaseFragment import com.silvertown.android.dailyphrase.presentation.component.BaseDialog import com.silvertown.android.dailyphrase.presentation.component.KakaoLoginDialog -import com.silvertown.android.dailyphrase.presentation.ui.ActionType +import com.silvertown.android.dailyphrase.presentation.util.ActionType +import com.silvertown.android.dailyphrase.presentation.ui.reward.RewardPopup +import com.silvertown.android.dailyphrase.presentation.util.Constants.TWENTY_FOUR_HOURS_IN_MILLIS +import com.silvertown.android.dailyphrase.presentation.util.Constants.TWO_MINUTES_IN_MILLIS import com.silvertown.android.dailyphrase.presentation.util.LoginResultListener +import com.silvertown.android.dailyphrase.presentation.util.sendKakaoLink import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime @AndroidEntryPoint class HomeFragment : @@ -34,8 +52,10 @@ class HomeFragment : LoginResultListener { private lateinit var postAdapter: PostAdapter + private lateinit var rewardBannerAdapter: HomeRewardBannerAdapter + private lateinit var homeAdapter: ConcatAdapter private val viewModel by viewModels() - private var isLoggedIn: Boolean = false + private var loginState: LoginState = LoginState() private var actionState: ActionType = ActionType.NONE override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -51,7 +71,7 @@ class HomeFragment : (activity as MainActivity).setLoginResultListener(this) binding.tvBookmark.setOnClickListener { - if (isLoggedIn) { + if (loginState.isLoggedIn) { HomeFragmentDirections .moveToBookmarkFragment() .also { findNavController().navigate(it) } @@ -62,7 +82,7 @@ class HomeFragment : } binding.ivProfile.setOnClickListener { - if (isLoggedIn) { + if (loginState.isLoggedIn) { HomeFragmentDirections .moveToMyPageFragment() .also { findNavController().navigate(it) } @@ -78,36 +98,37 @@ class HomeFragment : postAdapter = PostAdapter( onPostClick = { moveToDetail(it) }, onClickBookmark = { phraseId, state -> - if (isLoggedIn) { - viewModel.onClickBookmark(phraseId, state) - } else { - actionState = ActionType.BOOKMARK - viewModel.showLoginDialog(true) - } + onClickBookmark(phraseId, state) }, onClickLike = { phraseId, state -> - if (isLoggedIn) { - viewModel.onClickLike(phraseId, state) - } else { - actionState = ActionType.LIKE - viewModel.showLoginDialog(true) - } + onClickLike(phraseId, state) + }, + onClickShare = { post -> + onClickShare(post) } ) + rewardBannerAdapter = HomeRewardBannerAdapter( + // TODO: 주환데브 연결 필요 -> 홈 배너에서 "3초만에 로그인하기" 클릭시 + // TODO: kakaoLogin callback 함수 하나 넣어서 처리해도 될 듯? + onClickKaKaoLogin = { (activity as? MainActivity)?.kakaoLogin() } + ) + binding.rvPost.apply { - adapter = postAdapter.run { - withLoadStateFooter(PostFooterLoadStateAdapter { postAdapter.retry() }) - } - var firstLoad = true + homeAdapter = ConcatAdapter( + rewardBannerAdapter, + postAdapter.apply { + withLoadStateFooter(PostFooterLoadStateAdapter { postAdapter.retry() }) + } + ) + adapter = homeAdapter postAdapter.addOnPagesUpdatedListener { - if (postAdapter.itemCount > 0 && firstLoad) { + if (postAdapter.itemCount > 0 && viewModel.getFirstLoad()) { scrollToPosition(0) - firstLoad = false + viewModel.setFirstLoad() } } setHasFixedSize(true) - addItemDecoration(PostItemDecoration(requireContext())) } binding.retryButton.setOnClickListener { @@ -123,10 +144,24 @@ class HomeFragment : } viewLifecycleOwner.lifecycleScope.launch { - viewModel.isLoggedIn + viewModel.rewardState + .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) + .filterNotNull() + .collectLatest { + rewardBannerAdapter.submitList(listOf(it.rewardBanner)) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.loginState .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED) .collectLatest { state -> - isLoggedIn = state + loginState = state + if (state.isLoggedIn) { + removeRewardBannerAdapter() + } else { + addRewardBannerAdapter() + } } } @@ -144,13 +179,9 @@ class HomeFragment : private fun initComposeView() { binding.composeView.setContent { val showDialog by viewModel.showLoginDialog.collectAsStateWithLifecycle() - - val messageRes = when (ActionType.valueOf(actionState.name)) { - ActionType.LIKE -> R.string.login_and_like_message - ActionType.BOOKMARK -> R.string.login_and_bookmark_message - ActionType.SHARE -> R.string.login_and_share_message - ActionType.NONE -> R.string.login_and_share_message - } + val loginState by viewModel.loginState.collectAsStateWithLifecycle() + val rewardState by viewModel.rewardState.collectAsStateWithLifecycle() + val messageRes = ActionType.valueOf(actionState.name).messageRes if (showDialog) { BaseDialog( @@ -170,6 +201,14 @@ class HomeFragment : ) } } + + if (loginState.isLoggedIn) { + HomeRewardPopup( + rewardState = rewardState, + shareEvent = viewModel.shareEvent, + navigateToEventPage = { } // TODO: 주환데브 연결 필요 -> 로그인 상태에서 팝업 클릭 시 이벤트 페이지로 이동 + ) + } } } @@ -181,11 +220,25 @@ class HomeFragment : override fun onResume() { super.onResume() setStatusBarColor(R.color.home_app_bar) + viewModel.checkAndEmitSharedEvent() } override fun onStop() { super.onStop() setStatusBarColor(R.color.white) + viewModel.setPrevSharedCount() + } + + private fun addRewardBannerAdapter() { + if (!homeAdapter.adapters.contains(rewardBannerAdapter)) { + homeAdapter.addAdapter(0, rewardBannerAdapter) + } + } + + private fun removeRewardBannerAdapter() { + if (homeAdapter.adapters.contains(rewardBannerAdapter)) { + homeAdapter.removeAdapter(rewardBannerAdapter) + } } private fun setStatusBarColor(@ColorRes colorRes: Int) { @@ -195,4 +248,84 @@ class HomeFragment : } } + private fun onClickBookmark(phraseId: Long, state: Boolean) { + if (loginState.isLoggedIn) { + viewModel.onClickBookmark(phraseId, state) + } else { + actionState = ActionType.BOOKMARK + viewModel.showLoginDialog(true) + } + } + + private fun onClickLike(phraseId: Long, state: Boolean) { + if (loginState.isLoggedIn) { + viewModel.onClickLike(phraseId, state) + } else { + actionState = ActionType.LIKE + viewModel.showLoginDialog(true) + } + } + + private fun onClickShare(post: Post) { + if (loginState.isLoggedIn) { + sendKakaoLink( + context = requireContext(), + phraseId = post.phraseId, + title = post.title, + description = post.content, + imageUrl = post.imageUrl, + likeCount = post.likeCount, + viewCount = post.viewCount, + accessToken = loginState.accessToken + ) { + viewModel.logShareEvent(post.phraseId) + } + } else { + actionState = ActionType.SHARE + viewModel.showLoginDialog(true) + } + } + + private fun showEndedEventTimerPopupTooltip(remainTime: Long): Boolean { + return remainTime in (TWO_MINUTES_IN_MILLIS + 1) until TWENTY_FOUR_HOURS_IN_MILLIS + } + + @Composable + private fun HomeRewardPopup( + rewardState: HomeRewardState?, + shareEvent: SharedFlow, + navigateToEventPage: () -> Unit, + modifier: Modifier = Modifier, + ) { + var showEndedEventTimerPopupTooltip by remember { mutableStateOf(false) } + var showSharedEventTooltip by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + shareEvent.collect { + showSharedEventTooltip = true + delay(2000) + showSharedEventTooltip = false + } + } + + rewardState?.let { state -> + val remainTime = + Duration.between(LocalDateTime.now(), state.eventEndDateTime).toMillis() + + if (showEndedEventTimerPopupTooltip(remainTime)) { + showEndedEventTimerPopupTooltip = true + } + + RewardPopup( + modifier = modifier, + state = state, + showSharedEventTooltip = showSharedEventTooltip, + showEndedEventTimerPopupTooltip = showEndedEventTimerPopupTooltip, + onTimeBelowThreshold = { + showEndedEventTimerPopupTooltip = false + }, + navigateToEventPage = navigateToEventPage, + ) + } + } } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeRewardBannerAdapter.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeRewardBannerAdapter.kt new file mode 100644 index 00000000..cf8f9ef2 --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeRewardBannerAdapter.kt @@ -0,0 +1,56 @@ +package com.silvertown.android.dailyphrase.presentation.ui.home + +import android.view.ViewGroup +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.silvertown.android.dailyphrase.domain.model.RewardBanner +import com.silvertown.android.dailyphrase.presentation.ui.reward.HomeRewardBanner + +class HomeRewardBannerAdapter( + private val onClickKaKaoLogin: () -> Unit, +) : ListAdapter(RewardBannerDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RewardBannerViewHolder { + return RewardBannerViewHolder( + ComposeView(parent.context), + onClickKaKaoLogin + ) + } + + override fun onBindViewHolder(holder: RewardBannerViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class RewardBannerDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: RewardBanner, newItem: RewardBanner): Boolean { + return oldItem.prizeId == newItem.prizeId + } + + override fun areContentsTheSame(oldItem: RewardBanner, newItem: RewardBanner): Boolean { + return oldItem == newItem + } + } +} + +class RewardBannerViewHolder( + private val composeView: ComposeView, + private val onClickKaKaoLogin: () -> Unit, +) : RecyclerView.ViewHolder(composeView) { + + fun bind(rewardBanner: RewardBanner) { + composeView.setContent { + HomeRewardBanner( + modifier = Modifier + .padding(top = 19.dp, bottom = 32.dp) + .padding(horizontal = 16.dp), + rewardBanner = rewardBanner, + onClickKaKaoLogin = onClickKaKaoLogin + ) + } + } +} diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeViewModel.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeViewModel.kt index 334484d5..49e3d2cf 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeViewModel.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/HomeViewModel.kt @@ -1,15 +1,21 @@ package com.silvertown.android.dailyphrase.presentation.ui.home +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import com.silvertown.android.dailyphrase.domain.model.HomeRewardState +import com.silvertown.android.dailyphrase.domain.model.LoginState import com.silvertown.android.dailyphrase.domain.model.Post import com.silvertown.android.dailyphrase.domain.model.onFailure import com.silvertown.android.dailyphrase.domain.model.onSuccess import com.silvertown.android.dailyphrase.domain.repository.MemberRepository import com.silvertown.android.dailyphrase.domain.repository.PostRepository +import com.silvertown.android.dailyphrase.domain.repository.ShareRepository +import com.silvertown.android.dailyphrase.domain.usecase.GetHomeRewardStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +23,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber @@ -24,8 +31,11 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, private val postRepository: PostRepository, private val memberRepository: MemberRepository, + private val getHomeRewardStateUseCase: GetHomeRewardStateUseCase, + private val shareRepository: ShareRepository, ) : ViewModel() { private val _uiEvent = MutableSharedFlow() @@ -34,20 +44,36 @@ class HomeViewModel @Inject constructor( private val _showLoginDialog = MutableStateFlow(false) val showLoginDialog = _showLoginDialog.asStateFlow() + private val _shareEvent = MutableSharedFlow() + val shareEvent = _shareEvent.asSharedFlow() + + private val prevSharedCount: Int? + get() = savedStateHandle.get(SHARED_COUNT) + val postList: Flow> = postRepository .getPosts() .cachedIn(viewModelScope) - val isLoggedIn: StateFlow = + val loginState: StateFlow = memberRepository .getLoginStateFlow() .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(1000L), - initialValue = false + initialValue = LoginState() ) + @OptIn(ExperimentalCoroutinesApi::class) + val rewardState: StateFlow = + loginState.flatMapLatest { state -> + getHomeRewardStateUseCase(state.isLoggedIn) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000L), + initialValue = null + ) + /** TODO: 주환 작업부 **/ fun createMember(socialToken: String) { // TODO: Api call @@ -123,14 +149,74 @@ class HomeViewModel @Inject constructor( } } + fun getFirstLoad(): Boolean { + return savedStateHandle[FIRST_LOAD] ?: false + } + + fun setFirstLoad() { + savedStateHandle[FIRST_LOAD] = true + } + fun showLoginDialog(action: Boolean) { viewModelScope.launch { _showLoginDialog.value = action } } + fun logShareEvent(phraseId: Long) { + viewModelScope.launch { + shareRepository.logShareEvent(phraseId) + } + } + + private fun updateSharedCount() { + viewModelScope.launch { + if (loginState.value.isLoggedIn) { + shareRepository.updateSharedCount() + } + } + } + + fun setPrevSharedCount() { + viewModelScope.launch { + rewardState.value?.let { + savedStateHandle[SHARED_COUNT] = it.shareCount + } + } + } + + fun checkAndEmitSharedEvent() { + viewModelScope.launch { + updateSharedCount() + + rewardState.value?.let { + if (shouldEmitSharedEvent(it.shareCount)) { + emitSharedEvent() + updatePrevSharedCount(it.shareCount) + } + } + } + } + + private fun shouldEmitSharedEvent(currentSharedCount: Int): Boolean { + return (prevSharedCount ?: 0) < currentSharedCount + } + + private suspend fun emitSharedEvent() { + _shareEvent.emit(Unit) + } + + private fun updatePrevSharedCount(currentSharedCount: Int) { + savedStateHandle[SHARED_COUNT] = currentSharedCount + } + sealed interface UiEvent { data object FirstSignup : UiEvent data object AlreadySignedUp : UiEvent } + + companion object { + const val SHARED_COUNT = "shared_count" + const val FIRST_LOAD = "first_load" + } } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/PostAdapter.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/PostAdapter.kt index c3303eaa..7b28ec22 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/PostAdapter.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/home/PostAdapter.kt @@ -20,6 +20,7 @@ class PostAdapter( private val onPostClick: (Long) -> Unit, private val onClickBookmark: (Long, Boolean) -> Unit, private val onClickLike: (Long, Boolean) -> Unit, + private val onClickShare: (Post) -> Unit, ) : PagingDataAdapter(diffUtil) { class PostViewHolder( @@ -27,6 +28,7 @@ class PostAdapter( onPostClick: (Long) -> Unit, onClickBookmark: (Long, Boolean) -> Unit, onClickLike: (Long, Boolean) -> Unit, + onClickShare: (Post) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { private lateinit var post: Post @@ -47,6 +49,9 @@ class PostAdapter( } // View 아이콘 클릭 시 root영역에 대한 클릭리스너와 중복을 피하기 위한 임시 리스너 활성 binding.clView.setOnClickListener { } + binding.share.setOnClickListener { + onClickShare(post) + } } fun bind(post: Post) = with(binding) { @@ -104,6 +109,7 @@ class PostAdapter( onPostClick, onClickBookmark, onClickLike, + onClickShare ) } diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/HomeRewardBanner.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/HomeRewardBanner.kt new file mode 100644 index 00000000..fa83d384 --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/HomeRewardBanner.kt @@ -0,0 +1,140 @@ +package com.silvertown.android.dailyphrase.presentation.ui.reward + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.silvertown.android.dailyphrase.domain.model.RewardBanner +import com.silvertown.android.dailyphrase.presentation.R +import com.silvertown.android.dailyphrase.presentation.base.theme.pretendardFamily +import com.silvertown.android.dailyphrase.presentation.component.KaKaoLoginButton + +@Composable +fun HomeRewardBanner( + modifier: Modifier = Modifier, + rewardBanner: RewardBanner, + onClickKaKaoLogin: () -> Unit = {}, +) { + val annotatedText = buildAnnotatedString { + append(stringResource(id = R.string.login_prompt)) + append("\n") + withStyle(style = SpanStyle(color = colorResource(id = R.color.orange))) { + append(rewardBanner.shortName) + } + append(" " + stringResource(id = R.string.claim_reward)) + } + + Column( + modifier = modifier + .border( + width = 1.dp, + brush = Brush.linearGradient( + colors = listOf( + colorResource(id = R.color.orange), + colorResource(id = R.color.vivid_red) + ) + ), + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .padding(top = 24.dp, bottom = 20.dp) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier, + horizontalAlignment = Alignment.Start + ) { + Text( + text = stringResource(id = R.string.reward_date_suffix, rewardBanner.eventId), + style = TextStyle( + fontSize = 14.sp, + fontFamily = pretendardFamily, + fontWeight = FontWeight.Medium + ), + color = colorResource(id = R.color.gray) + ) + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = annotatedText, + style = TextStyle( + fontSize = 22.sp, + lineHeight = 32.sp, + fontFamily = pretendardFamily, + fontWeight = FontWeight.SemiBold + ), + color = colorResource(id = R.color.black) + ) + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(35.dp)) + .background( + color = colorResource(id = R.color.orange).copy( + alpha = 0.12f + ) + ) + .padding(vertical = 7.dp, horizontal = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource( + id = R.string.reward_participation_suffix, + rewardBanner.totalParticipantCount + ), + style = TextStyle( + fontSize = 14.sp, + fontFamily = pretendardFamily, + fontWeight = FontWeight.Medium + ), + color = colorResource(id = R.color.orange) + ) + } + } + + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = rewardBanner.imageUrl, + contentDescription = null + ) + } + + KaKaoLoginButton( + modifier = Modifier.fillMaxWidth(), + title = R.string.simple_login, + onClickKaKaoLogin = onClickKaKaoLogin, + ) + } +} diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/RewardPopup.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/RewardPopup.kt new file mode 100644 index 00000000..b185369e --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/ui/reward/RewardPopup.kt @@ -0,0 +1,295 @@ +package com.silvertown.android.dailyphrase.presentation.ui.reward + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.silvertown.android.dailyphrase.domain.model.HomeRewardState +import com.silvertown.android.dailyphrase.presentation.R +import com.silvertown.android.dailyphrase.presentation.base.theme.pretendardFamily +import com.silvertown.android.dailyphrase.presentation.util.EventTimer +import com.silvertown.android.dailyphrase.presentation.util.calculateAcquirableTicketResetTime +import com.skydoves.balloon.ArrowPositionRules +import com.skydoves.balloon.BalloonAnimation +import com.skydoves.balloon.BalloonSizeSpec +import com.skydoves.balloon.compose.Balloon +import com.skydoves.balloon.compose.BalloonWindow +import com.skydoves.balloon.compose.rememberBalloonBuilder +import kotlinx.coroutines.delay + +@Composable +internal fun RewardPopup( + state: HomeRewardState, + showSharedEventTooltip: Boolean, + showEndedEventTimerPopupTooltip: Boolean, + onTimeBelowThreshold: () -> Unit, + navigateToEventPage: () -> Unit, + modifier: Modifier = Modifier, +) { + var acquirableTicketResetTimer by remember { mutableStateOf(calculateAcquirableTicketResetTime()) } + val shouldRunAcquirableTicketResetTimer by remember { + derivedStateOf { shouldRunAcquirableTicketResetTimer(state.shareCount) } + } + + var balloonWindow: BalloonWindow? by remember { mutableStateOf(null) } + + val builder = rememberBalloonBuilder { + setArrowPositionRules(ArrowPositionRules.ALIGN_ANCHOR) + setArrowPosition(0.5f) + setArrowTopPadding(3) + setWidth(BalloonSizeSpec.WRAP) + setHeight(BalloonSizeSpec.WRAP) + setPaddingLeft(12) + setPaddingRight(8) + setPaddingVertical(8) + setCornerRadius(4f) + setBackgroundColorResource(R.color.tooltip_background) + setBalloonAnimation(BalloonAnimation.NONE) + setDismissWhenClicked(true) + setDismissWhenTouchOutside(false) + } + + LaunchedEffect(shouldRunAcquirableTicketResetTimer) { + if (shouldRunAcquirableTicketResetTimer) { + while (true) { + acquirableTicketResetTimer = calculateAcquirableTicketResetTime() + delay(1000L) + } + } + } + + val popupText = getPopupText( + state = state, + acquirableTicketResetTimer = acquirableTicketResetTimer + ) + + Box( + modifier = modifier.fillMaxWidth() + ) { + Balloon( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = (-30).dp), // 하단부터 팝업포지션 offset + builder = builder, + key = showSharedEventTooltip to showEndedEventTimerPopupTooltip, + onBalloonWindowInitialized = { balloonWindow = it }, + balloonContent = { + if (showSharedEventTooltip) { + TicketReceivedText() + } else { + CountdownTimer( + state = state, + onTimeBelowThreshold = onTimeBelowThreshold + ) + } + } + ) { balloonWindow -> + LaunchedEffect(showSharedEventTooltip, showEndedEventTimerPopupTooltip) { + if (showSharedEventTooltip || showEndedEventTimerPopupTooltip) { + balloonWindow.showAlignTop() + } else { + balloonWindow.dismiss() + } + } + + Box( + modifier = Modifier.wrapContentSize(), + contentAlignment = Alignment.TopEnd + ) { + PopupContainer( + popupText = popupText, + navigateToEventPage = navigateToEventPage + ) + + if (state.rewardBanner.myTicketCount > 0) { + OwnedTicketBadge( + myTicketCount = state.rewardBanner.myTicketCount.toString() + ) + } + } + } + } +} + +@Composable +private fun TicketReceivedText() { + Text( + text = stringResource(id = R.string.get_ticket_title), + style = TextStyle( + fontSize = 14.sp, + fontFamily = pretendardFamily, + fontWeight = FontWeight.Medium + ), + color = colorResource(id = R.color.white) + ) +} + +@Composable +private fun CountdownTimer( + state: HomeRewardState, + onTimeBelowThreshold: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + EventTimer( + eventEndedTime = state.eventEndDateTime, + onTimeBelowThreshold = onTimeBelowThreshold + ) + Icon( + painter = painterResource(id = R.drawable.ic_close_fill), + tint = Color.Unspecified, + contentDescription = null + ) + } +} + +@Composable +private fun PopupContainer( + popupText: AnnotatedString, + navigateToEventPage: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(40.dp) + .border( + width = 1.dp, + color = colorResource(id = R.color.orange), + shape = RoundedCornerShape(20.dp) + ) + .clip(RoundedCornerShape(20.dp)) + .background(color = colorResource(id = R.color.white)) + .clickable { + navigateToEventPage() + } + .padding( + start = 16.dp, + end = 12.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = popupText, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ), + color = colorResource(id = R.color.black) + ) + Image( + painter = painterResource(id = R.drawable.ic_ticket_30), + contentDescription = null + ) + } +} + +@Composable +private fun OwnedTicketBadge( + myTicketCount: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .offset(x = 3.dp, y = (-1).dp) // count 포지션 offset + .widthIn(min = 18.dp) + .height(18.dp) + .background( + color = colorResource(id = R.color.bright_red), + shape = CircleShape + ) + .padding(horizontal = 4.5.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = myTicketCount, + style = TextStyle( + fontSize = 14.sp, + fontFamily = pretendardFamily, + fontWeight = FontWeight.SemiBold + ), + color = colorResource(id = R.color.white), + ) + } +} + +@Composable +private fun getPopupText( + state: HomeRewardState, + acquirableTicketResetTimer: String, +): AnnotatedString { + val annotatedText = buildAnnotatedString { + val orangeStyle = SpanStyle(color = colorResource(id = R.color.orange)) + val pretendardFamilyStyle = SpanStyle(fontFamily = pretendardFamily) + val orangePretendardStyle = SpanStyle( + color = colorResource(id = R.color.orange), + fontFamily = pretendardFamily + ) + + if (shouldRunAcquirableTicketResetTimer(state.shareCount)) { + withStyle(style = orangeStyle) { + append(acquirableTicketResetTimer) + } + withStyle(style = orangePretendardStyle) { + append(" " + stringResource(id = R.string.acquirable_ticket_reset_timer_text_suffix) + " ") + } + withStyle(style = pretendardFamilyStyle) { + append(stringResource(id = R.string.acquirable_ticket_reset_info_text)) + } + } else { + withStyle(style = pretendardFamilyStyle) { + append(stringResource(id = R.string.reward_popup_text_prefix)) + } + withStyle(style = orangePretendardStyle) { + append(" " + state.rewardBanner.shortName + " ") + } + withStyle(style = pretendardFamilyStyle) { + append(stringResource(id = R.string.reward_popup_text_suffix)) + } + } + } + return annotatedText +} + +fun shouldRunAcquirableTicketResetTimer(shareCount: Int): Boolean { + return shareCount >= 10 +} diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/ActionType.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/ActionType.kt new file mode 100644 index 00000000..154df56a --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/ActionType.kt @@ -0,0 +1,10 @@ +package com.silvertown.android.dailyphrase.presentation.util + +import com.silvertown.android.dailyphrase.presentation.R + +enum class ActionType(val messageRes: Int) { + LIKE(R.string.login_and_like_message), + BOOKMARK(R.string.login_and_bookmark_message), + SHARE(R.string.login_and_share_message), + NONE(R.string.login_and_share_message); +} diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/Constants.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/Constants.kt index 8f9e80e4..cda8e774 100644 --- a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/Constants.kt +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/Constants.kt @@ -2,4 +2,6 @@ package com.silvertown.android.dailyphrase.presentation.util object Constants { const val PHRASE_ID = "phraseId" + const val TWO_MINUTES_IN_MILLIS = 2 * 60 * 1000 + const val TWENTY_FOUR_HOURS_IN_MILLIS = 24 * 60 * 60 * 1000 } \ No newline at end of file diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/EventTimer.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/EventTimer.kt new file mode 100644 index 00000000..cd3b3e33 --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/EventTimer.kt @@ -0,0 +1,103 @@ +package com.silvertown.android.dailyphrase.presentation.util + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import com.silvertown.android.dailyphrase.presentation.R +import com.silvertown.android.dailyphrase.presentation.base.theme.pretendardFamily +import com.silvertown.android.dailyphrase.presentation.util.Constants.TWO_MINUTES_IN_MILLIS +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit + +@Composable +internal fun EventTimer( + modifier: Modifier = Modifier, + eventEndedTime: LocalDateTime?, + onTimeBelowThreshold: () -> Unit, +) { + var remainTime by remember { + mutableLongStateOf( + Duration.between( + LocalDateTime.now(), + eventEndedTime + ).toMillis() + ) + } + + LaunchedEffect(Unit) { + while (remainTime > 0) { + delay(1000) + remainTime = Duration.between(LocalDateTime.now(), eventEndedTime).toMillis() + if (remainTime <= TWO_MINUTES_IN_MILLIS) { + onTimeBelowThreshold() + break + } + } + } + + val annotatedText = buildAnnotatedString { + withStyle( + style = SpanStyle( + fontFamily = pretendardFamily, + ) + ) { + append(stringResource(R.string.event_timer_text_prefix)) + } + append(" " + formatTime(remainTime) + " ") + withStyle( + style = SpanStyle( + fontFamily = pretendardFamily, + ) + ) { + append(stringResource(R.string.event_timer_text_suffix)) + } + } + + Box( + modifier = modifier + ) { + Text( + text = annotatedText, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + color = colorResource(id = R.color.white) + ) + } +} + +private fun formatTime(time: Long): String { + val hours = TimeUnit.MILLISECONDS.toHours(time) + val min = TimeUnit.MILLISECONDS.toMinutes(time) % 60 + val sec = TimeUnit.MILLISECONDS.toSeconds(time) % 60 + + return String.format("%02d:%02d:%02d", hours, min, sec) +} + +fun calculateAcquirableTicketResetTime(): String { + val now = LocalDateTime.now() + val midnight = now.toLocalDate().plusDays(1).atStartOfDay() + val remainingSeconds = ChronoUnit.SECONDS.between(now, midnight) + val hours = remainingSeconds / 3600 + val minutes = (remainingSeconds % 3600) / 60 + val seconds = remainingSeconds % 60 + return String.format("%02d:%02d:%02d", hours, minutes, seconds) +} \ No newline at end of file diff --git a/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/KakaoLink.kt b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/KakaoLink.kt new file mode 100644 index 00000000..89731618 --- /dev/null +++ b/presentation/src/main/kotlin/com/silvertown/android/dailyphrase/presentation/util/KakaoLink.kt @@ -0,0 +1,96 @@ +package com.silvertown.android.dailyphrase.presentation.util + +import android.content.ActivityNotFoundException +import android.content.Context +import com.kakao.sdk.common.util.KakaoCustomTabsClient +import com.kakao.sdk.share.ShareClient +import com.kakao.sdk.share.WebSharerClient +import com.kakao.sdk.template.model.Button +import com.kakao.sdk.template.model.Content +import com.kakao.sdk.template.model.FeedTemplate +import com.kakao.sdk.template.model.Link +import com.kakao.sdk.template.model.Social +import com.silvertown.android.dailyphrase.presentation.R +import com.silvertown.android.dailyphrase.presentation.ui.detail.Url +import timber.log.Timber + +internal fun sendKakaoLink( + context: Context, + phraseId: Long, + title: String, + description: String, + imageUrl: String, + accessToken: String, + likeCount: Int = 0, + commentCount: Int = 0, + sharedCount: Int = 0, + viewCount: Int = 0, + logShareEvent: () -> Unit, +) { + val webUrl = Url.webUrl + phraseId + + val phraseFeed = FeedTemplate( + content = Content( + title = title, + description = description, + imageUrl = imageUrl, + link = Link( + webUrl = webUrl, + mobileWebUrl = webUrl + ) + ), + social = Social( + likeCount = likeCount, + commentCount = commentCount, + sharedCount = sharedCount, + viewCount = viewCount + ), + buttons = listOf( + Button( + title = context.resources.getString(R.string.more_see), + Link( + webUrl = webUrl, + mobileWebUrl = webUrl + ) + ) + ) + ) + + val serverCallbackArgs = mapOf( + "accessToken" to accessToken + ) + + if (ShareClient.instance.isKakaoTalkSharingAvailable(context)) { + ShareClient.instance.shareDefault( + context, + phraseFeed, + serverCallbackArgs + ) { sharingResult, error -> + if (error != null) { + Timber.e(error) + Timber.e("카카오톡 공유 실패", error) + } else if (sharingResult != null) { + Timber.d("카카오톡 공유 성공 ${sharingResult.intent}") + context.startActivity(sharingResult.intent) + + Timber.w("Warning Msg: ${sharingResult.warningMsg}") + Timber.w("Argument Msg: ${sharingResult.argumentMsg}") + logShareEvent() + } + } + } else { + val sharerUrl = WebSharerClient.instance.makeDefaultUrl(phraseFeed, serverCallbackArgs) + + try { + KakaoCustomTabsClient.openWithDefault(context, sharerUrl) + } catch (e: UnsupportedOperationException) { + // CustomTabsServiceConnection 지원 브라우저가 없을 때 예외처리 + } + + try { + KakaoCustomTabsClient.open(context, sharerUrl) + } catch (e: ActivityNotFoundException) { + // 디바이스에 설치된 인터넷 브라우저가 없을 때 예외처리 + } + } +} \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_close_fill.xml b/presentation/src/main/res/drawable/ic_close_fill.xml new file mode 100644 index 00000000..6150ef31 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_close_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_ticket_30.xml b/presentation/src/main/res/drawable/ic_ticket_30.xml new file mode 100644 index 00000000..fe5a415c --- /dev/null +++ b/presentation/src/main/res/drawable/ic_ticket_30.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable/rounded_button_light_gray_blue.xml b/presentation/src/main/res/drawable/rounded_button_light_gray_blue.xml new file mode 100644 index 00000000..8c8a865f --- /dev/null +++ b/presentation/src/main/res/drawable/rounded_button_light_gray_blue.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/presentation/src/main/res/drawable/rounded_button.xml b/presentation/src/main/res/drawable/rounded_button_orange.xml similarity index 100% rename from presentation/src/main/res/drawable/rounded_button.xml rename to presentation/src/main/res/drawable/rounded_button_orange.xml diff --git a/presentation/src/main/res/layout/fragment_home.xml b/presentation/src/main/res/layout/fragment_home.xml index f9de9b70..76fc001d 100644 --- a/presentation/src/main/res/layout/fragment_home.xml +++ b/presentation/src/main/res/layout/fragment_home.xml @@ -110,7 +110,7 @@ android:id="@+id/retryButton" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/rounded_button" + android:background="@drawable/rounded_button_orange" android:backgroundTint="@color/orange" android:fontFamily="@font/pretendard_semi_bold" android:paddingHorizontal="25dp" diff --git a/presentation/src/main/res/layout/item_post.xml b/presentation/src/main/res/layout/item_post.xml index a06095de..96a6fa36 100644 --- a/presentation/src/main/res/layout/item_post.xml +++ b/presentation/src/main/res/layout/item_post.xml @@ -161,4 +161,28 @@ app:layout_constraintTop_toTopOf="parent" tools:text="9,999" /> + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/load_state_footer_view_item.xml b/presentation/src/main/res/layout/load_state_footer_view_item.xml index b76a7728..77e22305 100644 --- a/presentation/src/main/res/layout/load_state_footer_view_item.xml +++ b/presentation/src/main/res/layout/load_state_footer_view_item.xml @@ -29,7 +29,7 @@ android:id="@+id/retryButton" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="@drawable/rounded_button" + android:background="@drawable/rounded_button_orange" android:backgroundTint="@color/orange" android:fontFamily="@font/pretendard_semi_bold" android:paddingHorizontal="30dp" diff --git a/presentation/src/main/res/values/colors.xml b/presentation/src/main/res/values/colors.xml index 3663bf1b..a4067e18 100644 --- a/presentation/src/main/res/values/colors.xml +++ b/presentation/src/main/res/values/colors.xml @@ -15,4 +15,8 @@ #64696B #F4F1EA #F4F1EA + #E3190B + #FF3C3C + #FF7900 + #F2F3F7 \ No newline at end of file diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 8af5ceac..eb8ad441 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ 즐겨찾기 카카오톡으로 공유하기 카카오 로그인 + 3초만에 로그인 하기 자세히 보기 내 정보 서비스 탈퇴 @@ -42,4 +43,16 @@ 앱 다운로드 새로운 글귀를 가져올 수 없어요.\n네트워크 상태를 확인해주세요. 재시도 + 로그인하고 + 받아가세요! + %1$d월 이벤트 + %1$d명 참여중 + 응모하고 + 받기 + 이벤트 마감까지 + 남음 + 이 글귀 공유하기 + 뒤에 + 응모권을 받을 수 있어요 + 응모권을 받았어요!