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..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 @@ -32,15 +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 = day.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) 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/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/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/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/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) + } } 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)) + } +} 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))), + ) + } +}