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/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/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/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..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 @@ -14,18 +14,32 @@ 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 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. + */ +data class HeatmapPalette(val minAlpha: Float, val maxAlpha: Float) + +/** + * Days. Most days have at most one visit, so the lightest step is already clearly visible and the + * range is narrow. + */ +val DayPalette = HeatmapPalette(minAlpha = 0.40f, maxAlpha = 0.65f) + +/** 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 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): 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, palette: HeatmapPalette = DayPalette): Color = + if (level <= 0) Color.Transparent + 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/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 8973c7e..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 @@ -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,21 @@ 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. */ -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 one-line summary: "5 this week". */ +fun overviewSummary(visitsThisWeek: Int): String = "$visitsThisWeek this week" /** - * 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 +54,62 @@ fun HistoryOverview( firstDayOfWeek: DayOfWeek, selectedDate: LocalDate?, onDayClick: (LocalDate) -> Unit, - onClearSelection: () -> Unit, modifier: Modifier = Modifier, - selectedLabel: (LocalDate) -> String = { it.toString() }, ) { - val summary = + var expanded by rememberSaveable { mutableStateOf(false) } + val visitsThisWeek = remember(countsByDay, today, firstDayOfWeek) { - summarize(buildHeatmap(countsByDay, today, firstDayOfWeek = 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), + 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..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 @@ -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,20 +160,27 @@ 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(day, selected = day.date == selectedDate) { onDayClick(day.date) } + DayCell( + day, + selected = day.date == selectedDate, + onClick = { onDayClick(day.date) }, + ) } } } @@ -187,12 +192,16 @@ 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 { day.isFuture -> scheme.onSurface.copy(alpha = 0.3f) - day.level >= 3 -> scheme.onPrimary else -> scheme.onSurface } val ring = @@ -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/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..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), + else heatmapColor(tile.level, YearPalette), TileShape, ) .then( 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)) + } +} 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() 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..b734ddb --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapColorTest.kt @@ -0,0 +1,42 @@ +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_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, YearPalette) } + + assertEquals(alphas.sorted(), alphas) + assertEquals(alphas.size, alphas.distinct().size) + } + + @Test + fun levelAlpha_clampsLevelsOutsideTheScale() { + assertEquals(levelAlpha(1, DayPalette), levelAlpha(0, DayPalette), 0.0001f) + assertEquals(levelAlpha(MAX_LEVEL, DayPalette), levelAlpha(9, DayPalette), 0.0001f) + } + + @Test + 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) + } +} 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 e36680e..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,38 +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(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())) + fun overviewSummary_countsTheVisitsOfTheWeek() { + assertEquals("0 this week", overviewSummary(0)) + assertEquals("1 this week", overviewSummary(1)) + assertEquals("5 this week", overviewSummary(5)) } } 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) + } }