Skip to content

Commit ab16ff2

Browse files
khashyap0803claude
andcommitted
Merge pull request #6 from khashyap0803/claude/audit-2026-10-r5
Keep Home figures on a failed read, count the streak once a day, clean-ups Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 10900e3 + b3cfe79 commit ab16ff2

11 files changed

Lines changed: 516 additions & 137 deletions

File tree

‎app/src/main/java/com/example/embeddedsystemscareerguide/AppDate.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ object AppDate {
3232

3333
/**
3434
* True if [dateString] (an ISO-8601 `yyyy-MM-dd` date, as written by [todayIso])
35-
* is exactly one calendar day before today. Returns false for a blank, malformed,
35+
* is exactly one calendar day before [today]. Returns false for a blank, malformed,
3636
* or unparseable value rather than throwing.
3737
*/
38-
fun isYesterday(dateString: String): Boolean {
38+
fun isYesterday(dateString: String, today: LocalDate = LocalDate.now()): Boolean {
3939
if (dateString.isBlank()) return false
4040
return try {
41-
LocalDate.parse(dateString) == LocalDate.now().minusDays(1)
41+
LocalDate.parse(dateString) == today.minusDays(1)
4242
} catch (e: Exception) {
4343
false
4444
}

‎app/src/main/java/com/example/embeddedsystemscareerguide/MainActivity.kt‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@ import android.view.View
1313
import androidx.appcompat.app.AppCompatActivity
1414
import androidx.appcompat.widget.Toolbar
1515
import androidx.core.view.doOnLayout
16+
import androidx.lifecycle.lifecycleScope
1617
import androidx.navigation.NavController
1718
import androidx.navigation.findNavController
1819
import androidx.navigation.fragment.NavHostFragment
1920
import androidx.navigation.ui.AppBarConfiguration
2021
import androidx.navigation.ui.setupActionBarWithNavController
2122
import com.example.embeddedsystemscareerguide.databinding.ActivityMainBinding
2223
import com.example.embeddedsystemscareerguide.services.AuthManager
24+
import com.example.embeddedsystemscareerguide.services.UserProgressSyncService
2325
import com.example.embeddedsystemscareerguide.ui.assessment.AssessmentActivity
2426
import com.example.embeddedsystemscareerguide.ui.auth.LoginActivity
2527
import com.example.embeddedsystemscareerguide.ui.practice.PracticeContentActivity
2628
import com.example.embeddedsystemscareerguide.ui.widget.RadialFabMenuView
2729
import com.google.android.material.appbar.AppBarLayout
2830
import com.google.android.material.dialog.MaterialAlertDialogBuilder
2931
import com.google.firebase.auth.FirebaseAuth
32+
import kotlinx.coroutines.launch
3033

3134
class MainActivity : AppCompatActivity() {
3235

@@ -85,6 +88,21 @@ class MainActivity : AppCompatActivity() {
8588
}
8689
}
8790

91+
override fun onStart() {
92+
super.onStart()
93+
if (FirebaseAuth.getInstance().currentUser == null) return
94+
95+
// The daily streak, counted once per calendar day whichever screen is open -
96+
// including after the app sat in the background past midnight. It used to be
97+
// counted only when Learning Path was opened. Home and Learning Path ask too
98+
// before reading progress; whichever asks first does it.
99+
lifecycleScope.launch {
100+
UserProgressSyncService.streakMessage(UserProgressSyncService(this@MainActivity).recordDailyVisit())?.let {
101+
android.widget.Toast.makeText(this@MainActivity, it, android.widget.Toast.LENGTH_LONG).show()
102+
}
103+
}
104+
}
105+
88106
override fun onCreateOptionsMenu(menu: Menu): Boolean {
89107
// Previously built by hand with only a Logout entry, which meant
90108
// action_settings (and therefore nav_settings / SettingsFragment) was

‎app/src/main/java/com/example/embeddedsystemscareerguide/services/FirestoreManager.kt‎

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,6 @@ class FirestoreManager private constructor(private val context: Context) {
111111
internal fun currentStageOf(stages: List<PersonalizedStage>): PersonalizedStage? =
112112
stages.firstOrNull { !it.isCompleted } ?: stages.lastOrNull()
113113

114-
/**
115-
* Keeps a failed read apart from an empty path. Callers used to turn a failure
116-
* into an empty list, so a network or permission error told the student to
117-
* take the assessment.
118-
*/
119114
/**
120115
* Every write a retake makes, for one WriteBatch: reset the stage progress,
121116
* delete the old stages the new path doesn't reuse, save the new stages. A set
@@ -133,6 +128,11 @@ class FirestoreManager private constructor(private val context: Context) {
133128
newStages.map { RetakeWrite.SaveStage(it) }
134129
}
135130

131+
/**
132+
* Keeps a failed read apart from an empty path. Callers used to turn a failure
133+
* into an empty list, so a network or permission error told the student to
134+
* take the assessment.
135+
*/
136136
internal fun pathReadOf(read: Result<List<PersonalizedStage>>): PathRead {
137137
val stages = read.getOrElse {
138138
// getPersonalizedStages hands a cancellation back as a failed Result.
@@ -552,30 +552,6 @@ class FirestoreManager private constructor(private val context: Context) {
552552
}
553553
}
554554

555-
/**
556-
* Delete all personalized stages (for regeneration)
557-
*/
558-
suspend fun deleteAllPersonalizedStages(): Boolean = withContext(Dispatchers.IO) {
559-
try {
560-
val username = getCurrentUsername()
561-
val stagesRef = getUserDocRef(username).collection(COLLECTION_PERSONALIZED_STAGES)
562-
563-
val docs = stagesRef.get().await()
564-
val batch = firestore.batch()
565-
566-
docs.documents.forEach { doc ->
567-
batch.delete(doc.reference)
568-
}
569-
570-
batch.commit().await()
571-
Log.d(TAG, "Deleted ${docs.size()} personalized stages")
572-
true
573-
} catch (e: Exception) {
574-
Log.e(TAG, "Error deleting stages", e)
575-
false
576-
}
577-
}
578-
579555
// ==================== STAGE CONTENT ====================
580556

581557
/**

‎app/src/main/java/com/example/embeddedsystemscareerguide/services/GeminiChallengeService.kt‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ DO NOT include comments - students should order pure code blocks.
263263
)
264264
}.shuffled() // Shuffle for the challenge
265265

266-
Log.i(TAG, "Generated ${result.size} code blocks for problem: $problemStatement")
266+
Log.i(TAG, "Generated ${result.size} code blocks for a ${problemStatement.length}-char problem")
267267
Result.success(result)
268268
} catch (e: Exception) {
269269
Log.e(TAG, "Failed to generate code blocks", e)
@@ -1147,7 +1147,8 @@ Return ONLY this JSON (no markdown, no explanation):
11471147
}
11481148

11491149
if (finalStart == -1 || finalEnd == -1 || finalStart >= finalEnd) {
1150-
Log.e(TAG, "No valid JSON array found. Cleaned response: ${cleaned.take(500)}")
1150+
// The reply's length, not its text: Log.e survives R8 into release builds.
1151+
Log.e(TAG, "No valid JSON array found in the response (${cleaned.length} chars)")
11511152
throw Exception("Invalid JSON array in response. No array brackets found.")
11521153
}
11531154

@@ -1177,7 +1178,7 @@ Return ONLY this JSON (no markdown, no explanation):
11771178
val start = cleaned.indexOf('{')
11781179
val end = cleaned.lastIndexOf('}')
11791180
if (start == -1 || end == -1 || start >= end) {
1180-
Log.e(TAG, "No valid JSON object found. Cleaned response: $cleaned")
1181+
Log.e(TAG, "No valid JSON object found in the response (${cleaned.length} chars)")
11811182
throw Exception("Invalid JSON object in response")
11821183
}
11831184
return cleaned.substring(start, end + 1)

‎app/src/main/java/com/example/embeddedsystemscareerguide/services/GeminiQuizService.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,8 @@ Generate 5 SHORT questions now:
290290
val type = object : TypeToken<List<QuizQuestion>>() {}.type
291291
parsedQuestions = gson.fromJson(cleanJson, type)
292292
} catch (e: Exception) {
293-
Log.e(TAG, "Error parsing quiz response: ${e.message}")
294-
Log.e(TAG, "Raw response (first 500 chars): ${response.take(500)}")
293+
// The reply's length, not its text: Log.e survives R8 into release builds.
294+
Log.e(TAG, "Error parsing quiz response (${response.length} chars): ${e.message}")
295295

296296
// Try an alternative parsing approach - parse question by question
297297
try {

‎app/src/main/java/com/example/embeddedsystemscareerguide/services/UserProgressSyncService.kt‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import com.example.embeddedsystemscareerguide.AppConstants
77
import com.google.firebase.auth.FirebaseAuth
88
import com.google.firebase.firestore.FirebaseFirestore
99
import com.google.firebase.firestore.SetOptions
10+
import com.example.embeddedsystemscareerguide.AppDate
1011
import kotlinx.coroutines.CancellationException
1112
import kotlinx.coroutines.TimeoutCancellationException
13+
import kotlinx.coroutines.sync.Mutex
14+
import kotlinx.coroutines.sync.withLock
1215
import kotlinx.coroutines.tasks.await
1316
import 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\nStarting 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

Comments
 (0)