Skip to content

Commit f3cf8e5

Browse files
authored
Merge pull request #257 from YAPP-Github/bugfix/#256-ghost-creating-state
[BUGFIX] 운세 생성 상태에 만료 기한 추가하여 유령 Creating 문제 해결
2 parents 94aeb45 + 3108e6f commit f3cf8e5

File tree

8 files changed

+550
-202
lines changed

8 files changed

+550
-202
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package com.yapp.datastore
2+
3+
import androidx.datastore.core.DataStore
4+
import androidx.datastore.preferences.core.Preferences
5+
import androidx.datastore.preferences.core.booleanPreferencesKey
6+
import androidx.datastore.preferences.core.edit
7+
import androidx.datastore.preferences.core.emptyPreferences
8+
import androidx.datastore.preferences.core.intPreferencesKey
9+
import androidx.datastore.preferences.core.longPreferencesKey
10+
import androidx.datastore.preferences.core.stringPreferencesKey
11+
import kotlinx.coroutines.ExperimentalCoroutinesApi
12+
import kotlinx.coroutines.flow.Flow
13+
import kotlinx.coroutines.flow.catch
14+
import kotlinx.coroutines.flow.distinctUntilChanged
15+
import kotlinx.coroutines.flow.map
16+
import kotlinx.coroutines.flow.transformLatest
17+
import java.time.Clock
18+
import java.time.Instant
19+
import java.time.LocalDate
20+
import javax.inject.Inject
21+
import javax.inject.Singleton
22+
23+
@Singleton
24+
class FortunePreferences @Inject constructor(
25+
private val dataStore: DataStore<Preferences>,
26+
private val clock: Clock,
27+
) {
28+
private object Keys {
29+
val ID = longPreferencesKey("fortune_id")
30+
val DATE = longPreferencesKey("fortune_date_epoch")
31+
val IMAGE_ID = intPreferencesKey("fortune_image_id")
32+
val SCORE = intPreferencesKey("fortune_score")
33+
val SEEN = booleanPreferencesKey("fortune_seen")
34+
val TOOLTIP_SHOWN = booleanPreferencesKey("fortune_tooltip_shown")
35+
36+
val CREATING = booleanPreferencesKey("fortune_creating")
37+
val FAILED = booleanPreferencesKey("fortune_failed")
38+
39+
val ATTEMPT_ID = stringPreferencesKey("fortune_attempt_id")
40+
val STARTED_AT = longPreferencesKey("fortune_started_at")
41+
val EXPIRES_AT = longPreferencesKey("fortune_expires_at")
42+
43+
val FIRST_ALARM_DISMISSED_TODAY = booleanPreferencesKey("first_alarm_dismissed_today")
44+
val FIRST_ALARM_DISMISSED_DATE_EPOCH = longPreferencesKey("first_alarm_dismissed_date_epoch")
45+
}
46+
47+
private fun todayEpoch(): Long = LocalDate.now(clock).toEpochDay()
48+
private fun nowMillis(): Long = Instant.now(clock).toEpochMilli()
49+
50+
val fortuneIdFlow: Flow<Long?> = dataStore.data
51+
.catch { emit(emptyPreferences()) }
52+
.map { it[Keys.ID] }
53+
.distinctUntilChanged()
54+
55+
val fortuneDateEpochFlow: Flow<Long?> = dataStore.data
56+
.catch { emit(emptyPreferences()) }
57+
.map { it[Keys.DATE] }
58+
.distinctUntilChanged()
59+
60+
val fortuneImageIdFlow: Flow<Int?> = dataStore.data
61+
.catch { emit(emptyPreferences()) }
62+
.map { it[Keys.IMAGE_ID] }
63+
.distinctUntilChanged()
64+
65+
val fortuneScoreFlow: Flow<Int?> = dataStore.data
66+
.catch { emit(emptyPreferences()) }
67+
.map { it[Keys.SCORE] }
68+
.distinctUntilChanged()
69+
70+
val hasUnseenFortuneFlow: Flow<Boolean> = dataStore.data
71+
.catch { emit(emptyPreferences()) }
72+
.map { pref ->
73+
val isToday = pref[Keys.DATE] == todayEpoch()
74+
isToday && (pref[Keys.ID] != null) && (pref[Keys.SEEN] != true)
75+
}
76+
.distinctUntilChanged()
77+
78+
val shouldShowFortuneToolTipFlow: Flow<Boolean> = dataStore.data
79+
.catch { emit(emptyPreferences()) }
80+
.map { pref ->
81+
val hasTodayFortune = (pref[Keys.DATE] == todayEpoch()) && (pref[Keys.ID] != null)
82+
val tooltipShown = pref[Keys.TOOLTIP_SHOWN] ?: false
83+
hasTodayFortune && !tooltipShown
84+
}
85+
.distinctUntilChanged()
86+
87+
@OptIn(ExperimentalCoroutinesApi::class)
88+
val isFortuneCreatingFlow: Flow<Boolean> = dataStore.data
89+
.catch { emit(emptyPreferences()) }
90+
.map { pref ->
91+
Triple(
92+
pref[Keys.CREATING] ?: false,
93+
pref[Keys.EXPIRES_AT] ?: 0L,
94+
pref[Keys.ATTEMPT_ID],
95+
)
96+
}
97+
.transformLatest { (creating, expiresAt, attemptId) ->
98+
if (creating) {
99+
val legacy = (expiresAt <= 0L) || attemptId.isNullOrEmpty()
100+
val expired = (!legacy && nowMillis() > expiresAt)
101+
102+
if (legacy || expired) {
103+
// 레거시(만료정보 없음) 또는 만료 → 실패로 교정
104+
dataStore.edit { pref ->
105+
pref[Keys.CREATING] = false
106+
pref[Keys.FAILED] = true
107+
}
108+
emit(false)
109+
return@transformLatest
110+
}
111+
}
112+
emit(creating)
113+
}
114+
.distinctUntilChanged()
115+
116+
val isFortuneFailedFlow: Flow<Boolean> = dataStore.data
117+
.catch { emit(emptyPreferences()) }
118+
.map { it[Keys.FAILED] ?: false }
119+
.distinctUntilChanged()
120+
121+
val isFirstAlarmDismissedTodayFlow: Flow<Boolean> = dataStore.data
122+
.catch { emit(emptyPreferences()) }
123+
.map { pref ->
124+
val flag = pref[Keys.FIRST_ALARM_DISMISSED_TODAY] ?: false
125+
val isToday = pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] == todayEpoch()
126+
flag && isToday
127+
}
128+
.distinctUntilChanged()
129+
130+
suspend fun markFortuneCreating(
131+
attemptId: String,
132+
lease: Long,
133+
) {
134+
val now = nowMillis()
135+
dataStore.edit { pref ->
136+
pref[Keys.CREATING] = true
137+
pref[Keys.FAILED] = false
138+
pref[Keys.ATTEMPT_ID] = attemptId
139+
pref[Keys.STARTED_AT] = now
140+
pref[Keys.EXPIRES_AT] = now + lease
141+
}
142+
}
143+
144+
suspend fun markFortuneCreatedIfAttemptMatches(
145+
attemptId: String,
146+
fortuneId: Long,
147+
) {
148+
dataStore.edit { pref ->
149+
val currentAttempt = pref[Keys.ATTEMPT_ID]
150+
val isCreating = pref[Keys.CREATING] ?: false
151+
val expiresAt = pref[Keys.EXPIRES_AT] ?: 0L
152+
153+
if (isCreating) {
154+
val legacy = (expiresAt <= 0L) || currentAttempt.isNullOrEmpty()
155+
val expired = (!legacy && nowMillis() > expiresAt)
156+
157+
if (legacy || expired) {
158+
// 만료된 상태라면 성공 처리 거부
159+
return@edit
160+
}
161+
}
162+
163+
if (isCreating && currentAttempt == attemptId) {
164+
val today = todayEpoch()
165+
val prevDate = pref[Keys.DATE]
166+
val isNewForToday = (pref[Keys.ID] != fortuneId) || (prevDate != today)
167+
168+
pref[Keys.ID] = fortuneId
169+
pref[Keys.DATE] = today
170+
pref[Keys.CREATING] = false
171+
pref[Keys.FAILED] = false
172+
pref.remove(Keys.ATTEMPT_ID)
173+
pref.remove(Keys.STARTED_AT)
174+
pref.remove(Keys.EXPIRES_AT)
175+
176+
if (isNewForToday) {
177+
pref[Keys.SEEN] = false
178+
pref[Keys.TOOLTIP_SHOWN] = false
179+
}
180+
}
181+
}
182+
}
183+
184+
suspend fun markFortuneFailedIfAttemptMatches(attemptId: String) {
185+
dataStore.edit { pref ->
186+
if (pref[Keys.ATTEMPT_ID] == attemptId) {
187+
pref[Keys.CREATING] = false
188+
pref[Keys.FAILED] = true
189+
pref.remove(Keys.ATTEMPT_ID)
190+
pref.remove(Keys.STARTED_AT)
191+
pref.remove(Keys.EXPIRES_AT)
192+
}
193+
}
194+
}
195+
196+
suspend fun markFortuneSeen() {
197+
dataStore.edit { it[Keys.SEEN] = true }
198+
}
199+
200+
suspend fun markFortuneTooltipShown() {
201+
dataStore.edit { it[Keys.TOOLTIP_SHOWN] = true }
202+
}
203+
204+
suspend fun saveFortuneImageId(imageResId: Int) {
205+
dataStore.edit { it[Keys.IMAGE_ID] = imageResId }
206+
}
207+
208+
suspend fun saveFortuneScore(score: Int) {
209+
dataStore.edit { it[Keys.SCORE] = score }
210+
}
211+
212+
suspend fun markFirstAlarmDismissedToday() {
213+
dataStore.edit { pref ->
214+
pref[Keys.FIRST_ALARM_DISMISSED_TODAY] = true
215+
pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] = todayEpoch()
216+
}
217+
}
218+
219+
suspend fun clearFortuneData() {
220+
dataStore.edit { pref ->
221+
pref.remove(Keys.ID)
222+
pref.remove(Keys.DATE)
223+
pref.remove(Keys.IMAGE_ID)
224+
pref.remove(Keys.SCORE)
225+
pref.remove(Keys.SEEN)
226+
pref.remove(Keys.TOOLTIP_SHOWN)
227+
pref.remove(Keys.CREATING)
228+
pref.remove(Keys.FAILED)
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)