diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66239e3c4..26472b369 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,10 +2,6 @@ -## 리뷰/머지 희망 기한 (선택) - - - ## 작업내용 diff --git a/core/database/src/main/java/com/twix/database/TwixDatabase.kt b/core/database/src/main/java/com/twix/database/TwixDatabase.kt index 4e60703e9..65b4fcf43 100644 --- a/core/database/src/main/java/com/twix/database/TwixDatabase.kt +++ b/core/database/src/main/java/com/twix/database/TwixDatabase.kt @@ -2,13 +2,34 @@ package com.twix.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.twix.database.poke.PokeHistoryDao import com.twix.database.poke.PokeHistoryEntity @Database( entities = [PokeHistoryEntity::class], - version = 1, + version = 2, ) abstract class TwixDatabase : RoomDatabase() { abstract fun pokeHistoryDao(): PokeHistoryDao + + companion object { + val MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS poke_history") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS poke_history ( + goalId INTEGER NOT NULL, + targetDate TEXT NOT NULL, + pokedAt INTEGER NOT NULL, + PRIMARY KEY(goalId, targetDate) + ) + """.trimIndent(), + ) + } + } + } } diff --git a/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt b/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt index 4143e350a..5b793801a 100644 --- a/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt +++ b/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt @@ -13,7 +13,8 @@ val databaseModule = androidContext(), TwixDatabase::class.java, "twix-database", - ).build() + ).addMigrations(TwixDatabase.MIGRATION_1_2) + .build() } single { get().pokeHistoryDao() } } diff --git a/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt b/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt index cc335012b..378a0bad2 100644 --- a/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt +++ b/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt @@ -9,6 +9,9 @@ interface PokeHistoryDao { @Upsert suspend fun upsert(entity: PokeHistoryEntity) - @Query("SELECT * FROM poke_history WHERE goalId = :goalId") - suspend fun findByGoalId(goalId: Long): PokeHistoryEntity? + @Query("SELECT * FROM poke_history WHERE goalId = :goalId AND targetDate = :targetDate") + suspend fun findPokeHistoryEntity( + goalId: Long, + targetDate: String, + ): PokeHistoryEntity? } diff --git a/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt b/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt index a8951aa66..5f135e0d7 100644 --- a/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt +++ b/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt @@ -1,10 +1,13 @@ package com.twix.database.poke import androidx.room.Entity -import androidx.room.PrimaryKey -@Entity(tableName = "poke_history") +@Entity( + tableName = "poke_history", + primaryKeys = ["goalId", "targetDate"], +) data class PokeHistoryEntity( - @PrimaryKey val goalId: Long, + val goalId: Long, + val targetDate: String, val pokedAt: Long, ) diff --git a/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt index 1b6114330..f901f6c90 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt @@ -20,10 +20,20 @@ class DefaultPokeRepository( override suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) { - pokeHistoryDao.upsert(PokeHistoryEntity(goalId = goalId, pokedAt = pokedAt)) + pokeHistoryDao.upsert( + PokeHistoryEntity( + goalId = goalId, + targetDate = targetDate, + pokedAt = pokedAt, + ), + ) } - override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistoryDao.findByGoalId(goalId)?.pokedAt + override suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? = pokeHistoryDao.findPokeHistoryEntity(goalId, targetDate)?.pokedAt } diff --git a/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt b/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt index e96deea25..5bbd9b312 100644 --- a/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt @@ -8,8 +8,12 @@ interface PokeRepository { suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) - suspend fun findPokeHistory(goalId: Long): Long? + suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? } diff --git a/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt b/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt index 1f155cb98..bd11de8bd 100644 --- a/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt +++ b/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt @@ -7,21 +7,27 @@ import com.twix.result.AppResult class PokeGoalUseCase( private val pokeRepository: PokeRepository, ) { - suspend fun invoke(goalId: Long): PokeGoalResult { - val remainingMs = remainingCooldown(goalId) + suspend fun invoke( + goalId: Long, + targetDate: String, + ): PokeGoalResult { + val remainingMs = remainingCooldown(goalId, targetDate) if (remainingMs > 0) return PokeGoalResult.OnCooldown(remainingMs) return when (val result = pokeRepository.pokeGoal(goalId)) { is AppResult.Success -> { - pokeRepository.savePokeHistory(goalId, System.currentTimeMillis()) + pokeRepository.savePokeHistory(goalId, targetDate, System.currentTimeMillis()) PokeGoalResult.Success(result.data.message) } is AppResult.Error -> PokeGoalResult.Error } } - suspend fun remainingCooldown(goalId: Long): Long { - val pokedAt = pokeRepository.findPokeHistory(goalId) ?: return 0L + suspend fun remainingCooldown( + goalId: Long, + targetDate: String, + ): Long { + val pokedAt = pokeRepository.findPokeHistory(goalId, targetDate) ?: return 0L val currentTime = System.currentTimeMillis() val elapsedMs = currentTime - pokedAt val remainingMs = COOLDOWN_MS - elapsedMs diff --git a/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt b/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt index 3f0842b4c..84bf47fc1 100644 --- a/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt +++ b/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt @@ -5,8 +5,8 @@ import com.twix.domain.repository.PokeRepository import com.twix.result.AppResult class FakePokeRepository : PokeRepository { - val pokeHistory: MutableMap = mutableMapOf() - val savedPokeHistory: MutableMap = mutableMapOf() + val pokeHistory: MutableMap = mutableMapOf() + val savedPokeHistory: MutableMap = mutableMapOf() var pokeGoalResult: AppResult = AppResult.Success(PokeResult(message = "")) var pokeGoalCallCount: Int = 0 @@ -17,10 +17,19 @@ class FakePokeRepository : PokeRepository { override suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) { - savedPokeHistory[goalId] = pokedAt + savedPokeHistory[PokeHistoryKey(goalId, targetDate)] = pokedAt } - override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistory[goalId] + override suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? = pokeHistory[PokeHistoryKey(goalId, targetDate)] + + data class PokeHistoryKey( + val goalId: Long, + val targetDate: String, + ) } diff --git a/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt b/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt index 7e95ef06b..67aa3f3fd 100644 --- a/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt +++ b/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt @@ -25,17 +25,19 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 1L + val targetDate = "2026-06-07" val serverMessage = "서버 응답 메시지" fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) - fakePokeRepository.pokeHistory[goalId] = null + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) assertThat((result as PokeGoalResult.Success).message).isEqualTo(serverMessage) - assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNotNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)]) + .isNotNull() } @Test @@ -43,15 +45,17 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 2L + val targetDate = "2026-06-07" fakePokeRepository.pokeGoalResult = AppResult.Error(AppError.Network()) - fakePokeRepository.pokeHistory[goalId] = null + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isEqualTo(PokeGoalResult.Error) - assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)]) + .isNull() } @Test @@ -59,11 +63,12 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 3L + val targetDate = "2026-06-07" val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) - fakePokeRepository.pokeHistory[goalId] = recentPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.OnCooldown::class.java) @@ -76,13 +81,14 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 4L + val targetDate = "2026-06-07" val justExpiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 1 val serverMessage = "서버 응답 메시지" - fakePokeRepository.pokeHistory[goalId] = justExpiredPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = justExpiredPokedAt fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) @@ -94,10 +100,11 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 10L - fakePokeRepository.pokeHistory[goalId] = null + val targetDate = "2026-06-07" + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isEqualTo(0L) @@ -108,11 +115,12 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 11L + val targetDate = "2026-06-07" val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) - fakePokeRepository.pokeHistory[goalId] = recentPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isGreaterThan(0L) @@ -123,13 +131,57 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 12L + val targetDate = "2026-06-07" val expiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 100 - fakePokeRepository.pokeHistory[goalId] = expiredPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = expiredPokedAt // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isEqualTo(0L) } + + @Test + fun `같은 목표라도 날짜가 다르면 remainingCooldown은 독립적으로 계산된다`() = + runTest { + // given + val goalId = 20L + val pokedDate = "2026-06-07" + val otherDate = "2026-06-08" + val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt + + // when + val pokedDateRemaining = useCase.remainingCooldown(goalId, pokedDate) + val otherDateRemaining = useCase.remainingCooldown(goalId, otherDate) + + // then + assertThat(pokedDateRemaining).isGreaterThan(0L) + assertThat(otherDateRemaining).isEqualTo(0L) + } + + @Test + fun `같은 목표의 다른 날짜 쿨타임은 찌르기 요청을 막지 않는다`() = + runTest { + // given + val goalId = 21L + val pokedDate = "2026-06-07" + val otherDate = "2026-06-08" + val serverMessage = "서버 응답 메시지" + val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt + fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) + + // when + val result = useCase.invoke(goalId, otherDate) + + // then + assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) + assertThat(fakePokeRepository.pokeGoalCallCount).isEqualTo(1) + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, otherDate)]) + .isNotNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)]) + .isNull() + } } diff --git a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt index 3173db98b..fa70a6488 100644 --- a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt +++ b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt @@ -129,7 +129,7 @@ class HomeViewModel( private fun pokeGoal(goalId: Long) { viewModelScope.launch { - when (val result = pokeGoalUseCase.invoke(goalId)) { + when (val result = pokeGoalUseCase.invoke(goalId, currentState.selectedDate.toString())) { is PokeGoalResult.Success -> tryEmitSideEffect(HomeSideEffect.ShowPokeToast) is PokeGoalResult.OnCooldown -> diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt index bd5b0c9db..d87ad1f13 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt @@ -220,7 +220,6 @@ fun PhotologDetailScreen( PhotologCardContent( uiState = uiState, - isPokeDisabled = uiState.isPokeDisabled, onSwipe = onSwipe, onClickUpload = onClickUpload, onPoke = onPoke, diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt index 4df175816..20a197732 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt @@ -20,7 +20,9 @@ import com.twix.ui.base.BaseViewModel import com.twix.util.bus.GoalRefreshBus import com.twix.util.bus.PhotologRefreshBus import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce @@ -55,6 +57,7 @@ class PhotologDetailViewModel( savedStateHandle[NavRoutes.PhotologDetailRoute.ARG_IS_COMPLETED] ?: false private var lastReaction: GoalReactionType? = null + private var pokeCooldownJob: Job? = null private val reactionFlow = MutableSharedFlow( @@ -164,22 +167,18 @@ class PhotologDetailViewModel( private fun checkPokeCooldown() { viewModelScope.launch { - val remaining = pokeGoalUseCase.remainingCooldown(argGoalId) - if (remaining > 0) reduce { copy(pokeCooldownRemaining = remaining) } + val remaining = pokeGoalUseCase.remainingCooldown(argGoalId, argTargetDate.toString()) + startPokeCooldown(remaining) } } private fun pokeToPartner() { viewModelScope.launch { - startPokeLoading() - handlePokeResult(pokeGoalUseCase.invoke(argGoalId)) + reduce { copy(isPoking = true) } + handlePokeResult(pokeGoalUseCase.invoke(argGoalId, argTargetDate.toString())) } } - private fun startPokeLoading() { - reduce { copy(isPoking = true) } - } - private suspend fun handlePokeResult(result: PokeGoalResult) { when (result) { is PokeGoalResult.Success -> handlePokeSuccess() @@ -189,20 +188,42 @@ class PhotologDetailViewModel( } private fun handlePokeSuccess() { - reduce { - copy( - isPoking = false, - pokeCooldownRemaining = PokeGoalUseCase.COOLDOWN_MS, - ) - } + startPokeCooldown(PokeGoalUseCase.COOLDOWN_MS) + reduce { copy(isPoking = false) } tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeToast) } private fun handlePokeCooldown(remainingMs: Long) { + startPokeCooldown(remainingMs) reduce { copy(isPoking = false) } tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeCooldownToast(remainingMs)) } + private fun startPokeCooldown(remainingMs: Long) { + pokeCooldownJob?.cancel() + + if (remainingMs <= 0L) { + clearPokeCooldown() + return + } + + reduce { copy(pokeCooldownRemaining = remainingMs) } + schedulePokeCooldownClear(remainingMs) + } + + private fun clearPokeCooldown() { + reduce { copy(pokeCooldownRemaining = 0L) } + pokeCooldownJob = null + } + + private fun schedulePokeCooldownClear(remainingMs: Long) { + pokeCooldownJob = + viewModelScope.launch { + delay(remainingMs) + clearPokeCooldown() + } + } + private suspend fun handlePokeError() { reduce { copy(isPoking = false) } showToast(R.string.toast_poke_goal_failed) diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt index b34ffe526..c0d3693d4 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt @@ -29,7 +29,6 @@ import kotlin.math.roundToInt @Composable internal fun PhotologCardContent( uiState: PhotologDetailUiState, - isPokeDisabled: Boolean, onSwipe: () -> Unit, onClickUpload: () -> Unit, onPoke: () -> Unit, @@ -63,7 +62,7 @@ internal fun PhotologCardContent( if (uiState.isDisplayedMyPhotolog) { onClickUpload } else { - { if (!isPokeDisabled) onPoke() } + { if (!uiState.isPoking) onPoke() } }, showActionButton = uiState.showActionButton, ) @@ -171,7 +170,6 @@ private fun PhotologCardContentPreview( TwixTheme { PhotologCardContent( uiState = uiState.copy(isLoading = true), - isPokeDisabled = false, onSwipe = {}, onClickUpload = {}, onPoke = {},