@@ -7,10 +7,15 @@ import com.example.embeddedsystemscareerguide.AppConstants
77import com.google.firebase.auth.FirebaseAuth
88import com.google.firebase.firestore.FirebaseFirestore
99import com.google.firebase.firestore.SetOptions
10+ import com.example.embeddedsystemscareerguide.AppDate
1011import kotlinx.coroutines.CancellationException
1112import kotlinx.coroutines.TimeoutCancellationException
13+ import kotlinx.coroutines.sync.Mutex
14+ import kotlinx.coroutines.sync.withLock
1215import kotlinx.coroutines.tasks.await
1316import kotlinx.coroutines.withTimeout
17+ import kotlinx.coroutines.withTimeoutOrNull
18+ import java.time.LocalDate
1419
1520/* *
1621 * Service for syncing user progress data between local SharedPreferences and Firebase Firestore.
@@ -84,6 +89,89 @@ class UserProgressSyncService(private val context: Context) {
8489 " lastUpdated" to now
8590 )
8691
92+ /* *
93+ * The once-a-day streak rule, unchanged from LearningPathFragment: a first
94+ * visit keeps the stored streak (at least 1), a second visit the same day
95+ * changes nothing, the day after the last visit adds 1, and a longer gap
96+ * starts again at 1. The best streak only ever goes up. Null when there is
97+ * nothing to write.
98+ */
99+ internal fun dailyStreak (progress : UserProgress , today : LocalDate ): StreakUpdate ? {
100+ val todayIso = today.toString()
101+ val lastVisit = progress.lastVisitDate
102+ val consecutive = AppDate .isYesterday(lastVisit, today)
103+ val newStreak = when {
104+ lastVisit.isEmpty() -> progress.streak.coerceAtLeast(1 )
105+ lastVisit == todayIso -> progress.streak
106+ consecutive -> progress.streak + 1
107+ else -> 1
108+ }
109+ val newBest = maxOf(newStreak, progress.bestStreak)
110+ if (newStreak == progress.streak && newBest == progress.bestStreak && lastVisit == todayIso) return null
111+ return StreakUpdate (
112+ previous = progress.streak,
113+ streak = newStreak,
114+ bestStreak = newBest,
115+ date = todayIso,
116+ broken = lastVisit.isNotEmpty() && lastVisit != todayIso && ! consecutive
117+ )
118+ }
119+
120+ /* *
121+ * How long the daily update waits for Firestore to confirm the streak write.
122+ * Offline a write never confirms, but it is already in the local cache and is
123+ * sent on reconnect - and Home and Learning Path wait on this before reading.
124+ */
125+ internal const val STREAK_SAVE_WAIT_MS = 3_000L
126+
127+ // Which student's day has been counted, as "username|yyyy-MM-dd". Process-wide,
128+ // because each screen makes its own UserProgressSyncService.
129+ private val dailyVisitLock = Mutex ()
130+ private var dailyVisitCounted: String? = null
131+
132+ /* *
133+ * Counts today's visit for [key]'s student once: MainActivity asks whenever it
134+ * starts, and Home and Learning Path ask before they read progress, so
135+ * whichever comes first does the update and the others wait for it and then
136+ * find it done. A failed read writes nothing and is tried again next time.
137+ *
138+ * @return the update, to the one caller that made it; null otherwise
139+ */
140+ internal suspend fun recordDailyVisit (
141+ key : String ,
142+ today : LocalDate ,
143+ load : suspend () -> ProgressLoad ,
144+ save : suspend (StreakUpdate ) -> Boolean ,
145+ saveWaitMs : Long = STREAK_SAVE_WAIT_MS
146+ ): StreakUpdate ? = dailyVisitLock.withLock {
147+ val counted = " $key |$today "
148+ if (dailyVisitCounted == counted) return null
149+ val progress = when (val read = load()) {
150+ is ProgressLoad .Loaded -> read.progress
151+ ProgressLoad .Absent -> UserProgress ()
152+ is ProgressLoad .Failed -> return null
153+ }
154+ val update = dailyStreak(progress, today)
155+ // Unconfirmed after saveWaitMs counts as made: see STREAK_SAVE_WAIT_MS.
156+ if (update != null && withTimeoutOrNull(saveWaitMs) { save(update) } == false ) return null
157+ dailyVisitCounted = counted
158+ update
159+ }
160+
161+ /* * The toast for a streak update, as Learning Path used to show it, or null for none. */
162+ fun streakMessage (update : StreakUpdate ? ): String? = when {
163+ update == null -> null
164+ update.broken && update.previous > 1 ->
165+ " 🔥 Previous streak: ${update.previous} days\n Starting fresh! Let's build a new streak!"
166+ update.streak > update.previous -> when (update.streak) {
167+ 7 -> " 🔥 1 WEEK STREAK! Amazing dedication!"
168+ 14 -> " 🔥 2 WEEK STREAK! You're unstoppable!"
169+ 30 -> " 🔥 30 DAY STREAK! Legendary commitment!"
170+ else -> if (update.streak % 10 == 0 ) " 🔥 ${update.streak} DAY STREAK! Keep it up!" else null
171+ }
172+ else -> null
173+ }
174+
87175 /* *
88176 * The fields a retake's progress reset writes - and the only ones. XP and
89177 * the streak are left alone, so the reset needs no read first and can go
@@ -307,6 +395,29 @@ class UserProgressSyncService(private val context: Context) {
307395 }
308396 }
309397
398+ /* * Today's streak, as [dailyStreak] works it out. */
399+ data class StreakUpdate (
400+ val previous : Int ,
401+ val streak : Int ,
402+ val bestStreak : Int ,
403+ val date : String ,
404+ val broken : Boolean
405+ )
406+
407+ /* *
408+ * Counts today's visit: the daily streak update, once per calendar day per student.
409+ * See the companion's recordDailyVisit.
410+ */
411+ suspend fun recordDailyVisit (): StreakUpdate ? {
412+ val username = getCurrentUsername() ? : return null
413+ return recordDailyVisit(
414+ key = username,
415+ today = LocalDate .now(),
416+ load = { loadProgressResult() },
417+ save = { saveStreak(it.streak, it.bestStreak, it.date) }
418+ )
419+ }
420+
310421 /* *
311422 * Outcome of a cloud progress read.
312423 *
0 commit comments