work · active
How I Shipped Leagues, Streaks and a Social Feed for Students in 3 Months
A weekly leaderboard, Boost, and a social feed for students, where likes, updates and sharing all had to feel instant on low-end phones.
- Feature owner, Android
- Oct 2025 – Jan 2026
- work
- active
Key results
- 3Student-facing features shipped in three months
- 30 s – 10 minHow often the feed checks for updates, set by the server and switchable off without an app update
Overview
Between October 2025 and January 2026 I built three features meant to bring students back to the Teachmint app every day:
- Leaderboard (October): leagues, with promotion and demotion between them.
- Boost (November): XP, streaks and boosts on the student home page.
- Social feed (December–January): a feed of class activity, with reactions and sharing.
Each one is a product feature first. This page is about the engineering that made them feel instant and stay correct on the phones students actually have.
The problem
Gamification only works if it feels live. A like that takes a second to show up, a leaderboard that’s a day stale, or a share that produces a blank image all break the loop the feature exists for. At the same time:
- many students use low-end Android phones on patchy networks
- the feed is server-driven, and its rules (what to show, how often to check) change faster than app releases
- the leaderboard needed explicit consent before a student appeared on it
My approach
Leaderboard: state that can’t tear
The leaderboard screen combines a student’s league, rank, rank change and league history, written
from several coroutines. Plain MutableStateFlow.value = … assignments can lose a concurrent write, so
every write goes through update { }, an atomic read-modify-write on the flow:
_uiState.update { it.copy(rankChange = change) } // never `_uiState.value = …`
Leaderboard consent is stored locally, as a new column added through an explicit Room migration.
The rest was product craft: rank-up, rank-down and “safe” states each with their own sheet, a demotion animation, past and future leagues, and analytics on every step of the funnel. I built most of it.
Feed reactions: compute from the base, not the last value
Likes have to change the moment a student taps. They can’t wait for the server. So the feed keeps a small override per card on top of the server’s data. The first version stored a running total in the override. The refactor stores the base the change was made from instead, and derives the displayed count from it:
// The server's count and the user's reaction when the override was created.
val baseLikes = override.baseLikes ?: (item.likesCount ?: 0L)
val baseLiked = (override.baseUserReaction ?: item.reactionType) != NONE
val nowLiked = (override.userReaction ?: item.reactionType) != NONE
val delta = when {
!baseLiked && nowLiked -> 1L
baseLiked && !nowLiked -> -1L
else -> 0L
}
val shownLikes = baseLikes + delta
However many times the student toggles, the count is the server’s base plus at most one. A refresh clears the overrides, so server data wins as soon as it arrives.
Polling the server can turn off
New posts are announced with a red dot. That needs polling, and polling is exactly what you want to switch off, or slow down, without shipping an app. The feed response carries two server-set values: whether to poll at all, and how often.
- If the server says not to poll, the client stops polling.
- Otherwise it waits the server’s interval, clamped to 30 seconds – 10 minutes, with a one-minute default. A server mistake can’t turn every phone into a load test.
- It compares the newest post’s id with the last one the student saw. If they differ, it shows the red dot and refreshes.
- Only students poll. Teachers’ sessions skip it entirely.
Share a card as an image
Students can share a feed card to other apps as a picture. The card is a composable, so it’s captured
from a graphics layer into a bitmap. The image is written to MediaStore with IS_PENDING set, so a
gallery never shows a half-written file, and cleared when the write completes. Then it’s shared through a
FileProvider URI to the chosen app.
Important decisions
- Server-driven where it changes often. Polling, feed content and leaderboard rules come from the server. The app owns how they feel: instant reactions, smooth transitions, no flicker on refresh.
- Fail quiet, not loud. A polling error stops polling until the next session rather than retrying in a tight loop on a bad network.
What went wrong
The feed took three releases to land. It shipped, was pulled the same day, went out again, was pulled again a few days later, and landed for good in mid-January. The final version added a student-specific feed and onboarding tooltips. The reasons for the two pulls aren’t in the history. What it does show is that a revert and a re-release each took a day.
Result
All three features shipped to students between October and January. The leaderboard and Boost added the daily loop, XP and streaks. The feed added class activity with instant reactions, a red dot driven by server-controlled polling, and image sharing. I built the Android side of each, end to end.
What I learned
- Use
update { }for everyStateFlowwrite from more than one place. Plain assignment loses updates under concurrency. - Optimistic UI should derive from the base it changed, not from its own last output. Stored totals drift. Stored bases don’t.
- Give the server an off switch, with client-side limits. A server flag plus a clamped interval made polling safe to ship.
- Write shared files with
IS_PENDING. Other apps should never see half an image. - Keep reverts boring. The feed went out three times in under three weeks, and each rollback was a single merge.