From 0adc112db54a62350d092e3e32a1619fb004d6e9 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 00:11:25 +0200 Subject: [PATCH 01/19] Add heatmap model for the history screen Pure functions that turn entries into a week-by-weekday grid with intensity levels, month labels and a week-streak summary. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HeatmapModel.kt | 158 ++++++++++++++ .../ui/history/heatmap/HeatmapModelTest.kt | 197 ++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt new file mode 100644 index 0000000..fdf4750 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt @@ -0,0 +1,158 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import com.github.se.pooltrack.model.entry.Entry +import com.github.se.pooltrack.model.entry.timestamp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.Month +import java.time.ZoneId +import java.time.temporal.TemporalAdjusters + +/** Number of intensity steps above "no visit"; levels run from 0 (none) to [MAX_LEVEL]. */ +const val MAX_LEVEL = 4 + +/** Weeks shown by default, so the grid covers a little over a year like GitHub's. */ +const val DEFAULT_WEEK_COUNT = 53 + +/** + * One cell of the heatmap. + * + * @property date The calendar day. + * @property count How many entries were confirmed that day. + * @property level The intensity step from 0 to [MAX_LEVEL], see [intensityLevel]. + */ +data class HeatmapDay(val date: LocalDate, val count: Int, val level: Int) + +/** A month name placed above the column of [weekIndex]. */ +data class HeatmapMonthLabel(val weekIndex: Int, val month: Month) + +/** + * The heatmap grid: one list per week, oldest first, each with seven slots in the order of + * [firstDayOfWeek]. A slot is `null` for days that are after today. + */ +data class Heatmap( + val weeks: List>, + val monthLabels: List, + val firstDayOfWeek: DayOfWeek, +) + +/** Counts [entries] per calendar day, resolving days in [zone]. */ +fun entryCountsByDay( + entries: List, + zone: ZoneId = ZoneId.systemDefault(), +): Map = entries.groupingBy { it.timestamp.atZone(zone).toLocalDate() }.eachCount() + +/** + * The intensity step of a day with [count] entries. The scale is absolute (one entry is level 1, + * two are level 2, ...) until a day beats [scaleFloor], after which it stretches so that the + * busiest day is [MAX_LEVEL]. An absolute scale matters here: most days have a single visit, and a + * purely relative one would paint every cell with the darkest color. + */ +fun intensityLevel(count: Int, maxCount: Int, scaleFloor: Int = MAX_LEVEL): Int { + if (count <= 0) return 0 + val scale = maxOf(maxCount, scaleFloor) + return ((count * MAX_LEVEL + scale - 1) / scale).coerceIn(1, MAX_LEVEL) +} + +/** + * Builds the grid ending with the week of [today] and spanning [weekCount] weeks. + * + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param firstDayOfWeek The day each column starts on, usually the locale's. + */ +fun buildHeatmap( + countsByDay: Map, + today: LocalDate, + weekCount: Int = DEFAULT_WEEK_COUNT, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, +): Heatmap { + require(weekCount > 0) { "weekCount must be positive" } + val lastWeekStart = today.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + val firstWeekStart = lastWeekStart.minusWeeks(weekCount - 1L) + val maxCount = + countsByDay.filterKeys { it >= firstWeekStart && it <= today }.values.maxOrNull() ?: 0 + + val weeks = + (0 until weekCount).map { week -> + val weekStart = firstWeekStart.plusWeeks(week.toLong()) + (0 until 7).map { offset -> + val date = weekStart.plusDays(offset.toLong()) + if (date.isAfter(today)) null + else { + val count = countsByDay[date] ?: 0 + HeatmapDay(date, count, intensityLevel(count, maxCount)) + } + } + } + return Heatmap(weeks, monthLabels(firstWeekStart, weekCount), firstDayOfWeek) +} + +/** + * Labels the first column in which each month appears, skipping a label that would sit closer than + * [minGap] columns to the previous one (it would overlap on screen). + */ +private fun monthLabels( + firstWeekStart: LocalDate, + weekCount: Int, + minGap: Int = 3, +): List { + val labels = mutableListOf() + var previousMonth: Month? = null + for (week in 0 until weekCount) { + // A week belongs to the month in which most of it falls, i.e. the month of its fourth day. + val month = firstWeekStart.plusWeeks(week.toLong()).plusDays(3).month + if (month != previousMonth) { + val lastIndex = labels.lastOrNull()?.weekIndex + if (lastIndex == null || week - lastIndex >= minGap) { + labels += HeatmapMonthLabel(week, month) + } + previousMonth = month + } + } + return labels +} + +/** + * A summary of the visible [Heatmap] range. + * + * @property visits Entries in the range. + * @property activeDays Days with at least one entry. + * @property currentWeekStreak Consecutive weeks with a visit, ending this week, or last week if + * this one has none yet (so the streak doesn't read 0 every Monday morning). + * @property longestWeekStreak The longest run of consecutive weeks with a visit in the range. + */ +data class HeatmapSummary( + val visits: Int, + val activeDays: Int, + val currentWeekStreak: Int, + val longestWeekStreak: Int, +) + +/** Summarizes [heatmap]. Streaks are by week, since swimming every single day is unrealistic. */ +fun summarize(heatmap: Heatmap): HeatmapSummary { + val days = heatmap.weeks.flatten().filterNotNull() + val activeWeeks = heatmap.weeks.map { week -> week.any { (it?.count ?: 0) > 0 } } + + var longest = 0 + var run = 0 + for (active in activeWeeks) { + run = if (active) run + 1 else 0 + longest = maxOf(longest, run) + } + + val lastIndex = activeWeeks.lastIndex + val streakEnd = if (activeWeeks[lastIndex] || lastIndex == 0) lastIndex else lastIndex - 1 + var current = 0 + var index = streakEnd + while (index >= 0 && activeWeeks[index]) { + current++ + index-- + } + + return HeatmapSummary( + visits = days.sumOf { it.count }, + activeDays = days.count { it.count > 0 }, + currentWeekStreak = current, + longestWeekStreak = longest, + ) +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt new file mode 100644 index 0000000..1e342f6 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt @@ -0,0 +1,197 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import com.github.se.pooltrack.model.entry.Entry +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.Month +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class HeatmapModelTest { + + // Wednesday 2026-10-07. + private val today = LocalDate.of(2026, 10, 7) + + private fun build(counts: Map = emptyMap(), weeks: Int = 5) = + buildHeatmap(counts, today, weeks, DayOfWeek.MONDAY) + + @Test + fun intensityLevel_isZero_withoutEntries() { + assertEquals(0, intensityLevel(count = 0, maxCount = 10)) + } + + @Test + fun intensityLevel_isAbsolute_untilTheScaleFloor() { + assertEquals(listOf(1, 2, 3, 4), (1..4).map { intensityLevel(it, maxCount = 4) }) + // A single visit stays light even when it is the busiest day. + assertEquals(1, intensityLevel(count = 1, maxCount = 1)) + } + + @Test + fun intensityLevel_stretches_whenBusierDaysExist() { + assertEquals(4, intensityLevel(count = 8, maxCount = 8)) + assertEquals(2, intensityLevel(count = 4, maxCount = 8)) + assertEquals(1, intensityLevel(count = 1, maxCount = 8)) + } + + @Test + fun entryCountsByDay_groupsEntriesOfTheSameDay() { + val zone = ZoneId.of("UTC") + val entries = + listOf( + Entry(LocalDate.of(2026, 10, 5).atTime(8, 0).atZone(zone).toInstant().toEpochMilli()), + Entry(LocalDate.of(2026, 10, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli()), + Entry(LocalDate.of(2026, 10, 6).atTime(9, 0).atZone(zone).toInstant().toEpochMilli()), + ) + + val counts = entryCountsByDay(entries, zone) + + assertEquals(mapOf(LocalDate.of(2026, 10, 5) to 2, LocalDate.of(2026, 10, 6) to 1), counts) + } + + @Test + fun buildHeatmap_hasOneColumnPerWeekOfSevenSlots() { + val heatmap = build(weeks = 5) + + assertEquals(5, heatmap.weeks.size) + assertEquals(listOf(7, 7, 7, 7, 7), heatmap.weeks.map { it.size }) + } + + @Test + fun buildHeatmap_endsWithTheWeekOfToday_andHidesTheFuture() { + val lastWeek = build().weeks.last() + + // Monday..Wednesday exist, Thursday..Sunday are in the future. + assertEquals(LocalDate.of(2026, 10, 5), lastWeek[0]?.date) + assertEquals(today, lastWeek[2]?.date) + assertNull(lastWeek[3]) + assertNull(lastWeek[6]) + } + + @Test + fun buildHeatmap_startsColumnsOnTheGivenFirstDayOfWeek() { + val sundayFirst = buildHeatmap(emptyMap(), today, weekCount = 3, DayOfWeek.SUNDAY) + + assertEquals(LocalDate.of(2026, 10, 4), sundayFirst.weeks.last()[0]?.date) + assertEquals(LocalDate.of(2026, 9, 20), sundayFirst.weeks.first()[0]?.date) + } + + @Test + fun buildHeatmap_assignsCountAndLevelToEachDay() { + val monday = LocalDate.of(2026, 10, 5) + val heatmap = build(mapOf(monday to 2)) + + val cell = heatmap.weeks.last()[0] + assertNotNull(cell) + assertEquals(2, cell!!.count) + assertEquals(2, cell.level) + assertEquals(0, heatmap.weeks.last()[1]?.count) + assertEquals(0, heatmap.weeks.last()[1]?.level) + } + + @Test + fun buildHeatmap_ignoresEntriesOutsideTheRange() { + val tooOld = LocalDate.of(2020, 1, 1) + val heatmap = build(mapOf(tooOld to 50)) + + assertEquals(0, summarize(heatmap).visits) + // The ancient busy day must not flatten the scale of the visible range. + assertEquals(1, build(mapOf(tooOld to 50, today to 1)).weeks.last()[2]?.level) + } + + @Test + fun buildHeatmap_rejectsNonPositiveWeekCount() { + assertThrows(IllegalArgumentException::class.java) { build(weeks = 0) } + } + + @Test + fun buildHeatmap_labelsEachMonthOnce_atItsFirstColumn() { + // 53 weeks ending 2026-10-07 start on Monday 2025-10-06. + val heatmap = buildHeatmap(emptyMap(), today, DEFAULT_WEEK_COUNT, DayOfWeek.MONDAY) + + val months = heatmap.monthLabels.map { it.month } + // October opens and closes the range, every other month shows up once. + assertEquals(13, months.size) + assertEquals(12, months.distinct().size) + assertEquals(0, heatmap.monthLabels.first().weekIndex) + assertEquals(Month.OCTOBER, heatmap.monthLabels.first().month) + assertEquals(Month.OCTOBER, heatmap.monthLabels.last().month) + } + + @Test + fun buildHeatmap_keepsMonthLabelsApart() { + val heatmap = buildHeatmap(emptyMap(), today, DEFAULT_WEEK_COUNT, DayOfWeek.MONDAY) + + heatmap.monthLabels.zipWithNext().forEach { (a, b) -> + assert(b.weekIndex - a.weekIndex >= 3) { "$a and $b overlap" } + } + } + + @Test + fun summarize_countsVisitsAndActiveDays() { + val counts = + mapOf( + LocalDate.of(2026, 10, 5) to 2, + LocalDate.of(2026, 10, 6) to 1, + LocalDate.of(2026, 9, 1) to 1, + ) + + val summary = summarize(build(counts, weeks = 8)) + + assertEquals(4, summary.visits) + assertEquals(3, summary.activeDays) + } + + @Test + fun summarize_ofAnEmptyHeatmap_isAllZero() { + assertEquals(HeatmapSummary(0, 0, 0, 0), summarize(build())) + } + + @Test + fun summarize_countsConsecutiveWeeksAsAStreak() { + // Weeks starting 09-21, 09-28 and 10-05 all have a visit. + val counts = + mapOf( + LocalDate.of(2026, 9, 22) to 1, + LocalDate.of(2026, 9, 30) to 1, + LocalDate.of(2026, 10, 5) to 1, + ) + + val summary = summarize(build(counts)) + + assertEquals(3, summary.currentWeekStreak) + assertEquals(3, summary.longestWeekStreak) + } + + @Test + fun summarize_keepsTheStreakAlive_whenThisWeekHasNoVisitYet() { + val counts = mapOf(LocalDate.of(2026, 9, 29) to 1, LocalDate.of(2026, 9, 22) to 1) + + val summary = summarize(build(counts)) + + assertEquals(2, summary.currentWeekStreak) + } + + @Test + fun summarize_resetsTheCurrentStreak_afterAMissedWeek() { + // Visits two and three weeks ago, none last week or this week. + val counts = mapOf(LocalDate.of(2026, 9, 22) to 1, LocalDate.of(2026, 9, 15) to 1) + + val summary = summarize(build(counts)) + + assertEquals(0, summary.currentWeekStreak) + assertEquals(2, summary.longestWeekStreak) + } + + @Test + fun summarize_worksWithASingleWeek() { + val summary = summarize(build(mapOf(today to 1), weeks = 1)) + + assertEquals(1, summary.currentWeekStreak) + assertEquals(1, summary.longestWeekStreak) + } +} From 0f3c12d70b7f1eda144fe74206807311044dc89e Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 00:11:31 +0200 Subject: [PATCH 02/19] Add contribution heatmap composables A GitHub-style calendar grid, its legend and the overview card that wraps it with a summary line and a clear-filter bar. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/ContributionHeatmap.kt | 186 ++++++++++++++++++ .../ui/history/heatmap/HistoryOverview.kt | 107 ++++++++++ .../heatmap/HistoryOverviewTextTest.kt | 38 ++++ 3 files changed, 331 insertions(+) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt new file mode 100644 index 0000000..0533a18 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt @@ -0,0 +1,186 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale + +object ContributionHeatmapTestTags { + const val HEATMAP = "ContributionHeatmap" + + fun cell(date: LocalDate) = "ContributionHeatmapCell_$date" +} + +private val CellSize = 14.dp +private val CellGap = 3.dp +private val CellShape = RoundedCornerShape(3.dp) +private val WeekdayLabelWidth = 32.dp +private val MonthLabelHeight = 16.dp + +/** The fill of a cell at [level], from an empty slot (0) to the busiest days ([MAX_LEVEL]). */ +@Composable +fun heatmapColor(level: Int): Color { + val scheme = MaterialTheme.colorScheme + return when (level) { + 0 -> scheme.onSurface.copy(alpha = 0.08f) + 1 -> scheme.primary.copy(alpha = 0.3f) + 2 -> scheme.primary.copy(alpha = 0.5f) + 3 -> scheme.primary.copy(alpha = 0.75f) + else -> scheme.primary + } +} + +/** + * A calendar of squares in the style of GitHub's contribution graph: one column per week, one row + * per weekday, darker for more entries. It scrolls horizontally and starts at the current week. + * + * @param heatmap The grid to draw, see [buildHeatmap]. + * @param selectedDate The highlighted day, if any. + * @param onDayClick Called with the tapped day. + * @param locale The locale for month and weekday names. + */ +@Composable +fun ContributionHeatmap( + heatmap: Heatmap, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + modifier: Modifier = Modifier, + locale: Locale = Locale.getDefault(), +) { + val scrollState = rememberScrollState() + LaunchedEffect(scrollState.maxValue) { scrollState.scrollTo(scrollState.maxValue) } + val step = CellSize + CellGap + + Row(modifier = modifier.testTag(ContributionHeatmapTestTags.HEATMAP)) { + // Only every other row is named, otherwise the labels would not fit between the squares. + Column( + modifier = Modifier.padding(top = MonthLabelHeight).width(WeekdayLabelWidth), + verticalArrangement = Arrangement.spacedBy(CellGap), + ) { + (0 until 7).forEach { row -> + Box(modifier = Modifier.height(CellSize), contentAlignment = Alignment.CenterStart) { + if (row % 2 == 1) { + Text( + text = + heatmap.firstDayOfWeek + .plus(row.toLong()) + .getDisplayName(TextStyle.SHORT, locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } + Column(modifier = Modifier.horizontalScroll(scrollState)) { + Box(modifier = Modifier.height(MonthLabelHeight).width(step * heatmap.weeks.size)) { + heatmap.monthLabels.forEach { label -> + Text( + text = label.month.getDisplayName(TextStyle.SHORT, locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.offset(x = step * label.weekIndex), + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(CellGap)) { + heatmap.weeks.forEach { week -> + Column(verticalArrangement = Arrangement.spacedBy(CellGap)) { + week.forEach { day -> + if (day == null) { + Box(modifier = Modifier.size(CellSize)) + } else { + HeatmapCell( + day = day, + selected = day.date == selectedDate, + onClick = { onDayClick(day.date) }, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun HeatmapCell(day: HeatmapDay, selected: Boolean, onClick: () -> Unit) { + val description = + when (day.count) { + 0 -> "${day.date}: no entries" + 1 -> "${day.date}: 1 entry" + else -> "${day.date}: ${day.count} entries" + } + Box( + modifier = + Modifier.size(CellSize) + .background(heatmapColor(day.level), CellShape) + .then( + if (selected) { + Modifier.border( + BorderStroke(2.dp, MaterialTheme.colorScheme.onSurface), + CellShape, + ) + } else Modifier + ) + .clickable(onClick = onClick) + .semantics { contentDescription = description } + .testTag(ContributionHeatmapTestTags.cell(day.date)) + ) +} + +/** The "Less, five squares, More" key explaining the colors. */ +@Composable +fun HeatmapLegend(modifier: Modifier = Modifier, cellSize: Dp = 12.dp) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Less", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + (0..MAX_LEVEL).forEach { level -> + Box( + modifier = + Modifier.padding(start = CellGap) + .size(cellSize) + .background(heatmapColor(level), CellShape) + ) + } + Text( + text = "More", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = CellGap * 2), + ) + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt new file mode 100644 index 0000000..1d05bbd --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -0,0 +1,107 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.time.LocalDate + +object HistoryOverviewTestTags { + const val CARD = "HistoryOverviewCard" + const val SUMMARY = "HistoryOverviewSummary" + const val CLEAR_FILTER = "HistoryOverviewClearFilter" +} + +/** "34 visits in the last year" for [summary]. */ +fun summaryTitle(summary: HeatmapSummary): String = + if (summary.visits == 1) "1 visit in the last year" + else "${summary.visits} visits in the last year" + +/** The week-streak sentence, or `null` when there is no streak worth mentioning. */ +fun streakText(summary: HeatmapSummary): String? = + when { + summary.currentWeekStreak >= 2 -> + "${summary.currentWeekStreak}-week streak (best: ${summary.longestWeekStreak})" + summary.longestWeekStreak >= 2 -> "Best streak: ${summary.longestWeekStreak} weeks in a row" + else -> null + } + +/** + * The card on top of the History list: how much the user swam over the last year as a heatmap, a + * one-line summary, and, once a day is picked, a bar to clear that filter. + * + * @param heatmap The grid, see [buildHeatmap]. + * @param selectedDate The day the list is filtered to, if any. + * @param onDayClick Called when a day is tapped; the caller toggles the filter. + * @param onClearSelection Called to show every day again. + * @param selectedLabel How to name the selected day, e.g. "Monday, Oct 5". + */ +@Composable +fun HistoryOverview( + heatmap: Heatmap, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + onClearSelection: () -> Unit, + modifier: Modifier = Modifier, + selectedLabel: (LocalDate) -> String = { it.toString() }, +) { + val summary = summarize(heatmap) + val activeDays = "${summary.activeDays} active ${if (summary.activeDays == 1) "day" else "days"}" + Card(modifier = modifier.fillMaxWidth().testTag(HistoryOverviewTestTags.CARD)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.testTag(HistoryOverviewTestTags.SUMMARY)) { + Text( + text = summaryTitle(summary), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = listOfNotNull(activeDays, streakText(summary)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + ContributionHeatmap( + heatmap = heatmap, + selectedDate = selectedDate, + onDayClick = onDayClick, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectedDate != null) { + Text( + text = "Showing ${selectedLabel(selectedDate)}", + style = MaterialTheme.typography.labelMedium, + ) + TextButton( + onClick = onClearSelection, + modifier = Modifier.testTag(HistoryOverviewTestTags.CLEAR_FILTER), + ) { + Text("Show all") + } + } else { + Text( + text = "Tap a day to filter", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HeatmapLegend() + } + } + } + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt new file mode 100644 index 0000000..e36680e --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt @@ -0,0 +1,38 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HistoryOverviewTextTest { + + private fun summary(visits: Int = 0, current: Int = 0, longest: Int = 0) = + HeatmapSummary(visits, activeDays = visits, current, longest) + + @Test + fun summaryTitle_isSingular_forOneVisit() { + assertEquals("1 visit in the last year", summaryTitle(summary(visits = 1))) + } + + @Test + fun summaryTitle_isPlural_otherwise() { + assertEquals("0 visits in the last year", summaryTitle(summary(visits = 0))) + assertEquals("34 visits in the last year", summaryTitle(summary(visits = 34))) + } + + @Test + fun streakText_showsTheCurrentStreak_fromTwoWeeks() { + assertEquals("3-week streak (best: 5)", streakText(summary(current = 3, longest = 5))) + } + + @Test + fun streakText_showsTheBestStreak_whenTheCurrentOneIsOver() { + assertEquals("Best streak: 4 weeks in a row", streakText(summary(current = 1, longest = 4))) + } + + @Test + fun streakText_isNull_withoutAStreak() { + assertNull(streakText(summary(current = 1, longest = 1))) + assertNull(streakText(summary())) + } +} From f3b67112261a8d3f73678eb1a8161c3bcb040e89 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 00:11:36 +0200 Subject: [PATCH 03/19] Show the heatmap on the history screen Tapping a day filters the list to it; tapping it again or Show all clears the filter. Screen tests use a tall viewport so the card does not push the rows out of view. Co-Authored-By: Claude Sonnet 5.5 --- .../se/pooltrack/ui/history/HistoryScreen.kt | 39 +++++++++++- .../history/HistoryScreenRobolectricTest.kt | 60 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index 6f1c47c..a3f716a 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -46,6 +46,8 @@ import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.timestamp import com.github.se.pooltrack.model.swim.formatSwimDistance import com.github.se.pooltrack.model.swim.formatSwimDuration +import com.github.se.pooltrack.ui.history.heatmap.HistoryOverview +import com.github.se.pooltrack.ui.history.heatmap.buildHeatmap import com.github.se.pooltrack.ui.navigation.BottomNavigationMenu import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.Screen @@ -58,6 +60,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle +import java.time.temporal.WeekFields import java.util.Locale import kotlinx.coroutines.launch @@ -73,6 +76,7 @@ object HistoryScreenTestTags { const val UNDO_ADD_ACTION = "Undo" const val LINK_BANNER = "HistoryScreenLinkBanner" const val LINK_BUTTON = "HistoryScreenLinkButton" + const val NO_ENTRIES_THAT_DAY = "HistoryScreenNoEntriesThatDay" } private val ZONE = ZoneId.systemDefault() @@ -100,6 +104,7 @@ fun HistoryScreen( val entries by viewModel.entries.collectAsState() var entryPendingDeletion by remember { mutableStateOf(null) } var showAddSheet by remember { mutableStateOf(false) } + var selectedDate by remember { mutableStateOf(null) } // Remembered across openings, so adding several entries from the same day takes one tap each. var lastAddedDate by remember { mutableStateOf(LocalDate.now(ZONE)) } var lastAddedTime by remember { mutableStateOf(LocalTime.of(12, 0)) } @@ -220,7 +225,17 @@ fun HistoryScreen( } } else { val today = LocalDate.now(ZONE) - val entriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } + val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } + val heatmap = + remember(entries, today) { + buildHeatmap( + countsByDay = allEntriesByDay.mapValues { it.value.size }, + today = today, + firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + ) + } + val entriesByDay = + selectedDate?.let { day -> allEntriesByDay.filterKeys { it == day } } ?: allEntriesByDay LazyColumn( modifier = @@ -229,6 +244,28 @@ fun HistoryScreen( .testTag(HistoryScreenTestTags.ENTRY_LIST), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 88.dp), ) { + item(key = "overview") { + HistoryOverview( + heatmap = heatmap, + selectedDate = selectedDate, + onDayClick = { day -> selectedDate = if (day == selectedDate) null else day }, + onClearSelection = { selectedDate = null }, + selectedLabel = { dayLabel(it, today) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + if (selectedDate != null && entriesByDay.isEmpty()) { + item(key = "no-entries-that-day") { + Text( + text = "No entries on this day.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(vertical = 16.dp) + .testTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY), + ) + } + } if (linkSuggestions.isNotEmpty()) { item(key = "link-banner") { LinkBanner( diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index a85bdc4..9978066 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -14,6 +14,8 @@ import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.Subscription import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider +import com.github.se.pooltrack.ui.history.heatmap.ContributionHeatmapTestTags +import com.github.se.pooltrack.ui.history.heatmap.HistoryOverviewTestTags import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.NavigationTestTags import com.github.se.pooltrack.ui.navigation.Screen @@ -34,7 +36,10 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +// A tall screen: the heatmap card sits above the list and would push the rows out of view. +@Config(qualifiers = "w360dp-h1200dp") @RunWith(RobolectricTestRunner::class) class HistoryScreenRobolectricTest { @@ -354,4 +359,59 @@ class HistoryScreenRobolectricTest { composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.LINK_BANNER).assertCountEquals(0) } + + @Test + fun historyScreen_showsTheHeatmapOverview_whenEntriesExist() { + show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(2))))) + + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CARD).assertIsDisplayed() + composeTestRule.onNodeWithText("2 visits in the last year").assertIsDisplayed() + composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(today)).assertExists() + } + + @Test + fun historyScreen_hidesTheHeatmapOverview_whenThereAreNoEntries() { + show(FakeEntryRepository()) + + composeTestRule.onAllNodesWithTag(HistoryOverviewTestTags.CARD).assertCountEquals(0) + } + + @Test + fun historyScreen_filtersTheListToATappedDay_andClearsIt() { + val older = today.minusDays(3) + show(FakeEntryRepository(listOf(entryAt(today), entryAt(older)))) + + composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(older)).performClick() + + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(1) + + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CLEAR_FILTER).performClick() + + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(2) + } + + @Test + fun historyScreen_tappingTheSelectedDayAgain_showsEveryDay() { + show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(3))))) + val cell = composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(today)) + + cell.performClick() + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) + cell.performClick() + + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(2) + } + + @Test + fun historyScreen_saysSoWhenTheTappedDayHasNoEntries() { + show(FakeEntryRepository(listOf(entryAt(today.minusDays(5))))) + + composeTestRule + .onNodeWithTag(ContributionHeatmapTestTags.cell(today.minusDays(1))) + .performClick() + + composeTestRule.onNodeWithTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(0) + } } From d6ae78033513c8cd4f3880e9d02c3f8c4ec59178 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 00:47:36 +0200 Subject: [PATCH 04/19] Add month grid model, drop year grid labels A wall-calendar layout of one month, plus the list of months to page through. The year grid now only feeds the summary, so its month labels go. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/ContributionHeatmap.kt | 186 ------------------ .../ui/history/heatmap/HeatmapModel.kt | 32 +-- .../ui/history/heatmap/MonthModel.kt | 67 +++++++ .../ui/history/heatmap/HeatmapModelTest.kt | 24 --- .../ui/history/heatmap/MonthModelTest.kt | 130 ++++++++++++ 5 files changed, 198 insertions(+), 241 deletions(-) delete mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt deleted file mode 100644 index 0533a18..0000000 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/ContributionHeatmap.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.github.se.pooltrack.ui.history.heatmap - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import java.time.LocalDate -import java.time.format.TextStyle -import java.util.Locale - -object ContributionHeatmapTestTags { - const val HEATMAP = "ContributionHeatmap" - - fun cell(date: LocalDate) = "ContributionHeatmapCell_$date" -} - -private val CellSize = 14.dp -private val CellGap = 3.dp -private val CellShape = RoundedCornerShape(3.dp) -private val WeekdayLabelWidth = 32.dp -private val MonthLabelHeight = 16.dp - -/** The fill of a cell at [level], from an empty slot (0) to the busiest days ([MAX_LEVEL]). */ -@Composable -fun heatmapColor(level: Int): Color { - val scheme = MaterialTheme.colorScheme - return when (level) { - 0 -> scheme.onSurface.copy(alpha = 0.08f) - 1 -> scheme.primary.copy(alpha = 0.3f) - 2 -> scheme.primary.copy(alpha = 0.5f) - 3 -> scheme.primary.copy(alpha = 0.75f) - else -> scheme.primary - } -} - -/** - * A calendar of squares in the style of GitHub's contribution graph: one column per week, one row - * per weekday, darker for more entries. It scrolls horizontally and starts at the current week. - * - * @param heatmap The grid to draw, see [buildHeatmap]. - * @param selectedDate The highlighted day, if any. - * @param onDayClick Called with the tapped day. - * @param locale The locale for month and weekday names. - */ -@Composable -fun ContributionHeatmap( - heatmap: Heatmap, - selectedDate: LocalDate?, - onDayClick: (LocalDate) -> Unit, - modifier: Modifier = Modifier, - locale: Locale = Locale.getDefault(), -) { - val scrollState = rememberScrollState() - LaunchedEffect(scrollState.maxValue) { scrollState.scrollTo(scrollState.maxValue) } - val step = CellSize + CellGap - - Row(modifier = modifier.testTag(ContributionHeatmapTestTags.HEATMAP)) { - // Only every other row is named, otherwise the labels would not fit between the squares. - Column( - modifier = Modifier.padding(top = MonthLabelHeight).width(WeekdayLabelWidth), - verticalArrangement = Arrangement.spacedBy(CellGap), - ) { - (0 until 7).forEach { row -> - Box(modifier = Modifier.height(CellSize), contentAlignment = Alignment.CenterStart) { - if (row % 2 == 1) { - Text( - text = - heatmap.firstDayOfWeek - .plus(row.toLong()) - .getDisplayName(TextStyle.SHORT, locale), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - } - } - } - Column(modifier = Modifier.horizontalScroll(scrollState)) { - Box(modifier = Modifier.height(MonthLabelHeight).width(step * heatmap.weeks.size)) { - heatmap.monthLabels.forEach { label -> - Text( - text = label.month.getDisplayName(TextStyle.SHORT, locale), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.offset(x = step * label.weekIndex), - ) - } - } - Row(horizontalArrangement = Arrangement.spacedBy(CellGap)) { - heatmap.weeks.forEach { week -> - Column(verticalArrangement = Arrangement.spacedBy(CellGap)) { - week.forEach { day -> - if (day == null) { - Box(modifier = Modifier.size(CellSize)) - } else { - HeatmapCell( - day = day, - selected = day.date == selectedDate, - onClick = { onDayClick(day.date) }, - ) - } - } - } - } - } - } - } -} - -@Composable -private fun HeatmapCell(day: HeatmapDay, selected: Boolean, onClick: () -> Unit) { - val description = - when (day.count) { - 0 -> "${day.date}: no entries" - 1 -> "${day.date}: 1 entry" - else -> "${day.date}: ${day.count} entries" - } - Box( - modifier = - Modifier.size(CellSize) - .background(heatmapColor(day.level), CellShape) - .then( - if (selected) { - Modifier.border( - BorderStroke(2.dp, MaterialTheme.colorScheme.onSurface), - CellShape, - ) - } else Modifier - ) - .clickable(onClick = onClick) - .semantics { contentDescription = description } - .testTag(ContributionHeatmapTestTags.cell(day.date)) - ) -} - -/** The "Less, five squares, More" key explaining the colors. */ -@Composable -fun HeatmapLegend(modifier: Modifier = Modifier, cellSize: Dp = 12.dp) { - Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Less", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - (0..MAX_LEVEL).forEach { level -> - Box( - modifier = - Modifier.padding(start = CellGap) - .size(cellSize) - .background(heatmapColor(level), CellShape) - ) - } - Text( - text = "More", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = CellGap * 2), - ) - } -} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt index fdf4750..85c46ee 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt @@ -4,7 +4,6 @@ import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.timestamp import java.time.DayOfWeek import java.time.LocalDate -import java.time.Month import java.time.ZoneId import java.time.temporal.TemporalAdjusters @@ -23,16 +22,12 @@ const val DEFAULT_WEEK_COUNT = 53 */ data class HeatmapDay(val date: LocalDate, val count: Int, val level: Int) -/** A month name placed above the column of [weekIndex]. */ -data class HeatmapMonthLabel(val weekIndex: Int, val month: Month) - /** * The heatmap grid: one list per week, oldest first, each with seven slots in the order of * [firstDayOfWeek]. A slot is `null` for days that are after today. */ data class Heatmap( val weeks: List>, - val monthLabels: List, val firstDayOfWeek: DayOfWeek, ) @@ -84,32 +79,7 @@ fun buildHeatmap( } } } - return Heatmap(weeks, monthLabels(firstWeekStart, weekCount), firstDayOfWeek) -} - -/** - * Labels the first column in which each month appears, skipping a label that would sit closer than - * [minGap] columns to the previous one (it would overlap on screen). - */ -private fun monthLabels( - firstWeekStart: LocalDate, - weekCount: Int, - minGap: Int = 3, -): List { - val labels = mutableListOf() - var previousMonth: Month? = null - for (week in 0 until weekCount) { - // A week belongs to the month in which most of it falls, i.e. the month of its fourth day. - val month = firstWeekStart.plusWeeks(week.toLong()).plusDays(3).month - if (month != previousMonth) { - val lastIndex = labels.lastOrNull()?.weekIndex - if (lastIndex == null || week - lastIndex >= minGap) { - labels += HeatmapMonthLabel(week, month) - } - previousMonth = month - } - } - return labels + return Heatmap(weeks, firstDayOfWeek) } /** diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt new file mode 100644 index 0000000..4a17ab7 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt @@ -0,0 +1,67 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +/** + * One day of a [MonthGrid]. + * + * @property level The intensity step from 0 to [MAX_LEVEL], see [intensityLevel]. + * @property isFuture Whether the day is after today, so nothing can have happened yet. + */ +data class MonthDay( + val date: LocalDate, + val count: Int, + val level: Int, + val isToday: Boolean, + val isFuture: Boolean, +) + +/** + * A month laid out like a wall calendar: [weeks] of seven slots each, starting on a given weekday, + * with `null` for the slots before the 1st and after the last day. + */ +data class MonthGrid(val month: YearMonth, val weeks: List>) { + /** Entries confirmed during the month. */ + val visits: Int + get() = weeks.sumOf { week -> week.sumOf { it?.count ?: 0 } } +} + +/** + * Lays out [month]. + * + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param maxCount The busiest day to scale the intensity against; pass the same value for every + * month so that colors mean the same thing from one month to the next. + */ +fun buildMonthGrid( + month: YearMonth, + countsByDay: Map, + today: LocalDate, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + maxCount: Int = countsByDay.values.maxOrNull() ?: 0, +): MonthGrid { + val leadingBlanks = (month.atDay(1).dayOfWeek.value - firstDayOfWeek.value + 7) % 7 + val slots: List = + List(leadingBlanks) { null } + + (1..month.lengthOfMonth()).map { dayOfMonth -> + val date = month.atDay(dayOfMonth) + val count = countsByDay[date] ?: 0 + MonthDay( + date = date, + count = count, + level = intensityLevel(count, maxCount), + isToday = date == today, + isFuture = date.isAfter(today), + ) + } + val padded = slots + List((7 - slots.size % 7) % 7) { null } + return MonthGrid(month, padded.chunked(7)) +} + +/** Every month from [first] to [last], oldest first; just [last] if [first] is after it. */ +fun monthsBetween(first: YearMonth, last: YearMonth): List { + if (first.isAfter(last)) return listOf(last) + return generateSequence(first) { it.plusMonths(1) }.takeWhile { !it.isAfter(last) }.toList() +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt index 1e342f6..9e88e3b 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt @@ -3,7 +3,6 @@ package com.github.se.pooltrack.ui.history.heatmap import com.github.se.pooltrack.model.entry.Entry import java.time.DayOfWeek import java.time.LocalDate -import java.time.Month import java.time.ZoneId import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -108,29 +107,6 @@ class HeatmapModelTest { assertThrows(IllegalArgumentException::class.java) { build(weeks = 0) } } - @Test - fun buildHeatmap_labelsEachMonthOnce_atItsFirstColumn() { - // 53 weeks ending 2026-10-07 start on Monday 2025-10-06. - val heatmap = buildHeatmap(emptyMap(), today, DEFAULT_WEEK_COUNT, DayOfWeek.MONDAY) - - val months = heatmap.monthLabels.map { it.month } - // October opens and closes the range, every other month shows up once. - assertEquals(13, months.size) - assertEquals(12, months.distinct().size) - assertEquals(0, heatmap.monthLabels.first().weekIndex) - assertEquals(Month.OCTOBER, heatmap.monthLabels.first().month) - assertEquals(Month.OCTOBER, heatmap.monthLabels.last().month) - } - - @Test - fun buildHeatmap_keepsMonthLabelsApart() { - val heatmap = buildHeatmap(emptyMap(), today, DEFAULT_WEEK_COUNT, DayOfWeek.MONDAY) - - heatmap.monthLabels.zipWithNext().forEach { (a, b) -> - assert(b.weekIndex - a.weekIndex >= 3) { "$a and $b overlap" } - } - } - @Test fun summarize_countsVisitsAndActiveDays() { val counts = diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt new file mode 100644 index 0000000..fd5e11d --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt @@ -0,0 +1,130 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MonthModelTest { + + private val october = YearMonth.of(2026, 10) // 1st is a Thursday, 31 days + private val today = LocalDate.of(2026, 10, 7) + + private fun grid( + counts: Map = emptyMap(), + month: YearMonth = october, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + ) = buildMonthGrid(month, counts, today, firstDayOfWeek) + + @Test + fun buildMonthGrid_padsEveryWeekToSevenSlots() { + assertEquals(listOf(7, 7, 7, 7, 7), grid().weeks.map { it.size }) + } + + @Test + fun buildMonthGrid_leavesBlanksBeforeTheFirst_accordingToFirstDayOfWeek() { + val mondayFirst = grid().weeks.first() + assertEquals(listOf(null, null, null), mondayFirst.take(3)) + assertEquals(october.atDay(1), mondayFirst[3]?.date) + + val sundayFirst = grid(firstDayOfWeek = DayOfWeek.SUNDAY).weeks.first() + assertEquals(4, sundayFirst.indexOfFirst { it != null }) + } + + @Test + fun buildMonthGrid_startsRightAway_whenTheFirstIsTheFirstWeekday() { + // 2026-06-01 is a Monday. + val june = grid(month = YearMonth.of(2026, 6)) + assertEquals(LocalDate.of(2026, 6, 1), june.weeks.first().first()?.date) + } + + @Test + fun buildMonthGrid_containsEveryDayOnce_inOrder() { + val dates = grid().weeks.flatten().filterNotNull().map { it.date } + + assertEquals((1..31).map { october.atDay(it) }, dates) + } + + @Test + fun buildMonthGrid_leavesBlanksAfterTheLastDay() { + // 31 October is a Saturday, so with Monday first the last row has one blank (Sunday). + val lastWeek = grid().weeks.last() + assertEquals(october.atEndOfMonth(), lastWeek[5]?.date) + assertNull(lastWeek[6]) + } + + @Test + fun buildMonthGrid_needsFourWeeks_forAFebruaryThatFitsExactly() { + // 2027-02 has 28 days and starts on a Monday. + assertEquals(4, grid(month = YearMonth.of(2027, 2)).weeks.size) + } + + @Test + fun buildMonthGrid_marksTodayAndTheFuture() { + val days = grid().weeks.flatten().filterNotNull().associateBy { it.date.dayOfMonth } + + assertEquals(true, days.getValue(7).isToday) + assertEquals(false, days.getValue(6).isToday) + assertEquals(false, days.getValue(7).isFuture) + assertEquals(true, days.getValue(8).isFuture) + assertEquals(false, days.getValue(6).isFuture) + } + + @Test + fun buildMonthGrid_assignsCountAndLevel() { + val day = LocalDate.of(2026, 10, 5) + val days = grid(mapOf(day to 2)).weeks.flatten().filterNotNull().associateBy { it.date } + + assertEquals(2, days.getValue(day).count) + assertEquals(2, days.getValue(day).level) + assertEquals(0, days.getValue(day.plusDays(1)).level) + } + + @Test + fun buildMonthGrid_scalesAgainstTheGivenMaxCount() { + val day = LocalDate.of(2026, 10, 5) + val grid = buildMonthGrid(october, mapOf(day to 2), today, maxCount = 8) + + assertEquals(1, grid.weeks.flatten().filterNotNull().first { it.date == day }.level) + } + + @Test + fun monthGrid_visits_sumsTheMonthOnly() { + val counts = + mapOf( + LocalDate.of(2026, 10, 1) to 1, + LocalDate.of(2026, 10, 5) to 2, + LocalDate.of(2026, 9, 30) to 5, + ) + + assertEquals(3, grid(counts).visits) + assertEquals(0, grid().visits) + } + + @Test + fun monthsBetween_listsEveryMonthOldestFirst() { + val months = monthsBetween(YearMonth.of(2025, 11), YearMonth.of(2026, 2)) + + assertEquals( + listOf( + YearMonth.of(2025, 11), + YearMonth.of(2025, 12), + YearMonth.of(2026, 1), + YearMonth.of(2026, 2), + ), + months, + ) + } + + @Test + fun monthsBetween_returnsASingleMonth_whenBothAreEqual() { + assertEquals(listOf(october), monthsBetween(october, october)) + } + + @Test + fun monthsBetween_fallsBackToTheLastMonth_whenFirstIsAfterIt() { + assertEquals(listOf(october), monthsBetween(october.plusMonths(2), october)) + } +} From 150a3968b90490ae34799be8c5b970ceb8510f1e Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 00:47:49 +0200 Subject: [PATCH 05/19] Replace heatmap grid with an iPhone-style calendar A full-width month calendar with round days, month title, previous and next buttons and swiping between months, instead of a sideways-scrolling year grid that does not fit a phone. Co-Authored-By: Claude Sonnet 5.5 --- .../se/pooltrack/ui/history/HistoryScreen.kt | 15 +- .../ui/history/heatmap/HeatmapLegend.kt | 58 +++++ .../ui/history/heatmap/HistoryOverview.kt | 31 ++- .../ui/history/heatmap/MonthCalendar.kt | 199 ++++++++++++++++++ .../history/HistoryScreenRobolectricTest.kt | 23 +- 5 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index a3f716a..d88d878 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -47,7 +48,6 @@ import com.github.se.pooltrack.model.entry.timestamp import com.github.se.pooltrack.model.swim.formatSwimDistance import com.github.se.pooltrack.model.swim.formatSwimDuration import com.github.se.pooltrack.ui.history.heatmap.HistoryOverview -import com.github.se.pooltrack.ui.history.heatmap.buildHeatmap import com.github.se.pooltrack.ui.navigation.BottomNavigationMenu import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.Screen @@ -226,14 +226,7 @@ fun HistoryScreen( } else { val today = LocalDate.now(ZONE) val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } - val heatmap = - remember(entries, today) { - buildHeatmap( - countsByDay = allEntriesByDay.mapValues { it.value.size }, - today = today, - firstDayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, - ) - } + val countsByDay = remember(allEntriesByDay) { allEntriesByDay.mapValues { it.value.size } } val entriesByDay = selectedDate?.let { day -> allEntriesByDay.filterKeys { it == day } } ?: allEntriesByDay @@ -246,7 +239,9 @@ fun HistoryScreen( ) { item(key = "overview") { HistoryOverview( - heatmap = heatmap, + countsByDay = countsByDay, + today = today, + firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek, selectedDate = selectedDate, onDayClick = { day -> selectedDate = if (day == selectedDate) null else day }, onClearSelection = { selectedDate = null }, diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt new file mode 100644 index 0000000..bda986d --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt @@ -0,0 +1,58 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** The fill of a day at [level], from no entry (0) to the busiest days ([MAX_LEVEL]). */ +@Composable +fun heatmapColor(level: Int): Color { + val scheme = MaterialTheme.colorScheme + return when (level) { + 0 -> Color.Transparent + 1 -> scheme.primary.copy(alpha = 0.3f) + 2 -> scheme.primary.copy(alpha = 0.5f) + 3 -> scheme.primary.copy(alpha = 0.75f) + else -> scheme.primary + } +} + +/** The "Less, five dots, More" key explaining the colors. */ +@Composable +fun HeatmapLegend(modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Less", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + (0..MAX_LEVEL).forEach { level -> + Box( + modifier = + Modifier.padding(start = 4.dp) + .size(12.dp) + .background( + if (level == 0) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else heatmapColor(level), + CircleShape, + ) + ) + } + Text( + text = "More", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt index 1d05bbd..ba87c87 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -10,12 +10,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import java.time.DayOfWeek import java.time.LocalDate +import java.time.YearMonth object HistoryOverviewTestTags { const val CARD = "HistoryOverviewCard" @@ -38,10 +41,12 @@ fun streakText(summary: HeatmapSummary): String? = } /** - * The card on top of the History list: how much the user swam over the last year as a heatmap, a - * one-line summary, and, once a day is picked, a bar to clear that filter. + * The card on top of the History list: how much the user swam over the last year in a month + * calendar, a one-line summary, and, once a day is picked, a bar to clear that filter. * - * @param heatmap The grid, see [buildHeatmap]. + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day. + * @param firstDayOfWeek The weekday the calendar columns start on. * @param selectedDate The day the list is filtered to, if any. * @param onDayClick Called when a day is tapped; the caller toggles the filter. * @param onClearSelection Called to show every day again. @@ -49,14 +54,23 @@ fun streakText(summary: HeatmapSummary): String? = */ @Composable fun HistoryOverview( - heatmap: Heatmap, + countsByDay: Map, + today: LocalDate, + firstDayOfWeek: DayOfWeek, selectedDate: LocalDate?, onDayClick: (LocalDate) -> Unit, onClearSelection: () -> Unit, modifier: Modifier = Modifier, selectedLabel: (LocalDate) -> String = { it.toString() }, ) { - val summary = summarize(heatmap) + val summary = + remember(countsByDay, today, firstDayOfWeek) { + summarize(buildHeatmap(countsByDay, today, firstDayOfWeek = firstDayOfWeek)) + } + val months = + remember(countsByDay, today) { + monthsBetween(YearMonth.from(countsByDay.keys.minOrNull() ?: today), YearMonth.from(today)) + } val activeDays = "${summary.activeDays} active ${if (summary.activeDays == 1) "day" else "days"}" Card(modifier = modifier.fillMaxWidth().testTag(HistoryOverviewTestTags.CARD)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -72,8 +86,11 @@ fun HistoryOverview( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - ContributionHeatmap( - heatmap = heatmap, + MonthCalendar( + months = months, + countsByDay = countsByDay, + today = today, + firstDayOfWeek = firstDayOfWeek, selectedDate = selectedDate, onDayClick = onDayClick, ) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt new file mode 100644 index 0000000..c1707c8 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -0,0 +1,199 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.launch + +object MonthCalendarTestTags { + const val CALENDAR = "MonthCalendar" + const val TITLE = "MonthCalendarTitle" + const val PREVIOUS = "MonthCalendarPrevious" + const val NEXT = "MonthCalendarNext" + + fun day(date: LocalDate) = "MonthCalendarDay_$date" +} + +/** A calendar page always has this many rows, so swiping never changes the height. */ +private const val ROWS = 6 + +/** + * A month calendar in the spirit of the iPhone's: a title with previous/next buttons, a row of + * weekday initials and a grid of round days that fills the width. Days with entries are filled, + * darker for more entries. Swipe sideways or use the buttons to change month. + * + * @param months The months that can be shown, oldest first; it opens on the last one. + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day, marked with a ring; later days can't be selected. + * @param selectedDate The highlighted day, if any. + * @param onDayClick Called with the tapped day. + * @param firstDayOfWeek The weekday the columns start on, usually the locale's. + */ +@Composable +fun MonthCalendar( + months: List, + countsByDay: Map, + today: LocalDate, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + locale: Locale = Locale.getDefault(), +) { + require(months.isNotEmpty()) { "months must not be empty" } + val pagerState = rememberPagerState(initialPage = months.lastIndex) { months.size } + val scope = rememberCoroutineScope() + val maxCount = remember(countsByDay) { countsByDay.values.maxOrNull() ?: 0 } + val current = months[pagerState.currentPage] + + Column(modifier = modifier.fillMaxWidth().testTag(MonthCalendarTestTags.CALENDAR)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = + "${current.month.getDisplayName(TextStyle.FULL_STANDALONE, locale)} ${current.year}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(MonthCalendarTestTags.TITLE), + ) + val visits = countsByDay.filterKeys { YearMonth.from(it) == current }.values.sum() + Text( + text = + when (visits) { + 0 -> "No visits" + 1 -> "1 visit" + else -> "$visits visits" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, + enabled = pagerState.currentPage > 0, + modifier = Modifier.testTag(MonthCalendarTestTags.PREVIOUS), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous month") + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, + enabled = pagerState.currentPage < months.lastIndex, + modifier = Modifier.testTag(MonthCalendarTestTags.NEXT), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next month") + } + } + + Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + (0 until 7).forEach { offset -> + Text( + text = firstDayOfWeek.plus(offset.toLong()).getDisplayName(TextStyle.NARROW, locale), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxWidth()) { page -> + val grid = + remember(months[page], countsByDay, today, firstDayOfWeek, maxCount) { + buildMonthGrid(months[page], countsByDay, today, firstDayOfWeek, maxCount) + } + Column(modifier = Modifier.fillMaxWidth()) { + (0 until ROWS).forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + val week = grid.weeks.getOrNull(row) + (0 until 7).forEach { column -> + val day = week?.get(column) + Box(modifier = Modifier.weight(1f).aspectRatio(1f).padding(3.dp)) { + if (day != null) { + DayCell(day, selected = day.date == selectedDate) { onDayClick(day.date) } + } + } + } + } + } + } + } + } +} + +@Composable +private fun DayCell(day: MonthDay, selected: Boolean, onClick: () -> Unit) { + val scheme = MaterialTheme.colorScheme + val textColor = + when { + day.isFuture -> scheme.onSurface.copy(alpha = 0.3f) + day.level >= 3 -> scheme.onPrimary + else -> scheme.onSurface + } + val ring = + when { + selected -> BorderStroke(2.dp, scheme.onSurface) + day.isToday -> BorderStroke(1.5.dp, scheme.primary) + else -> null + } + val description = + when (day.count) { + 0 -> "${day.date}: no entries" + 1 -> "${day.date}: 1 entry" + else -> "${day.date}: ${day.count} entries" + } + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.fillMaxWidth() + .aspectRatio(1f) + .background(heatmapColor(day.level), CircleShape) + .then(if (ring != null) Modifier.border(ring, CircleShape) else Modifier) + .then(if (day.isFuture) Modifier else Modifier.clickable(onClick = onClick)) + .semantics { contentDescription = description } + .testTag(MonthCalendarTestTags.day(day.date)), + ) { + Text( + text = day.date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal, + color = textColor, + ) + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index 9978066..1b75ea0 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -14,8 +14,8 @@ import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.Subscription import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider -import com.github.se.pooltrack.ui.history.heatmap.ContributionHeatmapTestTags import com.github.se.pooltrack.ui.history.heatmap.HistoryOverviewTestTags +import com.github.se.pooltrack.ui.history.heatmap.MonthCalendarTestTags import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.NavigationTestTags import com.github.se.pooltrack.ui.navigation.Screen @@ -26,6 +26,7 @@ import io.mockk.mockk import io.mockk.verify import java.time.Instant import java.time.LocalDate +import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -360,13 +361,22 @@ class HistoryScreenRobolectricTest { composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.LINK_BANNER).assertCountEquals(0) } + /** Taps [date] in the calendar, first going back a month if it isn't in the current one. */ + private fun tapDay(date: LocalDate) { + if (YearMonth.from(date) != YearMonth.from(today)) { + composeTestRule.onNodeWithTag(MonthCalendarTestTags.PREVIOUS).performClick() + composeTestRule.waitForIdle() + } + composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(date)).performClick() + } + @Test fun historyScreen_showsTheHeatmapOverview_whenEntriesExist() { show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(2))))) composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CARD).assertIsDisplayed() composeTestRule.onNodeWithText("2 visits in the last year").assertIsDisplayed() - composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(today)).assertExists() + composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(today)).assertExists() } @Test @@ -381,7 +391,7 @@ class HistoryScreenRobolectricTest { val older = today.minusDays(3) show(FakeEntryRepository(listOf(entryAt(today), entryAt(older)))) - composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(older)).performClick() + tapDay(older) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(1) @@ -394,7 +404,7 @@ class HistoryScreenRobolectricTest { @Test fun historyScreen_tappingTheSelectedDayAgain_showsEveryDay() { show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(3))))) - val cell = composeTestRule.onNodeWithTag(ContributionHeatmapTestTags.cell(today)) + val cell = composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(today)) cell.performClick() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) @@ -406,10 +416,7 @@ class HistoryScreenRobolectricTest { @Test fun historyScreen_saysSoWhenTheTappedDayHasNoEntries() { show(FakeEntryRepository(listOf(entryAt(today.minusDays(5))))) - - composeTestRule - .onNodeWithTag(ContributionHeatmapTestTags.cell(today.minusDays(1))) - .performClick() + tapDay(today.minusDays(1)) composeTestRule.onNodeWithTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY).assertIsDisplayed() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(0) From 8164d2ca6d21e3899a3a18bd7526d2a1acff629e Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 11:46:31 +0200 Subject: [PATCH 06/19] Add restoring a deleted entry to history Lets the UI undo a delete with the entry's swim data intact. Co-Authored-By: Claude Sonnet 5.5 --- .../se/pooltrack/ui/history/HistoryViewModel.kt | 5 +++++ .../se/pooltrack/ui/history/HistoryViewModelTest.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt index 6bec86a..f7d955b 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt @@ -96,6 +96,11 @@ class HistoryViewModel( viewModelScope.launch { repository.deleteEntry(entry) } } + /** Puts back [entry] exactly as it was, to undo [onDeleteEntry]. */ + fun onRestoreEntry(entry: Entry) { + viewModelScope.launch { repository.addEntry(entry) } + } + /** * Records an entry that happened at [timestamp], e.g. one that was forgotten at the time. It is * refused if [timestamp] is after [now] or an entry already exists at that exact instant (see diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt index 289dfe4..4bd9cb1 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt @@ -58,6 +58,18 @@ class HistoryViewModelTest { assertEquals(listOf(older), viewModel.entries.value) } + @Test + fun onRestoreEntry_putsBackADeletedEntryWithAllItsData() { + val detailed = newer.copy(swimDistanceMeters = 1200, swimDurationMillis = 3_000_000L) + val repository = FakeEntryRepository(listOf(older, detailed)) + val viewModel = HistoryViewModel(repository, FakeSubscriptionRepository()) + viewModel.onDeleteEntry(detailed) + + viewModel.onRestoreEntry(detailed) + + assertEquals(listOf(older, detailed), repository.storedEntries) + } + private val now = Instant.parse("2026-10-06T12:00:00Z") private val past = Instant.parse("2025-09-15T15:49:34Z") From 5cecbab2e1acfeb7a04a9a5a042a347e936fe72c Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 11:46:45 +0200 Subject: [PATCH 07/19] Redesign the history list Group entries under sticky month headers with visit counts, use flat compact rows, and delete by swiping left with an Undo snackbar instead of a red button and a confirmation dialog on every row. Screen readers get a Delete entry action in place of the swipe. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/history/HistoryListItems.kt | 183 ++++++++++++++++++ .../se/pooltrack/ui/history/HistoryScreen.kt | 176 ++++------------- .../ui/history/HistoryListItemsTest.kt | 29 +++ .../history/HistoryScreenRobolectricTest.kt | 75 ++++--- 4 files changed, 305 insertions(+), 158 deletions(-) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt new file mode 100644 index 0000000..15f0850 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -0,0 +1,183 @@ +package com.github.se.pooltrack.ui.history + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale + +/** "October" for a month of the current year, "October 2025" for any other. */ +internal fun monthLabel( + month: YearMonth, + thisMonth: YearMonth, + locale: Locale = Locale.getDefault(), +): String { + val name = month.month.getDisplayName(TextStyle.FULL_STANDALONE, locale) + return if (month.year == thisMonth.year) name else "$name ${month.year}" +} + +/** "5 visits" (or "1 visit"). */ +internal fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" + +/** The sticky title above all the entries of one month, with how many there were. */ +@Composable +internal fun MonthHeader(label: String, visits: Int, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.MONTH_HEADER), + color = MaterialTheme.colorScheme.background, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = visitsLabel(visits), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** A light label above the entries of one day; the count only shows when there are several. */ +@Composable +internal fun DayHeader(label: String, entryCount: Int, modifier: Modifier = Modifier) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 2.dp) + .testTag(HistoryScreenTestTags.DAY_HEADER), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + if (entryCount > 1) { + Text( + text = "$entryCount entries", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * A flat, compact row for one entry. Tap it to open the entry; swipe it to the left to delete it + * (the caller offers an Undo). Screen readers get a "Delete entry" action instead of the swipe. + * + * @param label The text of the row, see [entryRowLabel]. + * @param onClick Called when the row is tapped. + * @param onDelete Called once the row was swiped away. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SwipeableEntryRow( + label: String, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val dismissState = + rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onDelete() + true + } else false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + modifier = + modifier.testTag(HistoryScreenTestTags.ENTRY_ITEM).semantics { + customActions = + listOf( + CustomAccessibilityAction("Delete entry") { + onDelete() + true + } + ) + }, + backgroundContent = { + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.padding(end = 20.dp), + ) + } + }, + ) { + Surface(onClick = onClick, color = MaterialTheme.colorScheme.background) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index d88d878..7ef701e 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -12,20 +12,15 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.DateRange -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -40,7 +35,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.github.se.pooltrack.model.entry.Entry @@ -56,9 +50,9 @@ import com.github.se.pooltrack.ui.navigation.TopNavigationMenu import java.time.Duration import java.time.LocalDate import java.time.LocalTime +import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle import java.time.temporal.WeekFields import java.util.Locale @@ -68,10 +62,8 @@ object HistoryScreenTestTags { const val EMPTY_MESSAGE = "HistoryScreenEmptyMessage" const val ENTRY_LIST = "HistoryScreenEntryList" const val DAY_HEADER = "HistoryScreenDayHeader" + const val MONTH_HEADER = "HistoryScreenMonthHeader" const val ENTRY_ITEM = "HistoryScreenEntryItem" - const val DELETE_BUTTON = "HistoryScreenDeleteButton" - const val CONFIRM_DELETE_BUTTON = "HistoryScreenConfirmDeleteButton" - const val CANCEL_DELETE_BUTTON = "HistoryScreenCancelDeleteButton" const val ADD_PAST_ENTRY_BUTTON = "HistoryScreenAddPastEntryButton" const val UNDO_ADD_ACTION = "Undo" const val LINK_BANNER = "HistoryScreenLinkBanner" @@ -88,13 +80,6 @@ private fun entryTimeFormatter(): DateTimeFormatter = .withLocale(Locale.getDefault()) .withZone(ZONE) -private fun entryDateTimeFormatter(): DateTimeFormatter = - DateTimeFormatterBuilder() - .appendPattern("EEEE, ") - .append(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) - .toFormatter(Locale.getDefault()) - .withZone(ZONE) - /** HistoryScreen lists every confirmed pool entry, grouped by day, most recent first. */ @Composable fun HistoryScreen( @@ -102,7 +87,6 @@ fun HistoryScreen( navigationActions: NavigationActions? = null, ) { val entries by viewModel.entries.collectAsState() - var entryPendingDeletion by remember { mutableStateOf(null) } var showAddSheet by remember { mutableStateOf(false) } var selectedDate by remember { mutableStateOf(null) } // Remembered across openings, so adding several entries from the same day takes one tap each. @@ -131,6 +115,21 @@ fun HistoryScreen( } } + // Deleting needs no confirmation: the swipe is deliberate and the snackbar offers an Undo. + val onDeleteEntry: (Entry) -> Unit = { entry -> + viewModel.onDeleteEntry(entry) + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val outcome = + snackbarHostState.showSnackbar( + message = "Entry deleted", + actionLabel = HistoryScreenTestTags.UNDO_ADD_ACTION, + withDismissAction = true, + ) + if (outcome == SnackbarResult.ActionPerformed) viewModel.onRestoreEntry(entry) + } + } + if (showAddSheet) { AddPastEntrySheet( initialDate = lastAddedDate, @@ -149,38 +148,6 @@ fun HistoryScreen( ) } - entryPendingDeletion?.let { entry -> - AlertDialog( - onDismissRequest = { entryPendingDeletion = null }, - title = { Text("Delete this entry?") }, - text = { - Text( - "The entry from ${entryDateTimeFormatter().format(entry.timestamp)} will be " + - "permanently removed." - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.onDeleteEntry(entry) - entryPendingDeletion = null - }, - modifier = Modifier.testTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON), - ) { - Text("Delete") - } - }, - dismissButton = { - TextButton( - onClick = { entryPendingDeletion = null }, - modifier = Modifier.testTag(HistoryScreenTestTags.CANCEL_DELETE_BUTTON), - ) { - Text("Cancel") - } - }, - ) - } - Scaffold( topBar = { TopNavigationMenu(Screen.History) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -285,19 +252,30 @@ fun HistoryScreen( ) } } - entriesByDay.forEach { (day, entriesForDay) -> - item(key = day.toEpochDay()) { - DayHeader(label = dayLabel(day, today), entryCount = entriesForDay.size) - } - items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> - EntryRow( - entry = entry, - onClick = { navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) }, - onDelete = { entryPendingDeletion = entry }, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - } + entriesByDay.entries + .groupBy { YearMonth.from(it.key) } + .forEach { (month, days) -> + stickyHeader(key = "month-$month") { + MonthHeader( + label = monthLabel(month, YearMonth.from(today)), + visits = days.sumOf { it.value.size }, + ) + } + days.forEach { (day, entriesForDay) -> + item(key = day.toEpochDay()) { + DayHeader(label = dayLabel(day, today), entryCount = entriesForDay.size) + } + items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> + SwipeableEntryRow( + label = entryRowLabel(entry), + onClick = { + navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) + }, + onDelete = { onDeleteEntry(entry) }, + ) + } + } + } } } } @@ -329,34 +307,8 @@ private fun LinkBanner(count: Int, onLink: () -> Unit, modifier: Modifier = Modi } } -/** Section header grouping entries from the same calendar day. */ -@Composable -private fun DayHeader(label: String, entryCount: Int) { - Surface( - modifier = Modifier.fillMaxWidth().testTag(HistoryScreenTestTags.DAY_HEADER), - color = MaterialTheme.colorScheme.background, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = if (entryCount == 1) "1 entry" else "$entryCount entries", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - /** "Today", "Yesterday", or a weekday/date, omitting the year unless [day] isn't this year. */ -private fun dayLabel(day: LocalDate, today: LocalDate): String = +internal fun dayLabel(day: LocalDate, today: LocalDate): String = when (day) { today -> "Today" today.minusDays(1) -> "Yesterday" @@ -370,52 +322,10 @@ private fun dayLabel(day: LocalDate, today: LocalDate): String = * The entry time, followed by the swim duration (old entries) and the logged distance, whichever * were recorded: "18:42 · 52min · 1200 m". */ -private fun entryRowLabel(entry: Entry): String = +internal fun entryRowLabel(entry: Entry): String = listOfNotNull( entryTimeFormatter().format(entry.timestamp), entry.swimDurationMillis?.let { formatSwimDuration(Duration.ofMillis(it)) }, entry.swimDistanceMeters?.let { formatSwimDistance(it) }, ) .joinToString(" · ") - -@Composable -private fun EntryRow( - entry: Entry, - onClick: () -> Unit, - onDelete: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - onClick = onClick, - modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.ENTRY_ITEM), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = entryRowLabel(entry), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), - ) - } - IconButton( - onClick = onDelete, - modifier = Modifier.testTag(HistoryScreenTestTags.DELETE_BUTTON), - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete entry", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } -} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt new file mode 100644 index 0000000..666b12a --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt @@ -0,0 +1,29 @@ +package com.github.se.pooltrack.ui.history + +import java.time.YearMonth +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryListItemsTest { + + private val thisMonth = YearMonth.of(2026, 10) + + @Test + fun monthLabel_omitsTheYear_forTheCurrentYear() { + assertEquals("March", monthLabel(YearMonth.of(2026, 3), thisMonth, Locale.ENGLISH)) + assertEquals("October", monthLabel(thisMonth, thisMonth, Locale.ENGLISH)) + } + + @Test + fun monthLabel_includesTheYear_forOtherYears() { + assertEquals("December 2025", monthLabel(YearMonth.of(2025, 12), thisMonth, Locale.ENGLISH)) + } + + @Test + fun visitsLabel_isSingularOnlyForOne() { + assertEquals("1 visit", visitsLabel(1)) + assertEquals("2 visits", visitsLabel(2)) + assertEquals("0 visits", visitsLabel(0)) + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index 1b75ea0..d5a6f87 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -2,14 +2,17 @@ package com.github.se.pooltrack.ui.history import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.Subscription @@ -91,7 +94,6 @@ class HistoryScreenRobolectricTest { composeTestRule.onNodeWithText("Today").assertIsDisplayed() composeTestRule.onNodeWithText("Yesterday").assertIsDisplayed() composeTestRule.onNodeWithText("2 entries").assertIsDisplayed() - composeTestRule.onNodeWithText("1 entry").assertIsDisplayed() composeTestRule.onNodeWithText(timeLabel(todayMorning)).assertIsDisplayed() composeTestRule.onNodeWithText(timeLabel(todayEvening)).assertIsDisplayed() composeTestRule.onNodeWithText(timeLabel(yesterdayEntry)).assertIsDisplayed() @@ -117,33 +119,27 @@ class HistoryScreenRobolectricTest { composeTestRule.onNodeWithText(expected).assertIsDisplayed() } - @Test - fun historyScreen_showsConfirmationDialog_whenDeleteTapped() { - val entry = entryAt(today) - val repository = FakeEntryRepository(listOf(entry)) - show(repository) - - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - - composeTestRule.onNodeWithText("Delete this entry?").assertIsDisplayed() - composeTestRule.onNode(hasText("permanently removed.", substring = true)).assertIsDisplayed() - assertEquals(listOf(entry), repository.storedEntries) + private fun swipeAwayFirstEntry() { + composeTestRule + .onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM) + .onFirst() + .performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() } @Test - fun historyScreen_deletesOnlyThatEntry_whenDeleteConfirmed() { + fun historyScreen_deletesOnlyThatEntry_whenSwipedAway() { val keep = entryAt(today, hour = 8) val remove = entryAt(today, hour = 18) val repository = FakeEntryRepository(listOf(keep, remove)) show(repository) - // Most recent first, so the first delete button belongs to the 18:00 entry. - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DELETE_BUTTON).onFirst().performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON).performClick() + // Most recent first, so the first row is the 18:00 entry. + swipeAwayFirstEntry() assertEquals(listOf(keep), repository.storedEntries) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - composeTestRule.onAllNodesWithText("Delete this entry?").assertCountEquals(0) + composeTestRule.onNodeWithText("Entry deleted").assertIsDisplayed() } @Test @@ -151,27 +147,56 @@ class HistoryScreenRobolectricTest { val repository = FakeEntryRepository(listOf(entryAt(today))) show(repository) - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON).performClick() + swipeAwayFirstEntry() assertEquals(emptyList(), repository.storedEntries) composeTestRule.onNodeWithTag(HistoryScreenTestTags.EMPTY_MESSAGE).assertIsDisplayed() } @Test - fun historyScreen_keepsEntry_whenDeleteCancelled() { - val entry = entryAt(today) + fun historyScreen_restoresTheEntryExactly_whenDeleteUndone() { + val entry = entryAt(today).copy(swimDistanceMeters = 900, swimDurationMillis = 60_000L) val repository = FakeEntryRepository(listOf(entry)) show(repository) + swipeAwayFirstEntry() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CANCEL_DELETE_BUTTON).performClick() + composeTestRule.onNodeWithText(HistoryScreenTestTags.UNDO_ADD_ACTION).performClick() + composeTestRule.waitForIdle() assertEquals(listOf(entry), repository.storedEntries) - composeTestRule.onAllNodesWithText("Delete this entry?").assertCountEquals(0) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) } + @Test + fun historyScreen_doesNotDelete_whenTheRowIsOnlyTapped() { + val entry = entryAt(today) + val repository = FakeEntryRepository(listOf(entry)) + show(repository) + + composeTestRule.onNodeWithTag(HistoryScreenTestTags.ENTRY_ITEM).performClick() + + assertEquals(listOf(entry), repository.storedEntries) + } + + @Test + fun historyScreen_titlesEachMonth_withItsVisitCount() { + val thisMonthEntry = entryAt(today) + val lastYearEntries = listOf(entryAt(today.minusYears(1)), entryAt(today.minusYears(1), 9)) + show(FakeEntryRepository(listOf(thisMonthEntry) + lastYearEntries)) + + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.MONTH_HEADER).assertCountEquals(2) + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.MONTH_HEADER) and hasAnyDescendant(hasText("1 visit")) + ) + .assertExists() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.MONTH_HEADER) and hasAnyDescendant(hasText("2 visits")) + ) + .assertExists() + } + @Test fun historyScreen_showsNewEntry_whenRepositoryChangesAfterComposition() { val repository = FakeEntryRepository() From a630034511d931ab5289aef4830498e6429eae15 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 16:52:05 +0200 Subject: [PATCH 08/19] Group the history list by week Entries sit under sticky week headers (This week, Last week, then the dates) with a visit count, and each row carries its own date block instead of a per-day header, since there is about one visit a day. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/history/HistoryListItems.kt | 122 +++++++++++------- .../se/pooltrack/ui/history/HistoryScreen.kt | 22 ++-- .../ui/history/HistoryListItemsTest.kt | 50 ++++++- .../history/HistoryScreenRobolectricTest.kt | 72 ++++------- 4 files changed, 153 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt index 15f0850..3cfc3f7 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -30,29 +30,56 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import java.time.YearMonth -import java.time.format.TextStyle +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters import java.util.Locale -/** "October" for a month of the current year, "October 2025" for any other. */ -internal fun monthLabel( - month: YearMonth, - thisMonth: YearMonth, +/** "5 visits" (or "1 visit"). */ +internal fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" + +/** The first day of the week that contains [day], where weeks start on [firstDayOfWeek]. */ +internal fun weekStart(day: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = + day.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + +/** + * Names the week starting on [weekStart]: "This week", "Last week", or its dates, e.g. "Oct 5 – 11" + * or "Sep 28 – Oct 4". The year is added when the week isn't in [today]'s year (and both years when + * it spans New Year's Eve). + */ +internal fun weekLabel( + weekStart: LocalDate, + today: LocalDate, + firstDayOfWeek: DayOfWeek, locale: Locale = Locale.getDefault(), ): String { - val name = month.month.getDisplayName(TextStyle.FULL_STANDALONE, locale) - return if (month.year == thisMonth.year) name else "$name ${month.year}" -} + val thisWeek = weekStart(today, firstDayOfWeek) + if (weekStart == thisWeek) return "This week" + if (weekStart == thisWeek.minusWeeks(1)) return "Last week" -/** "5 visits" (or "1 visit"). */ -internal fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" + val end = weekStart.plusDays(6) + fun format(date: LocalDate, pattern: String) = + date.format(DateTimeFormatter.ofPattern(pattern, locale)) + return when { + weekStart.year != end.year -> + "${format(weekStart, "MMM d, yyyy")} – ${format(end, "MMM d, yyyy")}" + else -> { + val range = + if (weekStart.month == end.month) "${format(weekStart, "MMM d")} – ${end.dayOfMonth}" + else "${format(weekStart, "MMM d")} – ${format(end, "MMM d")}" + if (weekStart.year == today.year) range else "$range, ${weekStart.year}" + } + } +} -/** The sticky title above all the entries of one month, with how many there were. */ +/** The sticky title above the entries of one week, with how many visits it had. */ @Composable -internal fun MonthHeader(label: String, visits: Int, modifier: Modifier = Modifier) { +internal fun WeekHeader(label: String, visits: Int, modifier: Modifier = Modifier) { Surface( - modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.MONTH_HEADER), + modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.WEEK_HEADER), color = MaterialTheme.colorScheme.background, ) { Row( @@ -62,7 +89,7 @@ internal fun MonthHeader(label: String, visits: Int, modifier: Modifier = Modifi ) { Text( text = label, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) Text( @@ -74,37 +101,43 @@ internal fun MonthHeader(label: String, visits: Int, modifier: Modifier = Modifi } } -/** A light label above the entries of one day; the count only shows when there are several. */ +/** The weekday and day of the month, stacked ("THU" over "8"); today's is tinted. */ @Composable -internal fun DayHeader(label: String, entryCount: Int, modifier: Modifier = Modifier) { - Row( +private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale.getDefault()) { + val scheme = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = - modifier - .fillMaxWidth() - .padding(top = 12.dp, bottom = 2.dp) - .testTag(HistoryScreenTestTags.DAY_HEADER), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Modifier.width(48.dp) + .background( + if (isToday) scheme.primaryContainer + else scheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(10.dp), + ) + .padding(vertical = 6.dp), ) { Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, + text = day.format(DateTimeFormatter.ofPattern("EEE", locale)).uppercase(locale), + style = MaterialTheme.typography.labelSmall, + color = if (isToday) scheme.onPrimaryContainer else scheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = day.dayOfMonth.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isToday) scheme.onPrimaryContainer else scheme.onSurface, ) - if (entryCount > 1) { - Text( - text = "$entryCount entries", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } /** - * A flat, compact row for one entry. Tap it to open the entry; swipe it to the left to delete it - * (the caller offers an Undo). Screen readers get a "Delete entry" action instead of the swipe. + * A flat, compact row for one entry: the day it happened on, then its time and swim details. Tap it + * to open the entry; swipe it to the left to delete it (the caller offers an Undo). Screen readers + * get a "Delete entry" action instead of the swipe. * + * @param day The day of the entry, shown in the date block. + * @param isToday Whether [day] is today. * @param label The text of the row, see [entryRowLabel]. * @param onClick Called when the row is tapped. * @param onDelete Called once the row was swiped away. @@ -112,6 +145,8 @@ internal fun DayHeader(label: String, entryCount: Int, modifier: Modifier = Modi @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SwipeableEntryRow( + day: LocalDate, + isToday: Boolean, label: String, onClick: () -> Unit, onDelete: () -> Unit, @@ -156,19 +191,14 @@ internal fun SwipeableEntryRow( Surface(onClick = onClick, color = MaterialTheme.colorScheme.background) { Column { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp, horizontal = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) + DateBlock(day = day, isToday = isToday) Text( text = label, style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), ) Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index 7ef701e..0ae881e 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -50,7 +50,6 @@ import com.github.se.pooltrack.ui.navigation.TopNavigationMenu import java.time.Duration import java.time.LocalDate import java.time.LocalTime -import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -61,8 +60,7 @@ import kotlinx.coroutines.launch object HistoryScreenTestTags { const val EMPTY_MESSAGE = "HistoryScreenEmptyMessage" const val ENTRY_LIST = "HistoryScreenEntryList" - const val DAY_HEADER = "HistoryScreenDayHeader" - const val MONTH_HEADER = "HistoryScreenMonthHeader" + const val WEEK_HEADER = "HistoryScreenWeekHeader" const val ENTRY_ITEM = "HistoryScreenEntryItem" const val ADD_PAST_ENTRY_BUTTON = "HistoryScreenAddPastEntryButton" const val UNDO_ADD_ACTION = "Undo" @@ -192,6 +190,7 @@ fun HistoryScreen( } } else { val today = LocalDate.now(ZONE) + val firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } val countsByDay = remember(allEntriesByDay) { allEntriesByDay.mapValues { it.value.size } } val entriesByDay = @@ -208,7 +207,7 @@ fun HistoryScreen( HistoryOverview( countsByDay = countsByDay, today = today, - firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek, + firstDayOfWeek = firstDayOfWeek, selectedDate = selectedDate, onDayClick = { day -> selectedDate = if (day == selectedDate) null else day }, onClearSelection = { selectedDate = null }, @@ -253,20 +252,19 @@ fun HistoryScreen( } } entriesByDay.entries - .groupBy { YearMonth.from(it.key) } - .forEach { (month, days) -> - stickyHeader(key = "month-$month") { - MonthHeader( - label = monthLabel(month, YearMonth.from(today)), + .groupBy { weekStart(it.key, firstDayOfWeek) } + .forEach { (start, days) -> + stickyHeader(key = "week-$start") { + WeekHeader( + label = weekLabel(start, today, firstDayOfWeek), visits = days.sumOf { it.value.size }, ) } days.forEach { (day, entriesForDay) -> - item(key = day.toEpochDay()) { - DayHeader(label = dayLabel(day, today), entryCount = entriesForDay.size) - } items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> SwipeableEntryRow( + day = day, + isToday = day == today, label = entryRowLabel(entry), onClick = { navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt index 666b12a..dd118e3 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt @@ -1,23 +1,59 @@ package com.github.se.pooltrack.ui.history -import java.time.YearMonth +import java.time.DayOfWeek +import java.time.LocalDate import java.util.Locale import org.junit.Assert.assertEquals import org.junit.Test class HistoryListItemsTest { - private val thisMonth = YearMonth.of(2026, 10) + // Wednesday 2026-10-07; with Monday first, this week started on 2026-10-05. + private val today = LocalDate.of(2026, 10, 7) + private val monday = DayOfWeek.MONDAY + + private fun label(weekStart: LocalDate, first: DayOfWeek = monday, now: LocalDate = today) = + weekLabel(weekStart, now, first, Locale.ENGLISH) + + @Test + fun weekStart_isTheFirstDayOfWeekOnOrBefore() { + assertEquals(LocalDate.of(2026, 10, 5), weekStart(today, monday)) + assertEquals(LocalDate.of(2026, 10, 5), weekStart(LocalDate.of(2026, 10, 5), monday)) + assertEquals(LocalDate.of(2026, 10, 5), weekStart(LocalDate.of(2026, 10, 11), monday)) + assertEquals(LocalDate.of(2026, 10, 4), weekStart(today, DayOfWeek.SUNDAY)) + } + + @Test + fun weekLabel_namesThisWeekAndLastWeek() { + assertEquals("This week", label(LocalDate.of(2026, 10, 5))) + assertEquals("Last week", label(LocalDate.of(2026, 9, 28))) + } + + @Test + fun weekLabel_showsTheDayRange_withinAMonth() { + assertEquals("Sep 14 – 20", label(LocalDate.of(2026, 9, 14))) + } + + @Test + fun weekLabel_showsBothMonths_whenTheWeekCrossesOne() { + assertEquals("Aug 31 – Sep 6", label(LocalDate.of(2026, 8, 31))) + } + + @Test + fun weekLabel_addsTheYear_forOtherYears() { + assertEquals("Oct 6 – 12, 2025", label(LocalDate.of(2025, 10, 6))) + } @Test - fun monthLabel_omitsTheYear_forTheCurrentYear() { - assertEquals("March", monthLabel(YearMonth.of(2026, 3), thisMonth, Locale.ENGLISH)) - assertEquals("October", monthLabel(thisMonth, thisMonth, Locale.ENGLISH)) + fun weekLabel_showsBothYears_whenTheWeekSpansNewYear() { + assertEquals("Dec 28, 2025 – Jan 3, 2026", label(LocalDate.of(2025, 12, 28), now = today)) } @Test - fun monthLabel_includesTheYear_forOtherYears() { - assertEquals("December 2025", monthLabel(YearMonth.of(2025, 12), thisMonth, Locale.ENGLISH)) + fun weekLabel_followsTheFirstDayOfWeek() { + // With Sunday first, "this week" began on Sunday 2026-10-04. + assertEquals("This week", label(LocalDate.of(2026, 10, 4), DayOfWeek.SUNDAY)) + assertEquals("Last week", label(LocalDate.of(2026, 9, 27), DayOfWeek.SUNDAY)) } @Test diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index d5a6f87..6157530 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -81,42 +81,37 @@ class HistoryScreenRobolectricTest { } @Test - fun historyScreen_showsEntriesGroupedByDay_whenEntriesRecorded() { - val yesterdayEntry = entryAt(today.minusDays(1), hour = 9) - val todayMorning = entryAt(today, hour = 8) - val todayEvening = entryAt(today, hour = 18) - show(FakeEntryRepository(listOf(yesterdayEntry, todayMorning, todayEvening))) + fun historyScreen_listsEntriesUnderWeekHeaders_withTheirVisitCounts() { + val thisWeekMorning = entryAt(today, hour = 8) + val thisWeekEvening = entryAt(today, hour = 18) + val older = entryAt(today.minusWeeks(3), hour = 9) + show(FakeEntryRepository(listOf(older, thisWeekMorning, thisWeekEvening))) composeTestRule.onNodeWithTag(HistoryScreenTestTags.ENTRY_LIST).assertIsDisplayed() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.EMPTY_MESSAGE).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(2) + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.WEEK_HEADER).assertCountEquals(2) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(3) - composeTestRule.onNodeWithText("Today").assertIsDisplayed() - composeTestRule.onNodeWithText("Yesterday").assertIsDisplayed() - composeTestRule.onNodeWithText("2 entries").assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(todayMorning)).assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(todayEvening)).assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(yesterdayEntry)).assertIsDisplayed() - } - - @Test - fun historyScreen_labelsOlderDaysWithoutYear_whenSameYear() { - val sameYearDay = listOf(today.minusDays(2), today.plusDays(2)).first { it.year == today.year } - show(FakeEntryRepository(listOf(entryAt(sameYearDay)))) - - val expected = - sameYearDay.format(DateTimeFormatter.ofPattern("EEEE, MMM d", Locale.getDefault())) - composeTestRule.onNodeWithText(expected).assertIsDisplayed() + composeTestRule.onNodeWithText("This week").assertIsDisplayed() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and hasAnyDescendant(hasText("2 visits")) + ) + .assertExists() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and hasAnyDescendant(hasText("1 visit")) + ) + .assertExists() + composeTestRule.onNodeWithText(timeLabel(thisWeekMorning)).assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(thisWeekEvening)).assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(older)).assertIsDisplayed() } @Test - fun historyScreen_labelsOlderDaysWithYear_whenDifferentYear() { - val lastYearDay = today.minusYears(1) - show(FakeEntryRepository(listOf(entryAt(lastYearDay)))) + fun historyScreen_namesTheWeekBeforeThisOne_lastWeek() { + show(FakeEntryRepository(listOf(entryAt(today.minusWeeks(1))))) - val expected = - lastYearDay.format(DateTimeFormatter.ofPattern("EEEE, MMM d, yyyy", Locale.getDefault())) - composeTestRule.onNodeWithText(expected).assertIsDisplayed() + composeTestRule.onNodeWithText("Last week").assertIsDisplayed() } private fun swipeAwayFirstEntry() { @@ -178,25 +173,6 @@ class HistoryScreenRobolectricTest { assertEquals(listOf(entry), repository.storedEntries) } - @Test - fun historyScreen_titlesEachMonth_withItsVisitCount() { - val thisMonthEntry = entryAt(today) - val lastYearEntries = listOf(entryAt(today.minusYears(1)), entryAt(today.minusYears(1), 9)) - show(FakeEntryRepository(listOf(thisMonthEntry) + lastYearEntries)) - - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.MONTH_HEADER).assertCountEquals(2) - composeTestRule - .onNode( - hasTestTag(HistoryScreenTestTags.MONTH_HEADER) and hasAnyDescendant(hasText("1 visit")) - ) - .assertExists() - composeTestRule - .onNode( - hasTestTag(HistoryScreenTestTags.MONTH_HEADER) and hasAnyDescendant(hasText("2 visits")) - ) - .assertExists() - } - @Test fun historyScreen_showsNewEntry_whenRepositoryChangesAfterComposition() { val repository = FakeEntryRepository() @@ -419,7 +395,7 @@ class HistoryScreenRobolectricTest { tapDay(older) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(1) + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.WEEK_HEADER).assertCountEquals(1) composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CLEAR_FILTER).performClick() From 67a6167de93705401ef64b82ea014228a311634e Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 13:59:11 +0200 Subject: [PATCH 09/19] Add year model for the zoomed-out calendar Twelve month tiles shaded by visits, the years to page through, and which month to open when zooming back in. The visits label moves next to it so both calendars share it. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/history/HistoryListItems.kt | 3 +- .../pooltrack/ui/history/heatmap/YearModel.kt | 63 ++++++++++++ .../ui/history/HistoryListItemsTest.kt | 1 + .../ui/history/heatmap/YearModelTest.kt | 99 +++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearModel.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/YearModelTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt index 3cfc3f7..97210e8 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -32,14 +32,13 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.github.se.pooltrack.ui.history.heatmap.visitsLabel import java.time.DayOfWeek import java.time.LocalDate import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters import java.util.Locale -/** "5 visits" (or "1 visit"). */ -internal fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" /** The first day of the week that contains [day], where weeks start on [firstDayOfWeek]. */ internal fun weekStart(day: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearModel.kt new file mode 100644 index 0000000..e06354f --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearModel.kt @@ -0,0 +1,63 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import java.time.LocalDate +import java.time.YearMonth + +/** + * Visits in a month that already count as the darkest tile of the year view. Going about three + * times a week is a full month, so a month with one visit a day is not drowned out by the scale. + */ +const val BUSY_MONTH_VISITS = 12 + +/** + * One tile of the year view. + * + * @property count Entries in the month. + * @property level The intensity step from 0 to [MAX_LEVEL]. + * @property isFuture Whether the month hasn't started yet. + */ +data class MonthTile(val month: YearMonth, val count: Int, val level: Int, val isFuture: Boolean) + +/** Sums [countsByDay] per month. */ +fun monthCounts(countsByDay: Map): Map = + countsByDay.entries.groupingBy { YearMonth.from(it.key) }.fold(0) { sum, e -> sum + e.value } + +/** + * The twelve tiles of [year], January first. + * + * @param monthCounts Entries per month, see [monthCounts]. + * @param maxCount The busiest month to scale against; pass the same value for every year so that + * colors mean the same thing from one year to the next. + */ +fun buildYearTiles( + year: Int, + monthCounts: Map, + today: LocalDate, + maxCount: Int = monthCounts.values.maxOrNull() ?: 0, +): List { + val thisMonth = YearMonth.from(today) + return (1..12).map { monthOfYear -> + val month = YearMonth.of(year, monthOfYear) + val count = monthCounts[month] ?: 0 + MonthTile( + month = month, + count = count, + level = intensityLevel(count, maxCount, scaleFloor = BUSY_MONTH_VISITS), + isFuture = month.isAfter(thisMonth), + ) + } +} + +/** Every year from [first] to [last], oldest first; just [last] if [first] is after it. */ +fun yearsBetween(first: Int, last: Int): List = + if (first > last) listOf(last) else (first..last).toList() + +/** "5 visits" (or "1 visit"). */ +fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" + +/** + * The month to open when zooming in from [year]: [current] if it is in that year, otherwise the + * latest of [months] in it (the year may have been swiped to meanwhile), or [current] if none is. + */ +fun monthToFocus(current: YearMonth, year: Int, months: List): YearMonth = + if (current.year == year) current else months.lastOrNull { it.year == year } ?: current diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt index dd118e3..cd16b75 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt @@ -1,5 +1,6 @@ package com.github.se.pooltrack.ui.history +import com.github.se.pooltrack.ui.history.heatmap.visitsLabel import java.time.DayOfWeek import java.time.LocalDate import java.util.Locale diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/YearModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/YearModelTest.kt new file mode 100644 index 0000000..41ec3ba --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/YearModelTest.kt @@ -0,0 +1,99 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import java.time.LocalDate +import java.time.YearMonth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class YearModelTest { + + private val today = LocalDate.of(2026, 10, 7) + + @Test + fun monthCounts_sumsDaysPerMonth() { + val counts = + mapOf( + LocalDate.of(2026, 10, 1) to 1, + LocalDate.of(2026, 10, 5) to 2, + LocalDate.of(2026, 9, 30) to 1, + ) + + assertEquals( + mapOf(YearMonth.of(2026, 10) to 3, YearMonth.of(2026, 9) to 1), + monthCounts(counts), + ) + } + + @Test + fun buildYearTiles_hasTwelveMonthsJanuaryFirst() { + val tiles = buildYearTiles(2026, emptyMap(), today) + + assertEquals((1..12).map { YearMonth.of(2026, it) }, tiles.map { it.month }) + } + + @Test + fun buildYearTiles_marksMonthsAfterTodayAsFuture() { + val tiles = buildYearTiles(2026, emptyMap(), today).associateBy { it.month.monthValue } + + assertFalse(tiles.getValue(10).isFuture) + assertTrue(tiles.getValue(11).isFuture) + assertFalse(buildYearTiles(2025, emptyMap(), today).any { it.isFuture }) + } + + @Test + fun buildYearTiles_shadesMonthsByTheirVisits() { + val counts = + mapOf(YearMonth.of(2026, 1) to 0, YearMonth.of(2026, 2) to 4, YearMonth.of(2026, 3) to 12) + + val tiles = buildYearTiles(2026, counts, today).associateBy { it.month.monthValue } + + assertEquals(0, tiles.getValue(1).level) + assertEquals(4, tiles.getValue(2).count) + assertEquals(MAX_LEVEL, tiles.getValue(3).level) + assertTrue(tiles.getValue(2).level in 1 until MAX_LEVEL) + } + + @Test + fun buildYearTiles_scalesAgainstTheBusiestMonthOnceItBeatsTheFloor() { + val counts = mapOf(YearMonth.of(2026, 1) to 30, YearMonth.of(2026, 2) to 12) + + val tiles = buildYearTiles(2026, counts, today).associateBy { it.month.monthValue } + + assertEquals(MAX_LEVEL, tiles.getValue(1).level) + assertTrue(tiles.getValue(2).level < MAX_LEVEL) + } + + @Test + fun yearsBetween_listsEveryYear() { + assertEquals(listOf(2024, 2025, 2026), yearsBetween(2024, 2026)) + assertEquals(listOf(2026), yearsBetween(2026, 2026)) + assertEquals(listOf(2026), yearsBetween(2027, 2026)) + } + + @Test + fun monthToFocus_keepsTheCurrentMonth_whenItIsInThatYear() { + val months = listOf(YearMonth.of(2025, 11), YearMonth.of(2025, 12), YearMonth.of(2026, 1)) + + assertEquals( + YearMonth.of(2026, 1), + monthToFocus(YearMonth.of(2026, 1), 2026, months), + ) + } + + @Test + fun monthToFocus_picksTheLatestMonthOfTheNewYear() { + val months = listOf(YearMonth.of(2025, 11), YearMonth.of(2025, 12), YearMonth.of(2026, 1)) + + assertEquals(YearMonth.of(2025, 12), monthToFocus(YearMonth.of(2026, 1), 2025, months)) + } + + @Test + fun monthToFocus_keepsTheCurrentMonth_whenTheYearHasNoMonths() { + assertEquals( + YearMonth.of(2026, 1), + monthToFocus(YearMonth.of(2026, 1), 2020, listOf(YearMonth.of(2026, 1))), + ) + } +} From 3648a6dfab816cfa2aa98f04bf60257227a516fd Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 13:59:14 +0200 Subject: [PATCH 10/19] Add a pinch gesture that leaves one finger alone Detects a two-finger pinch out or in without consuming single touches, so pager swipes and day taps keep working. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/PinchGesture.kt | 56 +++++++++++++++++++ .../ui/history/heatmap/PinchGestureTest.kt | 27 +++++++++ 2 files changed, 83 insertions(+) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/PinchGesture.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/PinchGestureTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/PinchGesture.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/PinchGesture.kt new file mode 100644 index 0000000..8b1450c --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/PinchGesture.kt @@ -0,0 +1,56 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput + +/** A pinch that shrinks the content by this factor (or more) counts as zooming out. */ +internal const val ZOOM_OUT_FACTOR = 0.8f + +/** A pinch that grows the content by this factor (or more) counts as zooming in. */ +internal const val ZOOM_IN_FACTOR = 1.25f + +/** What the total scale of a pinch means: [ZoomChange.Out], [ZoomChange.In] or neither. */ +internal enum class ZoomChange { + Out, + In, +} + +/** The [ZoomChange] for a pinch that scaled the content by [scale], or `null` if too small. */ +internal fun zoomChangeFor(scale: Float): ZoomChange? = + when { + scale <= ZOOM_OUT_FACTOR -> ZoomChange.Out + scale >= ZOOM_IN_FACTOR -> ZoomChange.In + else -> null + } + +/** + * Calls [onZoomOut] or [onZoomIn] once per two-finger pinch. One-finger touches are left alone, so + * swiping a pager or tapping a day inside keeps working. + */ +internal fun Modifier.pinchToZoom(onZoomOut: () -> Unit, onZoomIn: () -> Unit): Modifier = + pointerInput(onZoomOut, onZoomIn) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var scale = 1f + var handled = false + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.changes.count { it.pressed } >= 2 && !handled) { + scale *= event.calculateZoom() + when (zoomChangeFor(scale)) { + ZoomChange.Out -> onZoomOut() + ZoomChange.In -> onZoomIn() + null -> Unit + } + handled = zoomChangeFor(scale) != null + } + if (handled || event.changes.count { it.pressed } >= 2) { + event.changes.forEach { it.consume() } + } + } while (event.changes.any { it.pressed }) + } + } diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/PinchGestureTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/PinchGestureTest.kt new file mode 100644 index 0000000..f6daa1a --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/PinchGestureTest.kt @@ -0,0 +1,27 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PinchGestureTest { + + @Test + fun zoomChangeFor_isOut_whenThePinchShrinksEnough() { + assertEquals(ZoomChange.Out, zoomChangeFor(0.8f)) + assertEquals(ZoomChange.Out, zoomChangeFor(0.5f)) + } + + @Test + fun zoomChangeFor_isIn_whenThePinchGrowsEnough() { + assertEquals(ZoomChange.In, zoomChangeFor(1.25f)) + assertEquals(ZoomChange.In, zoomChangeFor(2f)) + } + + @Test + fun zoomChangeFor_isNull_forSmallMovements() { + assertNull(zoomChangeFor(1f)) + assertNull(zoomChangeFor(0.9f)) + assertNull(zoomChangeFor(1.2f)) + } +} From 6cb9b4b1362761f25bcf573a235175326fddb227 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 13:59:18 +0200 Subject: [PATCH 11/19] Zoom the history calendar out to the year Pinch out, or tap the year above the month, to see the year as twelve tiles shaded by how often you went. Tap a month or pinch in to return. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HistoryCalendar.kt | 88 ++++++++ .../ui/history/heatmap/HistoryOverview.kt | 2 +- .../ui/history/heatmap/MonthCalendar.kt | 31 ++- .../ui/history/heatmap/YearCalendar.kt | 193 ++++++++++++++++++ .../history/HistoryScreenRobolectricTest.kt | 27 +++ 5 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryCalendar.kt create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryCalendar.kt new file mode 100644 index 0000000..6787c46 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryCalendar.kt @@ -0,0 +1,88 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +/** How far the calendar is zoomed: one month of days, or a year of months. */ +enum class CalendarZoom { + Month, + Year, +} + +/** + * The calendar with two zoom levels. It opens on the current month; zoom out (pinch, or the "< + * year" button) for the year's months shaded by how often the user went, and tap a month, or pinch + * in, to come back. + * + * @param months The months that can be shown, oldest first. + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day. + * @param selectedDate The highlighted day, if any. + * @param onDayClick Called with the tapped day. + * @param firstDayOfWeek The weekday the month columns start on. + */ +@Composable +fun HistoryCalendar( + months: List, + countsByDay: Map, + today: LocalDate, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + firstDayOfWeek: DayOfWeek, + modifier: Modifier = Modifier, +) { + var zoom by remember { mutableStateOf(CalendarZoom.Month) } + var focusedMonth by remember { mutableStateOf(months.last()) } + val years = remember(months) { yearsBetween(months.first().year, months.last().year) } + + Crossfade( + targetState = zoom, + label = "calendar zoom", + modifier = + modifier + .fillMaxWidth() + .pinchToZoom( + onZoomOut = { zoom = CalendarZoom.Year }, + onZoomIn = { zoom = CalendarZoom.Month }, + ), + ) { level -> + when (level) { + CalendarZoom.Month -> + MonthCalendar( + months = months, + countsByDay = countsByDay, + today = today, + selectedDate = selectedDate, + onDayClick = onDayClick, + firstDayOfWeek = firstDayOfWeek, + initialMonth = focusedMonth, + onMonthChanged = { focusedMonth = it }, + onZoomOut = { current -> + focusedMonth = current + zoom = CalendarZoom.Year + }, + ) + CalendarZoom.Year -> + YearCalendar( + years = years, + countsByDay = countsByDay, + today = today, + initialYear = focusedMonth.year, + onYearChanged = { year -> focusedMonth = monthToFocus(focusedMonth, year, months) }, + onMonthClick = { month -> + focusedMonth = month + zoom = CalendarZoom.Month + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt index ba87c87..8973c7e 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -86,7 +86,7 @@ fun HistoryOverview( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - MonthCalendar( + HistoryCalendar( months = months, countsByDay = countsByDay, today = today, diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt index c1707c8..a774b9e 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -44,6 +45,7 @@ object MonthCalendarTestTags { const val TITLE = "MonthCalendarTitle" const val PREVIOUS = "MonthCalendarPrevious" const val NEXT = "MonthCalendarNext" + const val ZOOM_OUT = "MonthCalendarZoomOut" fun day(date: LocalDate) = "MonthCalendarDay_$date" } @@ -62,6 +64,9 @@ private const val ROWS = 6 * @param selectedDate The highlighted day, if any. * @param onDayClick Called with the tapped day. * @param firstDayOfWeek The weekday the columns start on, usually the locale's. + * @param initialMonth The month to open on; the last one if it isn't in [months]. + * @param onZoomOut When set, a "< year" button calls it with the month on screen. + * @param onMonthChanged Called with the month on screen, on opening and after every swipe. */ @Composable fun MonthCalendar( @@ -73,14 +78,38 @@ fun MonthCalendar( modifier: Modifier = Modifier, firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, locale: Locale = Locale.getDefault(), + initialMonth: YearMonth = months.last(), + onZoomOut: ((YearMonth) -> Unit)? = null, + onMonthChanged: (YearMonth) -> Unit = {}, ) { require(months.isNotEmpty()) { "months must not be empty" } - val pagerState = rememberPagerState(initialPage = months.lastIndex) { months.size } + val initialPage = months.indexOf(initialMonth).takeIf { it >= 0 } ?: months.lastIndex + val pagerState = rememberPagerState(initialPage = initialPage) { months.size } val scope = rememberCoroutineScope() val maxCount = remember(countsByDay) { countsByDay.values.maxOrNull() ?: 0 } val current = months[pagerState.currentPage] + LaunchedEffect(current) { onMonthChanged(current) } Column(modifier = modifier.fillMaxWidth().testTag(MonthCalendarTestTags.CALENDAR)) { + if (onZoomOut != null) { + // Like the iPhone's "< 2026": one step back to the year. + Row( + modifier = + Modifier.clickable { onZoomOut(current) }.testTag(MonthCalendarTestTags.ZOOM_OUT), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = current.year.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt new file mode 100644 index 0000000..1ac32ba --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt @@ -0,0 +1,193 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.launch + +object YearCalendarTestTags { + const val CALENDAR = "YearCalendar" + const val TITLE = "YearCalendarTitle" + const val PREVIOUS = "YearCalendarPrevious" + const val NEXT = "YearCalendarNext" + + fun month(month: YearMonth) = "YearCalendarMonth_$month" +} + +private const val COLUMNS = 3 +private val TileShape = RoundedCornerShape(14.dp) + +/** + * The zoomed-out calendar: a year as twelve tiles, each shaded by how often the user went that + * month and showing the count. Tap a month to zoom in on it; swipe or use the buttons to change + * year. + * + * @param years The years that can be shown, oldest first; it opens on [initialYear]. + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day; months after it are dimmed and can't be opened. + * @param onMonthClick Called with the tapped month. + * @param onYearChanged Called with the year on screen, on opening and after every swipe. + */ +@Composable +fun YearCalendar( + years: List, + countsByDay: Map, + today: LocalDate, + onMonthClick: (YearMonth) -> Unit, + modifier: Modifier = Modifier, + initialYear: Int = years.last(), + onYearChanged: (Int) -> Unit = {}, + locale: Locale = Locale.getDefault(), +) { + require(years.isNotEmpty()) { "years must not be empty" } + val initialPage = years.indexOf(initialYear).takeIf { it >= 0 } ?: years.lastIndex + val pagerState = rememberPagerState(initialPage = initialPage) { years.size } + val scope = rememberCoroutineScope() + val monthCounts = remember(countsByDay) { monthCounts(countsByDay) } + val maxCount = remember(monthCounts) { monthCounts.values.maxOrNull() ?: 0 } + val year = years[pagerState.currentPage] + LaunchedEffect(year) { onYearChanged(year) } + + Column(modifier = modifier.fillMaxWidth().testTag(YearCalendarTestTags.CALENDAR)) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = year.toString(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(YearCalendarTestTags.TITLE), + ) + Text( + text = visitsLabel(monthCounts.filterKeys { it.year == year }.values.sum()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, + enabled = pagerState.currentPage > 0, + modifier = Modifier.testTag(YearCalendarTestTags.PREVIOUS), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous year") + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, + enabled = pagerState.currentPage < years.lastIndex, + modifier = Modifier.testTag(YearCalendarTestTags.NEXT), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next year") + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { page -> + val tiles = + remember(years[page], monthCounts, today, maxCount) { + buildYearTiles(years[page], monthCounts, today, maxCount) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + tiles.chunked(COLUMNS).forEach { rowTiles -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowTiles.forEach { tile -> + MonthTileCell( + tile = tile, + today = today, + locale = locale, + onClick = { onMonthClick(tile.month) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } +} + +@Composable +private fun MonthTileCell( + tile: MonthTile, + today: LocalDate, + locale: Locale, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + val isThisMonth = tile.month == YearMonth.from(today) + val name = tile.month.month.getDisplayName(TextStyle.SHORT_STANDALONE, locale) + val textColor = + when { + tile.isFuture -> scheme.onSurface.copy(alpha = 0.3f) + tile.level >= 3 -> scheme.onPrimary + else -> scheme.onSurface + } + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .aspectRatio(1.35f) + .background( + if (tile.level == 0) scheme.onSurface.copy(alpha = 0.06f) + else heatmapColor(tile.level), + TileShape, + ) + .then( + if (isThisMonth) Modifier.border(1.5.dp, scheme.primary, TileShape) else Modifier + ) + .then(if (tile.isFuture) Modifier else Modifier.clickable(onClick = onClick)) + .semantics { + contentDescription = "$name ${tile.month.year}: ${visitsLabel(tile.count)}" + } + .testTag(YearCalendarTestTags.month(tile.month)), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isThisMonth) FontWeight.Bold else FontWeight.Medium, + color = textColor, + textAlign = TextAlign.Center, + ) + Text( + text = if (tile.isFuture) "" else tile.count.toString(), + style = MaterialTheme.typography.labelMedium, + color = textColor, + ) + } + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index 6157530..68d7c72 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -19,6 +19,7 @@ import com.github.se.pooltrack.model.subscription.Subscription import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider import com.github.se.pooltrack.ui.history.heatmap.HistoryOverviewTestTags import com.github.se.pooltrack.ui.history.heatmap.MonthCalendarTestTags +import com.github.se.pooltrack.ui.history.heatmap.YearCalendarTestTags import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.NavigationTestTags import com.github.se.pooltrack.ui.navigation.Screen @@ -422,4 +423,30 @@ class HistoryScreenRobolectricTest { composeTestRule.onNodeWithTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY).assertIsDisplayed() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(0) } + + @Test + fun historyScreen_zoomsOutToTheYear_withAShadedTilePerMonth() { + show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(40))))) + + composeTestRule.onNodeWithTag(MonthCalendarTestTags.ZOOM_OUT).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(YearCalendarTestTags.CALENDAR).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(MonthCalendarTestTags.CALENDAR).assertCountEquals(0) + composeTestRule.onNodeWithTag(YearCalendarTestTags.month(YearMonth.from(today))).assertExists() + } + + @Test + fun historyScreen_zoomsBackIntoATappedMonth() { + val older = today.minusMonths(1).withDayOfMonth(15) + show(FakeEntryRepository(listOf(entryAt(today), entryAt(older)))) + composeTestRule.onNodeWithTag(MonthCalendarTestTags.ZOOM_OUT).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(YearCalendarTestTags.month(YearMonth.from(older))).performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(older)).assertExists() + composeTestRule.onAllNodesWithTag(YearCalendarTestTags.CALENDAR).assertCountEquals(0) + } } From 443fa8913890adc170a4e62ce984a3358424b0e0 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 16:53:44 +0200 Subject: [PATCH 12/19] Fix formatting after the rebase Co-Authored-By: Claude Sonnet 5.5 --- .../java/com/github/se/pooltrack/ui/history/HistoryListItems.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt index 97210e8..162cc8c 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -39,7 +39,6 @@ import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAdjusters import java.util.Locale - /** The first day of the week that contains [day], where weeks start on [firstDayOfWeek]. */ internal fun weekStart(day: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = day.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) From 56565ba16ebe6591675ca26df1baffe55fde5b2d Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 17:28:11 +0200 Subject: [PATCH 13/19] Add the history list row model A flat list of week headers and entries with weekly totals, finding an entry by day, the distance-first row texts and the distance format. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/history/HistoryListModel.kt | 91 +++++++++++ .../ui/history/HistoryListModelTest.kt | 141 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListModel.kt create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListModelTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListModel.kt new file mode 100644 index 0000000..131af9a --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListModel.kt @@ -0,0 +1,91 @@ +package com.github.se.pooltrack.ui.history + +import com.github.se.pooltrack.model.entry.Entry +import com.github.se.pooltrack.model.entry.timestamp +import com.github.se.pooltrack.model.swim.formatSwimDistance +import com.github.se.pooltrack.model.swim.formatSwimDuration +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale + +/** One line of the history list below the overview card. */ +sealed interface HistoryRow { + /** A stable identity for the list. */ + val key: Any + + /** + * The header of the week starting on [start]. + * + * @property visits Entries in the week. + * @property meters The distance logged during the week, in metres. + */ + data class Week(val start: LocalDate, val visits: Int, val meters: Int) : HistoryRow { + override val key: Any = "week-$start" + } + + /** An [entry] confirmed on [day]. */ + data class Item(val day: LocalDate, val entry: Entry) : HistoryRow { + override val key: Any = entry.timestampEpochMilli + } +} + +/** + * Lays out [entries] as a week header followed by that week's entries, most recent first. Days are + * resolved in [zone] and weeks start on [firstDayOfWeek]. + */ +fun buildHistoryRows( + entries: List, + firstDayOfWeek: DayOfWeek, + zone: ZoneId = ZoneId.systemDefault(), +): List = + entries + .sortedByDescending { it.timestamp } + .map { HistoryRow.Item(it.timestamp.atZone(zone).toLocalDate(), it) } + .groupBy { weekStart(it.day, firstDayOfWeek) } + .flatMap { (start, items) -> + listOf( + HistoryRow.Week( + start = start, + visits = items.size, + meters = items.sumOf { it.entry.swimDistanceMeters ?: 0 }, + ) + ) + items + } + +/** The position of the first (most recent) entry of [day] in these rows, or `null` if none. */ +fun List.indexOfDay(day: LocalDate): Int? = indexOfFirst { + it is HistoryRow.Item && it.day == day +} + .takeIf { it >= 0 } + +/** + * What a row of the list says about an entry. The distance leads when it was logged, since it is + * what swims are compared by; otherwise the time does. + * + * @property primary The bold line: the distance, or the time if no distance was logged. + * @property secondary The lighter line: the time (when the distance leads) and the swim duration; + * `null` when there is nothing more to say. + */ +data class EntryRowTexts(val primary: String, val secondary: String?) + +/** + * The [EntryRowTexts] for [entry], with its time already formatted as [time]. + * + * Examples: "1200 m" over "6:42 PM · 52min"; just "6:42 PM" over "52min" for an entry without a + * distance; just "6:42 PM" when it has neither. + */ +fun entryRowTexts(entry: Entry, time: String): EntryRowTexts { + val duration = entry.swimDurationMillis?.let { formatSwimDuration(Duration.ofMillis(it)) } + val distance = entry.swimDistanceMeters?.let { formatSwimDistance(it) } + return if (distance != null) { + EntryRowTexts(distance, listOfNotNull(time, duration).joinToString(" · ")) + } else { + EntryRowTexts(time, duration) + } +} + +/** "850 m" under a kilometre, otherwise kilometres with one decimal ("4.2 km"). */ +fun formatTotalDistance(meters: Int, locale: Locale = Locale.getDefault()): String = + if (meters < 1000) "$meters m" else String.format(locale, "%.1f km", meters / 1000.0) diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListModelTest.kt new file mode 100644 index 0000000..42b6d7c --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListModelTest.kt @@ -0,0 +1,141 @@ +package com.github.se.pooltrack.ui.history + +import com.github.se.pooltrack.model.entry.Entry +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HistoryListModelTest { + + private val zone = ZoneId.of("UTC") + private val monday = DayOfWeek.MONDAY + + private fun entry(day: LocalDate, hour: Int = 12, meters: Int? = null) = + Entry( + timestampEpochMilli = day.atTime(hour, 0).atZone(zone).toInstant().toEpochMilli(), + swimDistanceMeters = meters, + ) + + // 2026-10-05 is a Monday. + private val thisWeekMon = LocalDate.of(2026, 10, 5) + private val thisWeekWed = LocalDate.of(2026, 10, 7) + private val lastWeekSun = LocalDate.of(2026, 10, 4) + + private fun rows(vararg entries: Entry) = buildHistoryRows(entries.toList(), monday, zone) + + @Test + fun buildHistoryRows_isEmpty_withoutEntries() { + assertEquals(emptyList(), rows()) + } + + @Test + fun buildHistoryRows_putsAWeekHeaderBeforeItsEntries_mostRecentFirst() { + val monday9 = entry(thisWeekMon, hour = 9) + val wednesday = entry(thisWeekWed) + val sunday = entry(lastWeekSun) + + val rows = rows(sunday, monday9, wednesday) + + assertEquals( + listOf( + HistoryRow.Week(thisWeekMon, visits = 2, meters = 0), + HistoryRow.Item(thisWeekWed, wednesday), + HistoryRow.Item(thisWeekMon, monday9), + HistoryRow.Week(LocalDate.of(2026, 9, 28), visits = 1, meters = 0), + HistoryRow.Item(lastWeekSun, sunday), + ), + rows, + ) + } + + @Test + fun buildHistoryRows_sumsTheLoggedDistanceOfTheWeek() { + val rows = + rows( + entry(thisWeekMon, meters = 1000), + entry(thisWeekWed, meters = 500), + entry(thisWeekWed, hour = 8), + ) + + assertEquals(1500, (rows.first() as HistoryRow.Week).meters) + } + + @Test + fun buildHistoryRows_startsWeeksOnTheGivenFirstDayOfWeek() { + val rows = buildHistoryRows(listOf(entry(lastWeekSun)), DayOfWeek.SUNDAY, zone) + + assertEquals(lastWeekSun, (rows.first() as HistoryRow.Week).start) + } + + @Test + fun buildHistoryRows_resolvesDaysInTheGivenZone() { + val lateEvening = + Entry(LocalDate.of(2026, 10, 7).atTime(23, 30).atZone(zone).toInstant().toEpochMilli()) + + val rows = buildHistoryRows(listOf(lateEvening), monday, ZoneId.of("Asia/Tokyo")) + + // 23:30 UTC is already the next morning in Tokyo. + assertEquals(LocalDate.of(2026, 10, 8), (rows[1] as HistoryRow.Item).day) + } + + @Test + fun indexOfDay_findsTheEntryOfThatDay() { + val rows = rows(entry(thisWeekMon), entry(thisWeekWed), entry(lastWeekSun)) + + assertEquals(1, rows.indexOfDay(thisWeekWed)) + assertEquals(2, rows.indexOfDay(thisWeekMon)) + assertEquals(4, rows.indexOfDay(lastWeekSun)) + } + + @Test + fun indexOfDay_isNull_forADayWithoutEntry() { + assertNull(rows(entry(thisWeekMon)).indexOfDay(LocalDate.of(2026, 10, 6))) + } + + @Test + fun entryRowTexts_leadsWithTheDistance_andKeepsTimeAndDurationBelow() { + val e = Entry(0, swimDistanceMeters = 1200, swimDurationMillis = 3_120_000L) + + assertEquals(EntryRowTexts("1200 m", "6:42 PM · 52min"), entryRowTexts(e, "6:42 PM")) + } + + @Test + fun entryRowTexts_showsOnlyTheTimeBelowADistanceWithoutDuration() { + assertEquals( + EntryRowTexts("800 m", "6:42 PM"), + entryRowTexts(Entry(0, swimDistanceMeters = 800), "6:42 PM"), + ) + } + + @Test + fun entryRowTexts_leadsWithTheTime_whenNoDistanceWasLogged() { + assertEquals( + EntryRowTexts("6:42 PM", "52min"), + entryRowTexts(Entry(0, swimDurationMillis = 3_120_000L), "6:42 PM"), + ) + assertEquals(EntryRowTexts("6:42 PM", null), entryRowTexts(Entry(0), "6:42 PM")) + } + + @Test + fun formatTotalDistance_usesMetresUnderAKilometre_andKilometresAbove() { + assertEquals("0 m", formatTotalDistance(0, Locale.ENGLISH)) + assertEquals("850 m", formatTotalDistance(850, Locale.ENGLISH)) + assertEquals("1.0 km", formatTotalDistance(1000, Locale.ENGLISH)) + assertEquals("4.2 km", formatTotalDistance(4200, Locale.ENGLISH)) + } + + @Test + fun weekTotals_listsVisitsAndDistance_whenThereIsOne() { + assertEquals("3 visits · 4.2 km", weekTotals(3, 4200)) + assertEquals("1 visit · 600 m", weekTotals(1, 600)) + } + + @Test + fun weekTotals_leavesOutTheDistance_whenNoneWasLogged() { + assertEquals("2 visits", weekTotals(2, 0)) + } +} From 3ee69459d976ce3cfe55121d55f4f1a9338d9305 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 17:28:14 +0200 Subject: [PATCH 14/19] Make the overview card compact One summary line (visits this week, week streak) and the current week as seven days; a Calendar button expands to the month calendar with its year zoom. Tapping a day no longer filters, it highlights. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HistoryOverview.kt | 120 +++++++++--------- .../ui/history/heatmap/MonthCalendar.kt | 15 ++- .../ui/history/heatmap/MonthModel.kt | 26 ++++ .../pooltrack/ui/history/heatmap/WeekStrip.kt | 76 +++++++++++ .../heatmap/HistoryOverviewTextTest.kt | 30 ++--- .../ui/history/heatmap/MonthModelTest.kt | 26 ++++ 6 files changed, 213 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/WeekStrip.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt index 8973c7e..e1096f5 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -5,12 +5,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Card +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -23,34 +31,26 @@ import java.time.YearMonth object HistoryOverviewTestTags { const val CARD = "HistoryOverviewCard" const val SUMMARY = "HistoryOverviewSummary" - const val CLEAR_FILTER = "HistoryOverviewClearFilter" + const val TOGGLE = "HistoryOverviewToggle" } -/** "34 visits in the last year" for [summary]. */ -fun summaryTitle(summary: HeatmapSummary): String = - if (summary.visits == 1) "1 visit in the last year" - else "${summary.visits} visits in the last year" - -/** The week-streak sentence, or `null` when there is no streak worth mentioning. */ +/** The week-streak sentence ("4-week streak"), or `null` when there is no streak yet. */ fun streakText(summary: HeatmapSummary): String? = - when { - summary.currentWeekStreak >= 2 -> - "${summary.currentWeekStreak}-week streak (best: ${summary.longestWeekStreak})" - summary.longestWeekStreak >= 2 -> "Best streak: ${summary.longestWeekStreak} weeks in a row" - else -> null - } + if (summary.currentWeekStreak >= 2) "${summary.currentWeekStreak}-week streak" else null + +/** The one-line summary: "5 this week · 4-week streak". */ +fun overviewSummary(visitsThisWeek: Int, summary: HeatmapSummary): String = + listOfNotNull("$visitsThisWeek this week", streakText(summary)).joinToString(" · ") /** - * The card on top of the History list: how much the user swam over the last year in a month - * calendar, a one-line summary, and, once a day is picked, a bar to clear that filter. + * The card on top of the History list. By default it is compact: a one-line summary and the current + * week as seven days. Expanding it shows the month calendar, which zooms out to the year. * * @param countsByDay Entries per day, see [entryCountsByDay]. * @param today The current day. - * @param firstDayOfWeek The weekday the calendar columns start on. - * @param selectedDate The day the list is filtered to, if any. - * @param onDayClick Called when a day is tapped; the caller toggles the filter. - * @param onClearSelection Called to show every day again. - * @param selectedLabel How to name the selected day, e.g. "Monday, Oct 5". + * @param firstDayOfWeek The weekday the week strip and calendar columns start on. + * @param selectedDate The day to highlight, if any. + * @param onDayClick Called when a day is tapped. */ @Composable fun HistoryOverview( @@ -59,66 +59,66 @@ fun HistoryOverview( firstDayOfWeek: DayOfWeek, selectedDate: LocalDate?, onDayClick: (LocalDate) -> Unit, - onClearSelection: () -> Unit, modifier: Modifier = Modifier, - selectedLabel: (LocalDate) -> String = { it.toString() }, ) { + var expanded by rememberSaveable { mutableStateOf(false) } val summary = remember(countsByDay, today, firstDayOfWeek) { summarize(buildHeatmap(countsByDay, today, firstDayOfWeek = firstDayOfWeek)) } + val visitsThisWeek = + remember(countsByDay, today, firstDayOfWeek) { + buildWeekStrip(today, countsByDay, firstDayOfWeek).sumOf { it.count } + } val months = remember(countsByDay, today) { monthsBetween(YearMonth.from(countsByDay.keys.minOrNull() ?: today), YearMonth.from(today)) } - val activeDays = "${summary.activeDays} active ${if (summary.activeDays == 1) "day" else "days"}" + Card(modifier = modifier.fillMaxWidth().testTag(HistoryOverviewTestTags.CARD)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Column(modifier = Modifier.testTag(HistoryOverviewTestTags.SUMMARY)) { - Text( - text = summaryTitle(summary), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = listOfNotNull(activeDays, streakText(summary)).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - HistoryCalendar( - months = months, - countsByDay = countsByDay, - today = today, - firstDayOfWeek = firstDayOfWeek, - selectedDate = selectedDate, - onDayClick = onDayClick, - ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - if (selectedDate != null) { - Text( - text = "Showing ${selectedLabel(selectedDate)}", - style = MaterialTheme.typography.labelMedium, - ) - TextButton( - onClick = onClearSelection, - modifier = Modifier.testTag(HistoryOverviewTestTags.CLEAR_FILTER), - ) { - Text("Show all") - } - } else { - Text( - text = "Tap a day to filter", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Text( + text = overviewSummary(visitsThisWeek, summary), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f).testTag(HistoryOverviewTestTags.SUMMARY), + ) + TextButton( + onClick = { expanded = !expanded }, + modifier = Modifier.testTag(HistoryOverviewTestTags.TOGGLE), + ) { + Text(if (expanded) "Hide" else "Calendar") + Icon( + imageVector = + if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, ) - HeatmapLegend() } } + if (expanded) { + HistoryCalendar( + months = months, + countsByDay = countsByDay, + today = today, + firstDayOfWeek = firstDayOfWeek, + selectedDate = selectedDate, + onDayClick = onDayClick, + ) + HeatmapLegend(modifier = Modifier.align(Alignment.End)) + } else { + WeekStrip( + countsByDay = countsByDay, + today = today, + selectedDate = selectedDate, + onDayClick = onDayClick, + firstDayOfWeek = firstDayOfWeek, + ) + } } } } diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt index a774b9e..3eeb7b3 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -175,7 +175,11 @@ fun MonthCalendar( val day = week?.get(column) Box(modifier = Modifier.weight(1f).aspectRatio(1f).padding(3.dp)) { if (day != null) { - DayCell(day, selected = day.date == selectedDate) { onDayClick(day.date) } + DayCell( + day, + selected = day.date == selectedDate, + onClick = { onDayClick(day.date) }, + ) } } } @@ -187,7 +191,12 @@ fun MonthCalendar( } @Composable -private fun DayCell(day: MonthDay, selected: Boolean, onClick: () -> Unit) { +internal fun DayCell( + day: MonthDay, + selected: Boolean, + onClick: () -> Unit, + testTag: String = MonthCalendarTestTags.day(day.date), +) { val scheme = MaterialTheme.colorScheme val textColor = when { @@ -216,7 +225,7 @@ private fun DayCell(day: MonthDay, selected: Boolean, onClick: () -> Unit) { .then(if (ring != null) Modifier.border(ring, CircleShape) else Modifier) .then(if (day.isFuture) Modifier else Modifier.clickable(onClick = onClick)) .semantics { contentDescription = description } - .testTag(MonthCalendarTestTags.day(day.date)), + .testTag(testTag), ) { Text( text = day.date.dayOfMonth.toString(), diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt index 4a17ab7..9d96954 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt @@ -3,6 +3,7 @@ package com.github.se.pooltrack.ui.history.heatmap import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth +import java.time.temporal.TemporalAdjusters /** * One day of a [MonthGrid]. @@ -65,3 +66,28 @@ fun monthsBetween(first: YearMonth, last: YearMonth): List { if (first.isAfter(last)) return listOf(last) return generateSequence(first) { it.plusMonths(1) }.takeWhile { !it.isAfter(last) }.toList() } + +/** + * The seven days of the week containing [today], oldest first, starting on [firstDayOfWeek]. + * + * @param maxCount The busiest day to scale the intensity against, as for [buildMonthGrid]. + */ +fun buildWeekStrip( + today: LocalDate, + countsByDay: Map, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + maxCount: Int = countsByDay.values.maxOrNull() ?: 0, +): List { + val start = today.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + return (0 until 7).map { offset -> + val date = start.plusDays(offset.toLong()) + val count = countsByDay[date] ?: 0 + MonthDay( + date = date, + count = count, + level = intensityLevel(count, maxCount), + isToday = date == today, + isFuture = date.isAfter(today), + ) + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/WeekStrip.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/WeekStrip.kt new file mode 100644 index 0000000..70564f7 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/WeekStrip.kt @@ -0,0 +1,76 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale + +object WeekStripTestTags { + const val STRIP = "WeekStrip" + + fun day(date: LocalDate) = "WeekStripDay_$date" +} + +/** + * The current week as one row of seven round days, filled for the days the user went. It is the + * compact stand-in for the month calendar: one glance answers "did I swim this week?". + * + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day, marked with a ring; later days can't be tapped. + * @param selectedDate The highlighted day, if any. + * @param onDayClick Called with the tapped day. + */ +@Composable +fun WeekStrip( + countsByDay: Map, + today: LocalDate, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + firstDayOfWeek: DayOfWeek, + modifier: Modifier = Modifier, + locale: Locale = Locale.getDefault(), +) { + val days = + remember(countsByDay, today, firstDayOfWeek) { + buildWeekStrip(today, countsByDay, firstDayOfWeek) + } + Row(modifier = modifier.fillMaxWidth().testTag(WeekStripTestTags.STRIP)) { + days.forEach { day -> + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = day.date.dayOfWeek.getDisplayName(TextStyle.NARROW, locale), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Box(modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(3.dp)) { + DayCell( + day = day, + selected = day.date == selectedDate, + testTag = WeekStripTestTags.day(day.date), + onClick = { onDayClick(day.date) }, + ) + } + } + } + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt index e36680e..fc4b50a 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt @@ -6,33 +6,29 @@ import org.junit.Test class HistoryOverviewTextTest { - private fun summary(visits: Int = 0, current: Int = 0, longest: Int = 0) = - HeatmapSummary(visits, activeDays = visits, current, longest) + private fun summary(current: Int = 0, longest: Int = 0) = + HeatmapSummary(visits = 0, activeDays = 0, current, longest) @Test - fun summaryTitle_isSingular_forOneVisit() { - assertEquals("1 visit in the last year", summaryTitle(summary(visits = 1))) + fun streakText_namesTheCurrentStreak_fromTwoWeeks() { + assertEquals("2-week streak", streakText(summary(current = 2, longest = 2))) + assertEquals("4-week streak", streakText(summary(current = 4, longest = 9))) } @Test - fun summaryTitle_isPlural_otherwise() { - assertEquals("0 visits in the last year", summaryTitle(summary(visits = 0))) - assertEquals("34 visits in the last year", summaryTitle(summary(visits = 34))) - } - - @Test - fun streakText_showsTheCurrentStreak_fromTwoWeeks() { - assertEquals("3-week streak (best: 5)", streakText(summary(current = 3, longest = 5))) + fun streakText_isNull_withoutAStreak() { + assertNull(streakText(summary(current = 1, longest = 5))) + assertNull(streakText(summary())) } @Test - fun streakText_showsTheBestStreak_whenTheCurrentOneIsOver() { - assertEquals("Best streak: 4 weeks in a row", streakText(summary(current = 1, longest = 4))) + fun overviewSummary_countsTheWeek_andAddsTheStreak() { + assertEquals("5 this week · 4-week streak", overviewSummary(5, summary(current = 4))) } @Test - fun streakText_isNull_withoutAStreak() { - assertNull(streakText(summary(current = 1, longest = 1))) - assertNull(streakText(summary())) + fun overviewSummary_isJustTheWeek_withoutAStreak() { + assertEquals("0 this week", overviewSummary(0, summary())) + assertEquals("1 this week", overviewSummary(1, summary(current = 1))) } } diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt index fd5e11d..66cad4b 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt @@ -127,4 +127,30 @@ class MonthModelTest { fun monthsBetween_fallsBackToTheLastMonth_whenFirstIsAfterIt() { assertEquals(listOf(october), monthsBetween(october.plusMonths(2), october)) } + + @Test + fun buildWeekStrip_hasTheSevenDaysOfTodaysWeek() { + val strip = buildWeekStrip(today, emptyMap(), DayOfWeek.MONDAY) + + assertEquals((5..11).map { LocalDate.of(2026, 10, it) }, strip.map { it.date }) + } + + @Test + fun buildWeekStrip_startsOnTheGivenFirstDayOfWeek() { + val strip = buildWeekStrip(today, emptyMap(), DayOfWeek.SUNDAY) + + assertEquals(LocalDate.of(2026, 10, 4), strip.first().date) + } + + @Test + fun buildWeekStrip_marksTodayAndTheFuture_andCarriesTheCounts() { + val monday = LocalDate.of(2026, 10, 5) + val strip = buildWeekStrip(today, mapOf(monday to 2), DayOfWeek.MONDAY).associateBy { it.date } + + assertEquals(2, strip.getValue(monday).count) + assertEquals(2, strip.getValue(monday).level) + assertEquals(true, strip.getValue(today).isToday) + assertEquals(false, strip.getValue(today).isFuture) + assertEquals(true, strip.getValue(today.plusDays(1)).isFuture) + } } From 12355a7c56601afa29b92bd5b800e03f34053382 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 17:28:17 +0200 Subject: [PATCH 15/19] Polish the history list Rows lead with the distance, week headers show the week's total distance, and tapping a day in the overview scrolls to its entry and tints it, or says there was no visit that day. Co-Authored-By: Claude Sonnet 5.5 --- .../pooltrack/ui/history/HistoryListItems.kt | 41 ++++-- .../se/pooltrack/ui/history/HistoryScreen.kt | 120 ++++++++++-------- .../history/HistoryScreenRobolectricTest.kt | 97 ++++++++------ 3 files changed, 157 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt index 162cc8c..a126d9c 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -75,7 +75,7 @@ internal fun weekLabel( /** The sticky title above the entries of one week, with how many visits it had. */ @Composable -internal fun WeekHeader(label: String, visits: Int, modifier: Modifier = Modifier) { +internal fun WeekHeader(label: String, visits: Int, meters: Int, modifier: Modifier = Modifier) { Surface( modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.WEEK_HEADER), color = MaterialTheme.colorScheme.background, @@ -91,7 +91,7 @@ internal fun WeekHeader(label: String, visits: Int, modifier: Modifier = Modifie fontWeight = FontWeight.Bold, ) Text( - text = visitsLabel(visits), + text = weekTotals(visits, meters), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -136,7 +136,8 @@ private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale. * * @param day The day of the entry, shown in the date block. * @param isToday Whether [day] is today. - * @param label The text of the row, see [entryRowLabel]. + * @param texts What the row says, see [entryRowTexts]. + * @param highlighted Whether to tint the row, e.g. because its day was picked in the calendar. * @param onClick Called when the row is tapped. * @param onDelete Called once the row was swiped away. */ @@ -145,7 +146,8 @@ private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale. internal fun SwipeableEntryRow( day: LocalDate, isToday: Boolean, - label: String, + texts: EntryRowTexts, + highlighted: Boolean, onClick: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, @@ -186,18 +188,32 @@ internal fun SwipeableEntryRow( } }, ) { - Surface(onClick = onClick, color = MaterialTheme.colorScheme.background) { + Surface( + onClick = onClick, + color = + if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.background, + ) { Column { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { DateBlock(day = day, isToday = isToday) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f).padding(horizontal = 16.dp), - ) + Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) { + Text( + text = texts.primary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + texts.secondary?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -209,3 +225,8 @@ internal fun SwipeableEntryRow( } } } + +/** "5 visits · 4.2 km"; the distance is left out when none was logged that week. */ +internal fun weekTotals(visits: Int, meters: Int): String = + listOfNotNull(visitsLabel(visits), if (meters > 0) formatTotalDistance(meters) else null) + .joinToString(" · ") diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index 0ae881e..01a8f5d 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.DateRange @@ -34,20 +34,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.timestamp -import com.github.se.pooltrack.model.swim.formatSwimDistance -import com.github.se.pooltrack.model.swim.formatSwimDuration import com.github.se.pooltrack.ui.history.heatmap.HistoryOverview +import com.github.se.pooltrack.ui.history.heatmap.entryCountsByDay import com.github.se.pooltrack.ui.navigation.BottomNavigationMenu import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.Screen import com.github.se.pooltrack.ui.navigation.Tab import com.github.se.pooltrack.ui.navigation.TopNavigationMenu -import java.time.Duration import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId @@ -55,6 +54,7 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.temporal.WeekFields import java.util.Locale +import kotlinx.coroutines.delay import kotlinx.coroutines.launch object HistoryScreenTestTags { @@ -66,7 +66,6 @@ object HistoryScreenTestTags { const val UNDO_ADD_ACTION = "Undo" const val LINK_BANNER = "HistoryScreenLinkBanner" const val LINK_BUTTON = "HistoryScreenLinkButton" - const val NO_ENTRIES_THAT_DAY = "HistoryScreenNoEntriesThatDay" } private val ZONE = ZoneId.systemDefault() @@ -86,7 +85,7 @@ fun HistoryScreen( ) { val entries by viewModel.entries.collectAsState() var showAddSheet by remember { mutableStateOf(false) } - var selectedDate by remember { mutableStateOf(null) } + var highlightedDate by remember { mutableStateOf(null) } // Remembered across openings, so adding several entries from the same day takes one tap each. var lastAddedDate by remember { mutableStateOf(LocalDate.now(ZONE)) } var lastAddedTime by remember { mutableStateOf(LocalTime.of(12, 0)) } @@ -191,12 +190,37 @@ fun HistoryScreen( } else { val today = LocalDate.now(ZONE) val firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek - val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } - val countsByDay = remember(allEntriesByDay) { allEntriesByDay.mapValues { it.value.size } } - val entriesByDay = - selectedDate?.let { day -> allEntriesByDay.filterKeys { it == day } } ?: allEntriesByDay + val countsByDay = remember(entries) { entryCountsByDay(entries, ZONE) } + val rows = + remember(entries, firstDayOfWeek) { buildHistoryRows(entries, firstDayOfWeek, ZONE) } + val listState = rememberLazyListState() + val headerClearance = with(LocalDensity.current) { STICKY_HEADER_CLEARANCE.roundToPx() } + // The overview, and the link banner when there is one, come before the rows. + val leadingItems = if (linkSuggestions.isEmpty()) 1 else 2 + + // A picked day stays highlighted for a moment, so the eye can find its row. + LaunchedEffect(highlightedDate) { + if (highlightedDate != null) { + delay(HIGHLIGHT_MILLIS) + highlightedDate = null + } + } + + val onDayPicked: (LocalDate) -> Unit = { day -> + highlightedDate = day + val index = rows.indexOfDay(day) + scope.launch { + if (index != null) { + listState.animateScrollToItem(leadingItems + index, scrollOffset = -headerClearance) + } else { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar("No visit on ${dayLabel(day, today)}") + } + } + } LazyColumn( + state = listState, modifier = Modifier.fillMaxSize() .padding(paddingValues) @@ -208,25 +232,11 @@ fun HistoryScreen( countsByDay = countsByDay, today = today, firstDayOfWeek = firstDayOfWeek, - selectedDate = selectedDate, - onDayClick = { day -> selectedDate = if (day == selectedDate) null else day }, - onClearSelection = { selectedDate = null }, - selectedLabel = { dayLabel(it, today) }, + selectedDate = highlightedDate, + onDayClick = onDayPicked, modifier = Modifier.padding(vertical = 4.dp), ) } - if (selectedDate != null && entriesByDay.isEmpty()) { - item(key = "no-entries-that-day") { - Text( - text = "No entries on this day.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier.padding(vertical = 16.dp) - .testTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY), - ) - } - } if (linkSuggestions.isNotEmpty()) { item(key = "link-banner") { LinkBanner( @@ -251,29 +261,35 @@ fun HistoryScreen( ) } } - entriesByDay.entries - .groupBy { weekStart(it.key, firstDayOfWeek) } - .forEach { (start, days) -> - stickyHeader(key = "week-$start") { - WeekHeader( - label = weekLabel(start, today, firstDayOfWeek), - visits = days.sumOf { it.value.size }, - ) - } - days.forEach { (day, entriesForDay) -> - items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> + rows.forEach { row -> + when (row) { + is HistoryRow.Week -> + stickyHeader(key = row.key) { + WeekHeader( + label = weekLabel(row.start, today, firstDayOfWeek), + visits = row.visits, + meters = row.meters, + ) + } + is HistoryRow.Item -> + item(key = row.key) { SwipeableEntryRow( - day = day, - isToday = day == today, - label = entryRowLabel(entry), + day = row.day, + isToday = row.day == today, + texts = + entryRowTexts( + row.entry, + entryTimeFormatter().format(row.entry.timestamp), + ), + highlighted = row.day == highlightedDate, onClick = { - navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) + navigationActions?.navigateToEntryDetails(row.entry.timestampEpochMilli) }, - onDelete = { onDeleteEntry(entry) }, + onDelete = { onDeleteEntry(row.entry) }, ) } - } - } + } + } } } } @@ -316,14 +332,8 @@ internal fun dayLabel(day: LocalDate, today: LocalDate): String = } } -/** - * The entry time, followed by the swim duration (old entries) and the logged distance, whichever - * were recorded: "18:42 · 52min · 1200 m". - */ -internal fun entryRowLabel(entry: Entry): String = - listOfNotNull( - entryTimeFormatter().format(entry.timestamp), - entry.swimDurationMillis?.let { formatSwimDuration(Duration.ofMillis(it)) }, - entry.swimDistanceMeters?.let { formatSwimDistance(it) }, - ) - .joinToString(" · ") +/** How long a day picked in the calendar keeps its row tinted. */ +private const val HIGHLIGHT_MILLIS = 2_500L + +/** How far below the top a scrolled-to row stops, so the sticky week header doesn't cover it. */ +private val STICKY_HEADER_CLEARANCE = 56.dp diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index 68d7c72..7480aa7 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -19,6 +20,7 @@ import com.github.se.pooltrack.model.subscription.Subscription import com.github.se.pooltrack.model.subscription.SubscriptionRepositoryProvider import com.github.se.pooltrack.ui.history.heatmap.HistoryOverviewTestTags import com.github.se.pooltrack.ui.history.heatmap.MonthCalendarTestTags +import com.github.se.pooltrack.ui.history.heatmap.WeekStripTestTags import com.github.se.pooltrack.ui.history.heatmap.YearCalendarTestTags import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.NavigationTestTags @@ -187,25 +189,48 @@ class HistoryScreenRobolectricTest { } @Test - fun historyScreen_showsSwimDurationNextToTime_whenRecorded() { + fun historyScreen_showsTheTimeAndSwimDuration_whenNoDistanceWasLogged() { val reported = entryAt(today, hour = 8).copy(swimDurationMillis = 4_320_000L) val unreported = entryAt(today.minusDays(1), hour = 9) show(FakeEntryRepository(listOf(reported, unreported))) - composeTestRule.onNodeWithText("${timeLabel(reported)} · 1h 12min").assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(reported)).assertIsDisplayed() + composeTestRule.onNodeWithText("1h 12min").assertIsDisplayed() composeTestRule.onNodeWithText(timeLabel(unreported)).assertIsDisplayed() } @Test - fun historyScreen_showsLoggedDistanceNextToTime() { + fun historyScreen_leadsWithTheLoggedDistance_overTheTime() { val logged = entryAt(today, hour = 8).copy(swimDistanceMeters = 1200) val both = entryAt(today.minusDays(1), hour = 9) .copy(swimDurationMillis = 4_320_000L, swimDistanceMeters = 800) show(FakeEntryRepository(listOf(logged, both))) - composeTestRule.onNodeWithText("${timeLabel(logged)} · 1200 m").assertIsDisplayed() - composeTestRule.onNodeWithText("${timeLabel(both)} · 1h 12min · 800 m").assertIsDisplayed() + composeTestRule.onNodeWithText("1200 m").assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(logged)).assertIsDisplayed() + composeTestRule.onNodeWithText("800 m").assertIsDisplayed() + composeTestRule.onNodeWithText("${timeLabel(both)} · 1h 12min").assertIsDisplayed() + } + + @Test + fun historyScreen_totalsTheWeeksDistance_inItsHeader() { + val first = entryAt(today, hour = 8).copy(swimDistanceMeters = 1200) + val second = entryAt(today.minusMonths(2), hour = 9).copy(swimDistanceMeters = 800) + show(FakeEntryRepository(listOf(first, second))) + + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and + hasAnyDescendant(hasText("1 visit · 1.2 km")) + ) + .assertExists() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and + hasAnyDescendant(hasText("1 visit · 800 m")) + ) + .assertExists() } @Test @@ -363,70 +388,69 @@ class HistoryScreenRobolectricTest { composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.LINK_BANNER).assertCountEquals(0) } - /** Taps [date] in the calendar, first going back a month if it isn't in the current one. */ - private fun tapDay(date: LocalDate) { - if (YearMonth.from(date) != YearMonth.from(today)) { - composeTestRule.onNodeWithTag(MonthCalendarTestTags.PREVIOUS).performClick() - composeTestRule.waitForIdle() - } - composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(date)).performClick() + private fun expandCalendar() { + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.TOGGLE).performClick() + composeTestRule.waitForIdle() } @Test - fun historyScreen_showsTheHeatmapOverview_whenEntriesExist() { - show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(2))))) + fun historyScreen_showsACompactOverview_withTheWeekAndOneSummaryLine() { + show(FakeEntryRepository(listOf(entryAt(today)))) composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CARD).assertIsDisplayed() - composeTestRule.onNodeWithText("2 visits in the last year").assertIsDisplayed() - composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(today)).assertExists() + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.SUMMARY).assertIsDisplayed() + composeTestRule.onNodeWithText("1 this week").assertIsDisplayed() + composeTestRule.onNodeWithTag(WeekStripTestTags.day(today)).assertExists() + composeTestRule.onAllNodesWithTag(MonthCalendarTestTags.CALENDAR).assertCountEquals(0) } @Test - fun historyScreen_hidesTheHeatmapOverview_whenThereAreNoEntries() { + fun historyScreen_hidesTheOverview_whenThereAreNoEntries() { show(FakeEntryRepository()) composeTestRule.onAllNodesWithTag(HistoryOverviewTestTags.CARD).assertCountEquals(0) } @Test - fun historyScreen_filtersTheListToATappedDay_andClearsIt() { - val older = today.minusDays(3) - show(FakeEntryRepository(listOf(entryAt(today), entryAt(older)))) + fun historyScreen_expandsTheOverviewToTheMonthCalendar_andCollapsesIt() { + show(FakeEntryRepository(listOf(entryAt(today)))) - tapDay(older) + expandCalendar() - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.WEEK_HEADER).assertCountEquals(1) + composeTestRule.onNodeWithTag(MonthCalendarTestTags.CALENDAR).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WeekStripTestTags.STRIP).assertCountEquals(0) - composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CLEAR_FILTER).performClick() + expandCalendar() - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(2) + composeTestRule.onNodeWithTag(WeekStripTestTags.STRIP).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(MonthCalendarTestTags.CALENDAR).assertCountEquals(0) } @Test - fun historyScreen_tappingTheSelectedDayAgain_showsEveryDay() { - show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(3))))) - val cell = composeTestRule.onNodeWithTag(MonthCalendarTestTags.day(today)) + fun historyScreen_keepsEveryEntryListed_whenADayIsTapped() { + show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(40))))) - cell.performClick() - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - cell.performClick() + composeTestRule.onNodeWithTag(WeekStripTestTags.day(today)).performClick() + composeTestRule.waitForIdle() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(2) + composeTestRule.onAllNodesWithText("No visit on Today").assertCountEquals(0) } @Test - fun historyScreen_saysSoWhenTheTappedDayHasNoEntries() { - show(FakeEntryRepository(listOf(entryAt(today.minusDays(5))))) - tapDay(today.minusDays(1)) + fun historyScreen_saysSoWhenTheTappedDayHasNoVisit() { + show(FakeEntryRepository(listOf(entryAt(today.minusDays(40))))) + + composeTestRule.onNodeWithTag(WeekStripTestTags.day(today)).performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY).assertIsDisplayed() - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(0) + composeTestRule.onNodeWithText("No visit on Today").assertIsDisplayed() } @Test fun historyScreen_zoomsOutToTheYear_withAShadedTilePerMonth() { show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(40))))) + expandCalendar() composeTestRule.onNodeWithTag(MonthCalendarTestTags.ZOOM_OUT).performClick() composeTestRule.waitForIdle() @@ -440,6 +464,7 @@ class HistoryScreenRobolectricTest { fun historyScreen_zoomsBackIntoATappedMonth() { val older = today.minusMonths(1).withDayOfMonth(15) show(FakeEntryRepository(listOf(entryAt(today), entryAt(older)))) + expandCalendar() composeTestRule.onNodeWithTag(MonthCalendarTestTags.ZOOM_OUT).performClick() composeTestRule.waitForIdle() From d74e66e078ef2ad1a2a1e0873021119f47064160 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 18:41:38 +0200 Subject: [PATCH 16/19] Make a single visit clearly filled Days with one visit start at 70% opacity instead of 30%, since most days have at most one visit; the year view keeps its wider gradient. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HeatmapLegend.kt | 33 ++++++++++++------ .../ui/history/heatmap/YearCalendar.kt | 2 +- .../ui/history/heatmap/HeatmapColorTest.kt | 34 +++++++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt index bda986d..d274de4 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt @@ -14,18 +14,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -/** The fill of a day at [level], from no entry (0) to the busiest days ([MAX_LEVEL]). */ +/** + * How opaque a day with one visit is. High, because most days have at most one visit: a faint fill + * would make every visited day look like it barely counted. + */ +const val DAY_MIN_ALPHA = 0.7f + +/** + * How opaque the lightest tile of the year view is; those span 1 to 12 visits, so it starts low. + */ +const val YEAR_MIN_ALPHA = 0.25f + +/** The opacity of the primary color at [level] (1 to [MAX_LEVEL]), from [minAlpha] up to 1. */ +fun levelAlpha(level: Int, minAlpha: Float): Float = + minAlpha + (1f - minAlpha) * (level.coerceIn(1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1) + +/** + * The fill at [level], from nothing (0) to the busiest ([MAX_LEVEL]); the lightest filled level has + * the opacity [minAlpha]. + */ @Composable -fun heatmapColor(level: Int): Color { - val scheme = MaterialTheme.colorScheme - return when (level) { - 0 -> Color.Transparent - 1 -> scheme.primary.copy(alpha = 0.3f) - 2 -> scheme.primary.copy(alpha = 0.5f) - 3 -> scheme.primary.copy(alpha = 0.75f) - else -> scheme.primary - } -} +fun heatmapColor(level: Int, minAlpha: Float = DAY_MIN_ALPHA): Color = + if (level <= 0) Color.Transparent + else MaterialTheme.colorScheme.primary.copy(alpha = levelAlpha(level, minAlpha)) /** The "Less, five dots, More" key explaining the colors. */ @Composable diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt index 1ac32ba..66a6f26 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt @@ -163,7 +163,7 @@ private fun MonthTileCell( .aspectRatio(1.35f) .background( if (tile.level == 0) scheme.onSurface.copy(alpha = 0.06f) - else heatmapColor(tile.level), + else heatmapColor(tile.level, YEAR_MIN_ALPHA), TileShape, ) .then( diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt new file mode 100644 index 0000000..65b86af --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt @@ -0,0 +1,34 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HeatmapColorTest { + + @Test + fun levelAlpha_startsAtTheMinimum_andEndsOpaque() { + assertEquals(0.7f, levelAlpha(1, DAY_MIN_ALPHA), 0.0001f) + assertEquals(1f, levelAlpha(MAX_LEVEL, DAY_MIN_ALPHA), 0.0001f) + } + + @Test + fun levelAlpha_growsWithTheLevel() { + val alphas = (1..MAX_LEVEL).map { levelAlpha(it, YEAR_MIN_ALPHA) } + + assertEquals(alphas.sorted(), alphas) + assertEquals(alphas.size, alphas.distinct().size) + } + + @Test + fun levelAlpha_clampsLevelsOutsideTheScale() { + assertEquals(levelAlpha(1, DAY_MIN_ALPHA), levelAlpha(0, DAY_MIN_ALPHA), 0.0001f) + assertEquals(levelAlpha(MAX_LEVEL, DAY_MIN_ALPHA), levelAlpha(9, DAY_MIN_ALPHA), 0.0001f) + } + + @Test + fun aSingleVisitIsClearlyFilled_butTheYearViewStillSpreadsOut() { + assertTrue(levelAlpha(1, DAY_MIN_ALPHA) >= 0.6f) + assertTrue(levelAlpha(MAX_LEVEL, YEAR_MIN_ALPHA) - levelAlpha(1, YEAR_MIN_ALPHA) >= 0.6f) + } +} From 8528283d5a2c7bb593845db1d833a40716c3086c Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 18:41:41 +0200 Subject: [PATCH 17/19] Size the month calendar to its weeks Render four to six weeks as the month needs instead of always six, so the card no longer has a blank band under the last week. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/MonthCalendar.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt index 3eeb7b3..2756865 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -1,5 +1,6 @@ package com.github.se.pooltrack.ui.history.heatmap +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -50,9 +51,6 @@ object MonthCalendarTestTags { fun day(date: LocalDate) = "MonthCalendarDay_$date" } -/** A calendar page always has this many rows, so swiping never changes the height. */ -private const val ROWS = 6 - /** * A month calendar in the spirit of the iPhone's: a title with previous/next buttons, a row of * weekday initials and a grid of round days that fills the width. Days with entries are filled, @@ -162,17 +160,20 @@ fun MonthCalendar( } } - HorizontalPager(state = pagerState, modifier = Modifier.fillMaxWidth()) { page -> + // A month has four to six weeks; the card follows the one on screen instead of leaving blanks. + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth().animateContentSize(), + ) { page -> val grid = remember(months[page], countsByDay, today, firstDayOfWeek, maxCount) { buildMonthGrid(months[page], countsByDay, today, firstDayOfWeek, maxCount) } Column(modifier = Modifier.fillMaxWidth()) { - (0 until ROWS).forEach { row -> + grid.weeks.forEach { week -> Row(modifier = Modifier.fillMaxWidth()) { - val week = grid.weeks.getOrNull(row) (0 until 7).forEach { column -> - val day = week?.get(column) + val day = week[column] Box(modifier = Modifier.weight(1f).aspectRatio(1f).padding(3.dp)) { if (day != null) { DayCell( @@ -201,7 +202,7 @@ internal fun DayCell( val textColor = when { day.isFuture -> scheme.onSurface.copy(alpha = 0.3f) - day.level >= 3 -> scheme.onPrimary + day.level >= 1 -> scheme.onPrimary else -> scheme.onSurface } val ring = From b5f65bd8fdda67ee1a6ac0e568cf1ad9c451e75d Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 19:01:36 +0200 Subject: [PATCH 18/19] Remove the week streak from the overview The summary is just the visits this week. The year-long grid and its streak summary only existed for that line, so they go too. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HeatmapModel.kt | 101 -------------- .../ui/history/heatmap/HistoryOverview.kt | 15 +- .../ui/history/heatmap/HeatmapModelTest.kt | 129 ------------------ .../heatmap/HistoryOverviewTextTest.kt | 28 +--- 4 files changed, 7 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt index 85c46ee..d126ded 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt @@ -2,35 +2,12 @@ package com.github.se.pooltrack.ui.history.heatmap import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.timestamp -import java.time.DayOfWeek import java.time.LocalDate import java.time.ZoneId -import java.time.temporal.TemporalAdjusters /** Number of intensity steps above "no visit"; levels run from 0 (none) to [MAX_LEVEL]. */ const val MAX_LEVEL = 4 -/** Weeks shown by default, so the grid covers a little over a year like GitHub's. */ -const val DEFAULT_WEEK_COUNT = 53 - -/** - * One cell of the heatmap. - * - * @property date The calendar day. - * @property count How many entries were confirmed that day. - * @property level The intensity step from 0 to [MAX_LEVEL], see [intensityLevel]. - */ -data class HeatmapDay(val date: LocalDate, val count: Int, val level: Int) - -/** - * The heatmap grid: one list per week, oldest first, each with seven slots in the order of - * [firstDayOfWeek]. A slot is `null` for days that are after today. - */ -data class Heatmap( - val weeks: List>, - val firstDayOfWeek: DayOfWeek, -) - /** Counts [entries] per calendar day, resolving days in [zone]. */ fun entryCountsByDay( entries: List, @@ -48,81 +25,3 @@ fun intensityLevel(count: Int, maxCount: Int, scaleFloor: Int = MAX_LEVEL): Int val scale = maxOf(maxCount, scaleFloor) return ((count * MAX_LEVEL + scale - 1) / scale).coerceIn(1, MAX_LEVEL) } - -/** - * Builds the grid ending with the week of [today] and spanning [weekCount] weeks. - * - * @param countsByDay Entries per day, see [entryCountsByDay]. - * @param firstDayOfWeek The day each column starts on, usually the locale's. - */ -fun buildHeatmap( - countsByDay: Map, - today: LocalDate, - weekCount: Int = DEFAULT_WEEK_COUNT, - firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, -): Heatmap { - require(weekCount > 0) { "weekCount must be positive" } - val lastWeekStart = today.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) - val firstWeekStart = lastWeekStart.minusWeeks(weekCount - 1L) - val maxCount = - countsByDay.filterKeys { it >= firstWeekStart && it <= today }.values.maxOrNull() ?: 0 - - val weeks = - (0 until weekCount).map { week -> - val weekStart = firstWeekStart.plusWeeks(week.toLong()) - (0 until 7).map { offset -> - val date = weekStart.plusDays(offset.toLong()) - if (date.isAfter(today)) null - else { - val count = countsByDay[date] ?: 0 - HeatmapDay(date, count, intensityLevel(count, maxCount)) - } - } - } - return Heatmap(weeks, firstDayOfWeek) -} - -/** - * A summary of the visible [Heatmap] range. - * - * @property visits Entries in the range. - * @property activeDays Days with at least one entry. - * @property currentWeekStreak Consecutive weeks with a visit, ending this week, or last week if - * this one has none yet (so the streak doesn't read 0 every Monday morning). - * @property longestWeekStreak The longest run of consecutive weeks with a visit in the range. - */ -data class HeatmapSummary( - val visits: Int, - val activeDays: Int, - val currentWeekStreak: Int, - val longestWeekStreak: Int, -) - -/** Summarizes [heatmap]. Streaks are by week, since swimming every single day is unrealistic. */ -fun summarize(heatmap: Heatmap): HeatmapSummary { - val days = heatmap.weeks.flatten().filterNotNull() - val activeWeeks = heatmap.weeks.map { week -> week.any { (it?.count ?: 0) > 0 } } - - var longest = 0 - var run = 0 - for (active in activeWeeks) { - run = if (active) run + 1 else 0 - longest = maxOf(longest, run) - } - - val lastIndex = activeWeeks.lastIndex - val streakEnd = if (activeWeeks[lastIndex] || lastIndex == 0) lastIndex else lastIndex - 1 - var current = 0 - var index = streakEnd - while (index >= 0 && activeWeeks[index]) { - current++ - index-- - } - - return HeatmapSummary( - visits = days.sumOf { it.count }, - activeDays = days.count { it.count > 0 }, - currentWeekStreak = current, - longestWeekStreak = longest, - ) -} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt index e1096f5..a3d5c37 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -34,13 +34,8 @@ object HistoryOverviewTestTags { const val TOGGLE = "HistoryOverviewToggle" } -/** The week-streak sentence ("4-week streak"), or `null` when there is no streak yet. */ -fun streakText(summary: HeatmapSummary): String? = - if (summary.currentWeekStreak >= 2) "${summary.currentWeekStreak}-week streak" else null - -/** The one-line summary: "5 this week · 4-week streak". */ -fun overviewSummary(visitsThisWeek: Int, summary: HeatmapSummary): String = - listOfNotNull("$visitsThisWeek this week", streakText(summary)).joinToString(" · ") +/** The one-line summary: "5 this week". */ +fun overviewSummary(visitsThisWeek: Int): String = "$visitsThisWeek this week" /** * The card on top of the History list. By default it is compact: a one-line summary and the current @@ -62,10 +57,6 @@ fun HistoryOverview( modifier: Modifier = Modifier, ) { var expanded by rememberSaveable { mutableStateOf(false) } - val summary = - remember(countsByDay, today, firstDayOfWeek) { - summarize(buildHeatmap(countsByDay, today, firstDayOfWeek = firstDayOfWeek)) - } val visitsThisWeek = remember(countsByDay, today, firstDayOfWeek) { buildWeekStrip(today, countsByDay, firstDayOfWeek).sumOf { it.count } @@ -83,7 +74,7 @@ fun HistoryOverview( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = overviewSummary(visitsThisWeek, summary), + text = overviewSummary(visitsThisWeek), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).testTag(HistoryOverviewTestTags.SUMMARY), diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt index 9e88e3b..9c03591 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt @@ -1,23 +1,13 @@ package com.github.se.pooltrack.ui.history.heatmap import com.github.se.pooltrack.model.entry.Entry -import java.time.DayOfWeek import java.time.LocalDate import java.time.ZoneId import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows import org.junit.Test class HeatmapModelTest { - // Wednesday 2026-10-07. - private val today = LocalDate.of(2026, 10, 7) - - private fun build(counts: Map = emptyMap(), weeks: Int = 5) = - buildHeatmap(counts, today, weeks, DayOfWeek.MONDAY) - @Test fun intensityLevel_isZero_withoutEntries() { assertEquals(0, intensityLevel(count = 0, maxCount = 10)) @@ -51,123 +41,4 @@ class HeatmapModelTest { assertEquals(mapOf(LocalDate.of(2026, 10, 5) to 2, LocalDate.of(2026, 10, 6) to 1), counts) } - - @Test - fun buildHeatmap_hasOneColumnPerWeekOfSevenSlots() { - val heatmap = build(weeks = 5) - - assertEquals(5, heatmap.weeks.size) - assertEquals(listOf(7, 7, 7, 7, 7), heatmap.weeks.map { it.size }) - } - - @Test - fun buildHeatmap_endsWithTheWeekOfToday_andHidesTheFuture() { - val lastWeek = build().weeks.last() - - // Monday..Wednesday exist, Thursday..Sunday are in the future. - assertEquals(LocalDate.of(2026, 10, 5), lastWeek[0]?.date) - assertEquals(today, lastWeek[2]?.date) - assertNull(lastWeek[3]) - assertNull(lastWeek[6]) - } - - @Test - fun buildHeatmap_startsColumnsOnTheGivenFirstDayOfWeek() { - val sundayFirst = buildHeatmap(emptyMap(), today, weekCount = 3, DayOfWeek.SUNDAY) - - assertEquals(LocalDate.of(2026, 10, 4), sundayFirst.weeks.last()[0]?.date) - assertEquals(LocalDate.of(2026, 9, 20), sundayFirst.weeks.first()[0]?.date) - } - - @Test - fun buildHeatmap_assignsCountAndLevelToEachDay() { - val monday = LocalDate.of(2026, 10, 5) - val heatmap = build(mapOf(monday to 2)) - - val cell = heatmap.weeks.last()[0] - assertNotNull(cell) - assertEquals(2, cell!!.count) - assertEquals(2, cell.level) - assertEquals(0, heatmap.weeks.last()[1]?.count) - assertEquals(0, heatmap.weeks.last()[1]?.level) - } - - @Test - fun buildHeatmap_ignoresEntriesOutsideTheRange() { - val tooOld = LocalDate.of(2020, 1, 1) - val heatmap = build(mapOf(tooOld to 50)) - - assertEquals(0, summarize(heatmap).visits) - // The ancient busy day must not flatten the scale of the visible range. - assertEquals(1, build(mapOf(tooOld to 50, today to 1)).weeks.last()[2]?.level) - } - - @Test - fun buildHeatmap_rejectsNonPositiveWeekCount() { - assertThrows(IllegalArgumentException::class.java) { build(weeks = 0) } - } - - @Test - fun summarize_countsVisitsAndActiveDays() { - val counts = - mapOf( - LocalDate.of(2026, 10, 5) to 2, - LocalDate.of(2026, 10, 6) to 1, - LocalDate.of(2026, 9, 1) to 1, - ) - - val summary = summarize(build(counts, weeks = 8)) - - assertEquals(4, summary.visits) - assertEquals(3, summary.activeDays) - } - - @Test - fun summarize_ofAnEmptyHeatmap_isAllZero() { - assertEquals(HeatmapSummary(0, 0, 0, 0), summarize(build())) - } - - @Test - fun summarize_countsConsecutiveWeeksAsAStreak() { - // Weeks starting 09-21, 09-28 and 10-05 all have a visit. - val counts = - mapOf( - LocalDate.of(2026, 9, 22) to 1, - LocalDate.of(2026, 9, 30) to 1, - LocalDate.of(2026, 10, 5) to 1, - ) - - val summary = summarize(build(counts)) - - assertEquals(3, summary.currentWeekStreak) - assertEquals(3, summary.longestWeekStreak) - } - - @Test - fun summarize_keepsTheStreakAlive_whenThisWeekHasNoVisitYet() { - val counts = mapOf(LocalDate.of(2026, 9, 29) to 1, LocalDate.of(2026, 9, 22) to 1) - - val summary = summarize(build(counts)) - - assertEquals(2, summary.currentWeekStreak) - } - - @Test - fun summarize_resetsTheCurrentStreak_afterAMissedWeek() { - // Visits two and three weeks ago, none last week or this week. - val counts = mapOf(LocalDate.of(2026, 9, 22) to 1, LocalDate.of(2026, 9, 15) to 1) - - val summary = summarize(build(counts)) - - assertEquals(0, summary.currentWeekStreak) - assertEquals(2, summary.longestWeekStreak) - } - - @Test - fun summarize_worksWithASingleWeek() { - val summary = summarize(build(mapOf(today to 1), weeks = 1)) - - assertEquals(1, summary.currentWeekStreak) - assertEquals(1, summary.longestWeekStreak) - } } diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt index fc4b50a..06ce702 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt @@ -1,34 +1,14 @@ package com.github.se.pooltrack.ui.history.heatmap import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Test class HistoryOverviewTextTest { - private fun summary(current: Int = 0, longest: Int = 0) = - HeatmapSummary(visits = 0, activeDays = 0, current, longest) - - @Test - fun streakText_namesTheCurrentStreak_fromTwoWeeks() { - assertEquals("2-week streak", streakText(summary(current = 2, longest = 2))) - assertEquals("4-week streak", streakText(summary(current = 4, longest = 9))) - } - - @Test - fun streakText_isNull_withoutAStreak() { - assertNull(streakText(summary(current = 1, longest = 5))) - assertNull(streakText(summary())) - } - - @Test - fun overviewSummary_countsTheWeek_andAddsTheStreak() { - assertEquals("5 this week · 4-week streak", overviewSummary(5, summary(current = 4))) - } - @Test - fun overviewSummary_isJustTheWeek_withoutAStreak() { - assertEquals("0 this week", overviewSummary(0, summary())) - assertEquals("1 this week", overviewSummary(1, summary(current = 1))) + fun overviewSummary_countsTheVisitsOfTheWeek() { + assertEquals("0 this week", overviewSummary(0)) + assertEquals("1 this week", overviewSummary(1)) + assertEquals("5 this week", overviewSummary(5)) } } From cb395c2b21a4bbc3eacfc9e75de7947e71b5ee21 Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Fri, 9 Oct 2026 19:06:40 +0200 Subject: [PATCH 19/19] Use soft tints for the calendar colors Fills are 40-65% tints for days and 15-60% for months instead of the full primary color, with the normal text color on top. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/history/heatmap/HeatmapLegend.kt | 31 ++++++++++--------- .../ui/history/heatmap/MonthCalendar.kt | 1 - .../ui/history/heatmap/YearCalendar.kt | 3 +- .../ui/history/heatmap/HeatmapColorTest.kt | 26 ++++++++++------ 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt index d274de4..6998974 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt @@ -15,28 +15,31 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** - * How opaque a day with one visit is. High, because most days have at most one visit: a faint fill - * would make every visited day look like it barely counted. + * How opaque the primary color is from the lightest filled level to the darkest. The fills are soft + * tints, never the full color, so the text on top stays in the normal text color and the card stays + * calm. */ -const val DAY_MIN_ALPHA = 0.7f +data class HeatmapPalette(val minAlpha: Float, val maxAlpha: Float) /** - * How opaque the lightest tile of the year view is; those span 1 to 12 visits, so it starts low. + * Days. Most days have at most one visit, so the lightest step is already clearly visible and the + * range is narrow. */ -const val YEAR_MIN_ALPHA = 0.25f +val DayPalette = HeatmapPalette(minAlpha = 0.40f, maxAlpha = 0.65f) -/** The opacity of the primary color at [level] (1 to [MAX_LEVEL]), from [minAlpha] up to 1. */ -fun levelAlpha(level: Int, minAlpha: Float): Float = - minAlpha + (1f - minAlpha) * (level.coerceIn(1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1) +/** Months in the year view, which span one visit to a dozen or more, so the range is wider. */ +val YearPalette = HeatmapPalette(minAlpha = 0.15f, maxAlpha = 0.60f) -/** - * The fill at [level], from nothing (0) to the busiest ([MAX_LEVEL]); the lightest filled level has - * the opacity [minAlpha]. - */ +/** The opacity of the primary color at [level] (1 to [MAX_LEVEL]) in [palette]. */ +fun levelAlpha(level: Int, palette: HeatmapPalette): Float = + palette.minAlpha + + (palette.maxAlpha - palette.minAlpha) * (level.coerceIn(1, MAX_LEVEL) - 1) / (MAX_LEVEL - 1) + +/** The fill at [level], from nothing (0) to the busiest ([MAX_LEVEL]), in [palette]. */ @Composable -fun heatmapColor(level: Int, minAlpha: Float = DAY_MIN_ALPHA): Color = +fun heatmapColor(level: Int, palette: HeatmapPalette = DayPalette): Color = if (level <= 0) Color.Transparent - else MaterialTheme.colorScheme.primary.copy(alpha = levelAlpha(level, minAlpha)) + else MaterialTheme.colorScheme.primary.copy(alpha = levelAlpha(level, palette)) /** The "Less, five dots, More" key explaining the colors. */ @Composable diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt index 2756865..2ded563 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -202,7 +202,6 @@ internal fun DayCell( val textColor = when { day.isFuture -> scheme.onSurface.copy(alpha = 0.3f) - day.level >= 1 -> scheme.onPrimary else -> scheme.onSurface } val ring = diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt index 66a6f26..3629ec5 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt @@ -153,7 +153,6 @@ private fun MonthTileCell( val textColor = when { tile.isFuture -> scheme.onSurface.copy(alpha = 0.3f) - tile.level >= 3 -> scheme.onPrimary else -> scheme.onSurface } Box( @@ -163,7 +162,7 @@ private fun MonthTileCell( .aspectRatio(1.35f) .background( if (tile.level == 0) scheme.onSurface.copy(alpha = 0.06f) - else heatmapColor(tile.level, YEAR_MIN_ALPHA), + else heatmapColor(tile.level, YearPalette), TileShape, ) .then( diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt index 65b86af..b734ddb 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt @@ -7,14 +7,14 @@ import org.junit.Test class HeatmapColorTest { @Test - fun levelAlpha_startsAtTheMinimum_andEndsOpaque() { - assertEquals(0.7f, levelAlpha(1, DAY_MIN_ALPHA), 0.0001f) - assertEquals(1f, levelAlpha(MAX_LEVEL, DAY_MIN_ALPHA), 0.0001f) + fun levelAlpha_runsFromTheMinimumToTheMaximum() { + assertEquals(DayPalette.minAlpha, levelAlpha(1, DayPalette), 0.0001f) + assertEquals(DayPalette.maxAlpha, levelAlpha(MAX_LEVEL, DayPalette), 0.0001f) } @Test fun levelAlpha_growsWithTheLevel() { - val alphas = (1..MAX_LEVEL).map { levelAlpha(it, YEAR_MIN_ALPHA) } + val alphas = (1..MAX_LEVEL).map { levelAlpha(it, YearPalette) } assertEquals(alphas.sorted(), alphas) assertEquals(alphas.size, alphas.distinct().size) @@ -22,13 +22,21 @@ class HeatmapColorTest { @Test fun levelAlpha_clampsLevelsOutsideTheScale() { - assertEquals(levelAlpha(1, DAY_MIN_ALPHA), levelAlpha(0, DAY_MIN_ALPHA), 0.0001f) - assertEquals(levelAlpha(MAX_LEVEL, DAY_MIN_ALPHA), levelAlpha(9, DAY_MIN_ALPHA), 0.0001f) + assertEquals(levelAlpha(1, DayPalette), levelAlpha(0, DayPalette), 0.0001f) + assertEquals(levelAlpha(MAX_LEVEL, DayPalette), levelAlpha(9, DayPalette), 0.0001f) } @Test - fun aSingleVisitIsClearlyFilled_butTheYearViewStillSpreadsOut() { - assertTrue(levelAlpha(1, DAY_MIN_ALPHA) >= 0.6f) - assertTrue(levelAlpha(MAX_LEVEL, YEAR_MIN_ALPHA) - levelAlpha(1, YEAR_MIN_ALPHA) >= 0.6f) + fun everyFillIsASoftTint_neverTheFullColor() { + listOf(DayPalette, YearPalette).forEach { palette -> + assertTrue(palette.maxAlpha <= 0.7f) + assertTrue(palette.minAlpha > 0f) + } + } + + @Test + fun aSingleVisitIsVisible_andTheYearViewStillSpreadsOut() { + assertTrue(DayPalette.minAlpha >= 0.35f) + assertTrue(YearPalette.maxAlpha - YearPalette.minAlpha >= 0.4f) } }