From a crash-prone MVP to a production-ready Android app — real bugs, real fixes, and the architectural patterns that actually matter in the gym.
The Problem With Most Fitness Apps
Every gym session, the same story plays out: you finish a brutal set, reach for your phone to log it, and the app freezes. Or worse — you logged 10 sets, rotated the screen, and they're all gone.
Shredzilla started as a side project to solve exactly this. But shipping v1.1.0 revealed a hard truth: building a working app and building a reliable app are two completely different problems.
This article is the honest story of v1.2.0 — the bugs we found, how we fixed them, and what architectural patterns actually held up under real-world gym conditions.
The Stack
Language: Kotlin
UI: Jetpack Compose (Material 3)
Architecture: MVVM + State Hoisting
Backend: Firebase Auth + Cloud Firestore
Async: Kotlin Coroutines + StateFlow
Bug 1: The Timer That Lied
The Problem
Our rest timer used a naive delay(1000L) loop:
// ❌ The old approach — drifts under load
timerJob = coroutineScope.launch {
while (isTimerRunning) {
delay(1000L)
timerRemainingSeconds--
}
}
Under heavy CPU load, background threading, or while scrolling a large list, delay(1000L) doesn't actually delay exactly 1000ms. It delays at least 1000ms. Over a 90-second rest period, this accumulated drift could mean 93–95 seconds of actual elapsed time.
The Fix: Delta Math
Instead of counting down by decrementing every second, we anchor to wall-clock time:
// ✅ Zero-drift delta math
fun startRestTimer() {
val targetEndTimeMillis = System.currentTimeMillis() + (duration * 1000L)
timerJob = coroutineScope.launch {
while (isTimerRunning) {
val currentMillis = System.currentTimeMillis()
if (currentMillis >= targetEndTimeMillis) {
timerRemainingSeconds = 0
break
}
// Math anchored to real time — never drifts
timerRemainingSeconds = ((targetEndTimeMillis - currentMillis) / 1000L).toInt()
NotificationUtils.showTimerRunningNotification(context, timerRemainingSeconds, timerTotalSeconds)
delay(100L) // Quick ticks, but evaluation is time-anchored
}
}
}
The key insight: we poll every 100ms, but the value we display is always (endTime - now) / 1000. No matter how delayed the coroutine wakes up, the number shown is mathematically correct.
Bonus fix: The timer now survives app minimization. Switch to YouTube mid-rest, come back after 20 seconds — the timer shows exactly 20 fewer seconds.
Bug 2: The ViewModel Zombie
This one killed us. The symptom: sign out, try to sign in with Google again, and the app freezes on the auth screen doing absolutely nothing.
Root Cause: Stale StateFlow
Our navigation was driven by a StateFlow in MainViewModel:
// In MainViewModel
val startDestinationFlow = savedStateHandle.getStateFlow<String?>("startDestination", null)
fun updateStartDestination(dest: String?) {
savedStateHandle["startDestination"] = dest
}
And in MainActivity:
LaunchedEffect(startDestination) {
val dest = startDestination ?: return@LaunchedEffect
navController.navigate(dest) { ... }
}
Here's what happened on the second login attempt:
- First login →
startDestination = "fitness_main"→ navigation works ✅ - Sign out → Firebase tokens cleared, navigated to Login screen
- But
startDestinationin the ViewModel was still"fitness_main" - Second Google login succeeds → calls
updateStartDestination("fitness_main") -
StateFlowsees the same value → emits nothing →LaunchedEffectnever triggers
The ViewModel survived the sign-out. We called it the "Zombie ViewModel."
The Fix: Reset Before Set
// In MainAppContainer.kt — onSignOut
onSignOut = {
firebaseEmailAuthManager.signOut()
firebaseGoogleAuthManager.signOut()
userDataManager.clearAllListeners()
ThemeManager.currentGenderTheme = null
// ✅ Kill the zombie — reset state before navigating
mainViewModel.updateStartDestination(null)
mainNavController.navigate(AppRoutes.AUTH) {
popUpTo(mainNavController.graph.startDestinationId) { inclusive = true }
launchSingleTop = true
}
}
// In MainActivity.kt — before setting new destination
private suspend fun checkOnboardingAndSetDestination(userId: String) {
mainViewModel.updateStartDestination(null) // ✅ Reset first
val userDataResult = firebaseEmailAuthManager.getUserData(userId)
// ... determine destination
mainViewModel.updateStartDestination(dest) // Then set new value
}
The null reset forces a new emission even when the destination is the same string.
Feature 1: Offline-First Set Logging
The Challenge
Gym WiFi is notoriously bad. If we wait for Firebase to confirm before showing the set in the UI, users see loading spinners mid-workout.
Optimistic UI Updates
fun recordSet(exerciseName: String, reps: Int, weightInput: Double,
notes: String, currentUser: FirebaseUser?) {
val ts = Timestamp.now()
val optimisticDocId = "pending_${java.util.UUID.randomUUID()}"
// ✅ Update UI immediately — zero latency
updateLocalExerciseSetInList(exerciseName, reps, weightInput, notes, ts, optimisticDocId)
// Firebase write happens in background
// Native offline persistence queues it if no internet
currentUser?.uid?.let { userId ->
db.collection("users").document(userId)
.collection("dailyActivity")
.document(todayDate)
.collection("recordedSets")
.add(setData)
.addOnFailureListener { e ->
Log.e("Firestore", "Error adding set", e)
}
}
}
Firebase Android SDK's offline persistence handles the rest. When connectivity returns, the queued writes sync automatically — completely invisible to the user.
Test this yourself: Enable Airplane Mode, log 5 sets, re-enable WiFi. Watch Firestore populate in the console.
Feature 2: Process Death Resilience
The Problem
On low-memory devices, Android can kill your app's process while it's in the background. When the user returns, onCreate is called again — but MainActivity's startDestination was just a plain mutableStateOf, which resets to null.
// ❌ Lost on process death
private var startDestination by mutableStateOf<String?>(null)
SavedStateHandle to the Rescue
class MainViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle // ✅ Survives process death
) : AndroidViewModel(application) {
val startDestinationFlow = savedStateHandle.getStateFlow<String?>("startDestination", null)
fun updateStartDestination(dest: String?) {
savedStateHandle["startDestination"] = dest
}
}
SavedStateHandle is backed by the same mechanism as Bundle in onSaveInstanceState. The OS serializes it before killing the process and restores it when the user returns.
Feature 3: Debounced Exercise Search
A naive search implementation re-filters on every keystroke:
// ❌ Filters on every character typed — causes lag
var searchQuery by remember { mutableStateOf("") }
val filtered = exercises.filter { it.contains(searchQuery) }
With 200+ exercises, this creates visible stuttering. Our fix uses StateFlow debouncing in the ViewModel:
// In MainViewModel
private val _searchQuery = MutableStateFlow("")
val searchQuery = _searchQuery.asStateFlow()
private val _debouncedSearchQuery = MutableStateFlow("")
val debouncedSearchQuery = _debouncedSearchQuery.asStateFlow()
init {
_searchQuery
.debounce(300L) // Wait 300ms after last keystroke
.onEach { _debouncedSearchQuery.value = it }
.launchIn(viewModelScope)
}
The UI binds to searchQuery for immediate display, but filtering uses debouncedSearchQuery. The keyboard stays perfectly responsive; filtering happens 300ms after the user stops typing.
Architecture Win: Listener Memory Leak Fix
The most dangerous silent bug: Firestore snapshot listeners that never get removed.
// ❌ Before — listeners lived forever
db.collection("users").document(userId).addSnapshotListener { ... }
// No reference kept, no way to remove
If a user logged in, logged out, and logged in again — they'd have duplicate listeners writing to the same state. On the third login, triple listeners. Classic memory leak.
// ✅ After — managed listener registry
class UserDataManager(...) {
private val listeners = mutableListOf<ListenerRegistration>()
fun setupFirestoreListeners(currentUser: FirebaseUser?) {
val reg = db.collection("commonExercises")
.addSnapshotListener { ... }
listeners.add(reg) // Track it
}
fun clearAllListeners() {
listeners.forEach { it.remove() } // Remove all on logout
listeners.clear()
}
}
clearAllListeners() is now called in three places: sign out, account deletion, and onCleared() in the ViewModel.
The Analytics Engine
Historical Data with Dynamic Y-Axis
fun loadHistoricalAnalytics(userId: String) {
val currentTime = System.currentTimeMillis()
val isCacheExpired = currentTime - lastAnalyticsLoadTimeMillis > CACHE_EXPIRY_MS
if (!isCacheExpired && _analyticsGraphData.value.isNotEmpty()) return
viewModelScope.launch {
// Fetch last 90 days — caps Firestore reads
val daysSnapshot = db.collection("users").document(userId)
.collection("dailyActivity")
.orderBy(FieldPath.documentId(), Query.Direction.DESCENDING)
.limit(90)
.get().await()
// Heavy computation off the main thread
val chartPoints = withContext(Dispatchers.Default) {
allSets.groupBy { it.exerciseId }
.mapValues { (_, sets) ->
sets.groupBy { truncateToDay(it.timestamp) }
.map { (day, daySets) ->
ChartDataPoint(day, daySets.sumOf { it.volume })
}
.sortedBy { it.timestampDate }
}
}
_analyticsGraphData.value = chartPoints
lastAnalyticsLoadTimeMillis = System.currentTimeMillis()
}
}
The Canvas renderer uses dynamic Y-axis clamping to prevent one outlier session from compressing all your other data:
@Composable
fun ProgressChartCanvas(dataPoints: List<ChartDataPoint>, modifier: Modifier = Modifier) {
val maxVolume = dataPoints.maxOf { it.totalVolume }.toFloat()
val minVolume = dataPoints.minOf { it.totalVolume }.toFloat()
// ✅ Safe division — handles all-equal values
val volumeRange = if (maxVolume > minVolume) (maxVolume - minVolume) else 1f
Canvas(modifier = modifier) {
dataPoints.forEachIndexed { index, point ->
val normalizedY = if (maxVolume == minVolume) 0.5f
else 1f - ((point.totalVolume.toFloat() - minVolume) / volumeRange)
val yPos = (normalizedY * height * 0.8f) + (height * 0.1f)
// ...draw path
}
}
}
v1.3.0: What's Next
Here's what we're shipping next:
CO-2: Screen Transition Animations
// Replacing default NavHost with animated transitions
NavHost(
navController = navController,
enterTransition = { slideInHorizontally { it } + fadeIn() },
exitTransition = { slideOutHorizontally { -it } + fadeOut() },
popEnterTransition = { slideInHorizontally { -it } + fadeIn() },
popExitTransition = { slideOutHorizontally { it } + fadeOut() }
) { ... }
CO-3: SavedStateHandle for Analytics
The analytics cache currently lives in a MutableStateFlow — it's lost on process death. Moving it to SavedStateHandle with JSON serialization:
private fun cacheAnalytics(data: Map<String, List<ChartDataPoint>>) {
val json = gson.toJson(data)
savedStateHandle["analytics_cache"] = json
savedStateHandle["analytics_cache_time"] = System.currentTimeMillis()
}
F-1: Real Activity Indicators on DayPill ✅ (Already shipped)
The horizontal day strip now shows a dot under days with recorded workouts, derived from the analytics data:
val activeDays = remember(analyticsData) {
analyticsData.values.flatten().map { it.timestampDate }.toSet()
}
// In DayPill
if (dayItem.hasActivity) {
Box(
modifier = Modifier
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape)
)
}
F-2: Per-Exercise Chart Selector
Currently shows the exercise with the most data points. Next version adds a horizontal chip selector to pick any exercise.
Q-3: Move Onboarding Logic to ViewModel
checkOnboardingAndSetDestination currently lives in MainActivity. The right home is MainViewModel:
// Target architecture
fun determineStartDestination(userId: String) {
viewModelScope.launch {
updateStartDestination(null) // Reset zombie state
val userData = firebaseAuth.getUserData(userId).getOrNull()
val dest = when {
userData == null -> AppRoutes.ONBOARDING
userData.containsKey("weeklyGoalFrequency") -> AppRoutes.FITNESS_MAIN
// ... onboarding resume logic
else -> AppRoutes.ONBOARDING
}
updateStartDestination(dest)
}
}
Removing F-6 and F-8 From the Roadmap
We originally planned to replace all background images with programmatic gradients. After shipping v1.2.0, we reconsidered.
The login/register screens already use image backgrounds with a semi-transparent overlay — they look good and the images are small. The onboarding screens were already simplified. The remaining gradient work would add complexity without meaningful user-visible improvement.
Removed from roadmap:
F-6: Diagonal split with gradient (GenderSelectionScreen)F-8: Replace login/register backgrounds with gradient
Engineering time is better spent on stability and features users actually see.
Lessons Learned
1. StateFlow doesn't emit duplicate values. If you set a StateFlow to the same value twice, the second set is silently dropped. Always reset to null before setting a new value if you need guaranteed re-emission.
2. Firestore listeners are not garbage-collected. You must manually remove them. Build a listener registry from day one.
3. Optimistic UI is the right default for logged-in users. Don't make users wait for network confirmation on actions they've already committed to.
4. SavedStateHandle is not just for rotation. It's your defense against OS process death — a real scenario on low-memory devices, not just an edge case.
5. Delta math for timers, always. delay(1000L) in a loop is never exactly 1000ms. If correctness matters, anchor to System.currentTimeMillis().
Source Code
The full source is available on GitHub:
github.com/kareem2099/Shredzilla
The project is MIT licensed. Issues and PRs welcome.
Built with Kotlin, Jetpack Compose, Firebase, and too many gym sessions to count. 🦖
Top comments (0)