DEV Community

Cover image for How I Built a Crash-Resistant Workout Tracker: Shredzilla v1.2.0 Deep Dive
freerave
freerave

Posted on

How I Built a Crash-Resistant Workout Tracker: Shredzilla v1.2.0 Deep Dive

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
Enter fullscreen mode Exit fullscreen mode

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--
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

And in MainActivity:

LaunchedEffect(startDestination) {
    val dest = startDestination ?: return@LaunchedEffect
    navController.navigate(dest) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Here's what happened on the second login attempt:

  1. First login → startDestination = "fitness_main" → navigation works ✅
  2. Sign out → Firebase tokens cleared, navigated to Login screen
  3. But startDestination in the ViewModel was still "fitness_main"
  4. Second Google login succeeds → calls updateStartDestination("fitness_main")
  5. StateFlow sees the same value → emits nothingLaunchedEffect never 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
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
) { ... }
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
    )
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)