Crafting a Robust Weekly Streak View with Jetpack Compose
Keeping users engaged often hinges on simple, visual feedback: “Did I complete today’s task?” A weekly streak component does exactly that, but it can hide subtle bugs if you rely only on “day of week.” Today we’ll walk through building a correct, testable, and delightful weekly streak UI in Jetpack Compose.
Why “Day-of-Week” Alone Doesn’t Cut It
A common mistake is to key your UI off Calendar.DAY_OF_WEEK
. It works… until someone reads on one Saturday, then you end up marking every Saturday in the row as complete!
Instead, let’s:
Convert each timestamp to an exact
LocalDate
Build a fixed Sunday → Saturday list for this week
Lookup by that exact date
1. Calculate Your Week Range
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
// Find the Sunday on or before “today”
val sunday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
// Build a list of seven consecutive dates
val weekDates = (0L..6L).map { offset -> sunday.plusDays(offset) }
Why Sunday start? You can choose Monday or any locale preference—just stay consistent.
Freezing “today” in tests becomes trivial: you can substitute a hard-coded
LocalDate
.
2. Index Your Streak Data by Date
data class UserStreak(
@PrimaryKey val date: Long, // midnight epoch-millis
val hasRead: Boolean,
val streakCount: Int
)
Turn your list into a map:
val streakByDate = streakList.associateBy { instant ->
Instant.ofEpochMilli(instant.date)
.atZone(zone)
.toLocalDate()
}
Now lookups are O(1) and precise:
val todayRead = streakByDate[today]?.hasRead == true
3. Shape & Styling Logic
A continuous streak looks best as a single bar with half-caps at the ends:
val prevRead = weekDates.getOrNull(i - 1)?.let { streakByDate[it]?.hasRead } == true
val nextRead = weekDates.getOrNull(i + 1)?.let { streakByDate[it]?.hasRead } == true
val bgShape = when {
hasRead && prevRead && nextRead ->
RectangleShape
hasRead && !prevRead && nextRead ->
RoundedCornerShape(topStart = 50.dp, bottomStart = 50.dp)
hasRead && prevRead && !nextRead ->
RoundedCornerShape(topEnd = 50.dp, bottomEnd = 50.dp)
hasRead ->
CircleShape
else ->
CircleShape
}
Filled primary color for
hasRead
Ghosted background + red “X” for past
!hasRead
Faded border for future dates
4. Always Highlight “Today”
Even on a filled circle, give today’s cell extra emphasis:
val isToday = date == today
val borderModifier = when {
isToday ->
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, bgShape)
// … other cases
else ->
Modifier
}
This small cue keeps users anchored—they immediately know “this is where I stand.”
5. Surface the Date in Empty Circles
Instead of blank holes, show the day-of-month:
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(32.dp)
.clip(bgShape)
.background(/*…*/)
.then(borderModifier)
) {
when {
hasRead -> Icon(Icons.Default.Check, null)
isMissed -> Icon(Icons.Default.Close, null)
else ->
Text(
text = date.dayOfMonth.toString(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
This reinforces context (“I missed the 12th”) and keeps future slots meaningful.
6. Edge Cases & Testing
Previews
Use Compose’s @Preview
to eyeball every scenario:
All days read
None read
Mid-week blocks
Isolated days (e.g. only Wednesday)
Weekend edges (Sunday & Saturday)
Future reads
UI Tests
Add
testTag = "day-$date"
andstateDescription = "read"
/"missed"
.Freeze “today” by hard-coding
LocalDate
in tests.Assert that only exact dates render as read, missed, or hidden.
7. Understanding Kotlin’s Date & Calendar APIs
Working with dates in Kotlin brings its own set of pitfalls:
Legacy
java.util.Calendar
Mutability & Thread-Safety:
Calendar
instances are mutable and not thread-safe—avoid sharing them between threads.Zero-Based Months:
Calendar.MONTH
starts at 0 (January) → off-by-one bugs are common.DST & Time-Zone Quirks: Calculating “midnight” via
calendar.set(HOUR_OF_DAY, 0)
can still land you at 23:00 the previous day in DST transitions.
Modern
java.time
(JSR-310)Immutable & Thread-Safe:
LocalDate
,ZonedDateTime
, etc., can be freely shared.Clear API for Week Fields: Use
TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)
instead of manual roll-back loops.Explicit Zones: Always specify a
ZoneId
when converting betweenInstant
andLocalDate
—don’t rely on the system default unless you mean to.
Epoch Millis ↔ LocalDate
// millis → LocalDate Instant.ofEpochMilli(ms) .atZone(zone) .toLocalDate() // LocalDate → millis at midnight localDate .atStartOfDay(zone) .toInstant() .toEpochMilli()
Beware: Converting “midnight” to millis then back again in a different zone can shift your date if you mix UTC and system zones.
Testing with Fixed “Today”
Use
LocalDate.of(2025, 5, 18)
in tests instead ofLocalDate.now()
to avoid flaky results around midnight or DST changes.
Wrapping Up
Building a weekly streak component is deceptively tricky—but by:
Mapping real dates (not weekdays)
Indexing in a map for precise lookups
Styling based on adjacency and state
Highlighting today
Covering all edge cases in previews & tests
—you’ll deliver a rock-solid, user-friendly UI that scales across weeks, months, and locales.
Have you tackled a similar “streak” or calendar UI? What patterns worked best for you? Drop your tips in the comments!