From 67a6167de93705401ef64b82ea014228a311634e Mon Sep 17 00:00:00 2001 From: Sami Kabbaj Date: Thu, 8 Oct 2026 13:59:11 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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))