diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt new file mode 100644 index 0000000..a126d9c --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -0,0 +1,232 @@ +package com.github.se.pooltrack.ui.history + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.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 + +/** The first day of the week that contains [day], where weeks start on [firstDayOfWeek]. */ +internal fun weekStart(day: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = + day.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + +/** + * Names the week starting on [weekStart]: "This week", "Last week", or its dates, e.g. "Oct 5 – 11" + * or "Sep 28 – Oct 4". The year is added when the week isn't in [today]'s year (and both years when + * it spans New Year's Eve). + */ +internal fun weekLabel( + weekStart: LocalDate, + today: LocalDate, + firstDayOfWeek: DayOfWeek, + locale: Locale = Locale.getDefault(), +): String { + val thisWeek = weekStart(today, firstDayOfWeek) + if (weekStart == thisWeek) return "This week" + if (weekStart == thisWeek.minusWeeks(1)) return "Last week" + + val end = weekStart.plusDays(6) + fun format(date: LocalDate, pattern: String) = + date.format(DateTimeFormatter.ofPattern(pattern, locale)) + return when { + weekStart.year != end.year -> + "${format(weekStart, "MMM d, yyyy")} – ${format(end, "MMM d, yyyy")}" + else -> { + val range = + if (weekStart.month == end.month) "${format(weekStart, "MMM d")} – ${end.dayOfMonth}" + else "${format(weekStart, "MMM d")} – ${format(end, "MMM d")}" + if (weekStart.year == today.year) range else "$range, ${weekStart.year}" + } + } +} + +/** The sticky title above the entries of one week, with how many visits it had. */ +@Composable +internal fun WeekHeader(label: String, visits: Int, meters: Int, modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.WEEK_HEADER), + color = MaterialTheme.colorScheme.background, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = weekTotals(visits, meters), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** The weekday and day of the month, stacked ("THU" over "8"); today's is tinted. */ +@Composable +private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale.getDefault()) { + val scheme = MaterialTheme.colorScheme + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.width(48.dp) + .background( + if (isToday) scheme.primaryContainer + else scheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(10.dp), + ) + .padding(vertical = 6.dp), + ) { + Text( + text = day.format(DateTimeFormatter.ofPattern("EEE", locale)).uppercase(locale), + style = MaterialTheme.typography.labelSmall, + color = if (isToday) scheme.onPrimaryContainer else scheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + text = day.dayOfMonth.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isToday) scheme.onPrimaryContainer else scheme.onSurface, + ) + } +} + +/** + * A flat, compact row for one entry: the day it happened on, then its time and swim details. Tap it + * to open the entry; swipe it to the left to delete it (the caller offers an Undo). Screen readers + * get a "Delete entry" action instead of the swipe. + * + * @param day The day of the entry, shown in the date block. + * @param isToday Whether [day] is today. + * @param 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. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SwipeableEntryRow( + day: LocalDate, + isToday: Boolean, + texts: EntryRowTexts, + highlighted: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val dismissState = + rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onDelete() + true + } else false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + modifier = + modifier.testTag(HistoryScreenTestTags.ENTRY_ITEM).semantics { + customActions = + listOf( + CustomAccessibilityAction("Delete entry") { + onDelete() + true + } + ) + }, + backgroundContent = { + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.padding(end = 20.dp), + ) + } + }, + ) { + Surface( + onClick = onClick, + color = + 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) + 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, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } +} + +/** "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 6f1c47c..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,23 +9,18 @@ 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.filled.CheckCircle -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.DateRange -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -38,37 +33,35 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.github.se.pooltrack.model.entry.Entry 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 import java.time.format.DateTimeFormatter -import java.time.format.DateTimeFormatterBuilder import java.time.format.FormatStyle +import java.time.temporal.WeekFields import java.util.Locale +import kotlinx.coroutines.delay import kotlinx.coroutines.launch object HistoryScreenTestTags { const val EMPTY_MESSAGE = "HistoryScreenEmptyMessage" const val ENTRY_LIST = "HistoryScreenEntryList" - const val DAY_HEADER = "HistoryScreenDayHeader" + const val WEEK_HEADER = "HistoryScreenWeekHeader" const val ENTRY_ITEM = "HistoryScreenEntryItem" - const val DELETE_BUTTON = "HistoryScreenDeleteButton" - const val CONFIRM_DELETE_BUTTON = "HistoryScreenConfirmDeleteButton" - const val CANCEL_DELETE_BUTTON = "HistoryScreenCancelDeleteButton" const val ADD_PAST_ENTRY_BUTTON = "HistoryScreenAddPastEntryButton" const val UNDO_ADD_ACTION = "Undo" const val LINK_BANNER = "HistoryScreenLinkBanner" @@ -84,13 +77,6 @@ private fun entryTimeFormatter(): DateTimeFormatter = .withLocale(Locale.getDefault()) .withZone(ZONE) -private fun entryDateTimeFormatter(): DateTimeFormatter = - DateTimeFormatterBuilder() - .appendPattern("EEEE, ") - .append(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) - .toFormatter(Locale.getDefault()) - .withZone(ZONE) - /** HistoryScreen lists every confirmed pool entry, grouped by day, most recent first. */ @Composable fun HistoryScreen( @@ -98,8 +84,8 @@ fun HistoryScreen( navigationActions: NavigationActions? = null, ) { val entries by viewModel.entries.collectAsState() - var entryPendingDeletion by remember { mutableStateOf(null) } var showAddSheet by remember { mutableStateOf(false) } + 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)) } @@ -126,6 +112,21 @@ fun HistoryScreen( } } + // Deleting needs no confirmation: the swipe is deliberate and the snackbar offers an Undo. + val onDeleteEntry: (Entry) -> Unit = { entry -> + viewModel.onDeleteEntry(entry) + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val outcome = + snackbarHostState.showSnackbar( + message = "Entry deleted", + actionLabel = HistoryScreenTestTags.UNDO_ADD_ACTION, + withDismissAction = true, + ) + if (outcome == SnackbarResult.ActionPerformed) viewModel.onRestoreEntry(entry) + } + } + if (showAddSheet) { AddPastEntrySheet( initialDate = lastAddedDate, @@ -144,38 +145,6 @@ fun HistoryScreen( ) } - entryPendingDeletion?.let { entry -> - AlertDialog( - onDismissRequest = { entryPendingDeletion = null }, - title = { Text("Delete this entry?") }, - text = { - Text( - "The entry from ${entryDateTimeFormatter().format(entry.timestamp)} will be " + - "permanently removed." - ) - }, - confirmButton = { - TextButton( - onClick = { - viewModel.onDeleteEntry(entry) - entryPendingDeletion = null - }, - modifier = Modifier.testTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON), - ) { - Text("Delete") - } - }, - dismissButton = { - TextButton( - onClick = { entryPendingDeletion = null }, - modifier = Modifier.testTag(HistoryScreenTestTags.CANCEL_DELETE_BUTTON), - ) { - Text("Cancel") - } - }, - ) - } - Scaffold( topBar = { TopNavigationMenu(Screen.History) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -220,15 +189,54 @@ fun HistoryScreen( } } else { val today = LocalDate.now(ZONE) - val entriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } + val firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek + 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) .testTag(HistoryScreenTestTags.ENTRY_LIST), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 88.dp), ) { + item(key = "overview") { + HistoryOverview( + countsByDay = countsByDay, + today = today, + firstDayOfWeek = firstDayOfWeek, + selectedDate = highlightedDate, + onDayClick = onDayPicked, + modifier = Modifier.padding(vertical = 4.dp), + ) + } if (linkSuggestions.isNotEmpty()) { item(key = "link-banner") { LinkBanner( @@ -253,17 +261,33 @@ fun HistoryScreen( ) } } - entriesByDay.forEach { (day, entriesForDay) -> - item(key = day.toEpochDay()) { - DayHeader(label = dayLabel(day, today), entryCount = entriesForDay.size) - } - items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> - EntryRow( - entry = entry, - onClick = { navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) }, - onDelete = { entryPendingDeletion = entry }, - modifier = Modifier.padding(vertical = 4.dp), - ) + 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 = row.day, + isToday = row.day == today, + texts = + entryRowTexts( + row.entry, + entryTimeFormatter().format(row.entry.timestamp), + ), + highlighted = row.day == highlightedDate, + onClick = { + navigationActions?.navigateToEntryDetails(row.entry.timestampEpochMilli) + }, + onDelete = { onDeleteEntry(row.entry) }, + ) + } } } } @@ -297,34 +321,8 @@ private fun LinkBanner(count: Int, onLink: () -> Unit, modifier: Modifier = Modi } } -/** Section header grouping entries from the same calendar day. */ -@Composable -private fun DayHeader(label: String, entryCount: Int) { - Surface( - modifier = Modifier.fillMaxWidth().testTag(HistoryScreenTestTags.DAY_HEADER), - color = MaterialTheme.colorScheme.background, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = if (entryCount == 1) "1 entry" else "$entryCount entries", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - /** "Today", "Yesterday", or a weekday/date, omitting the year unless [day] isn't this year. */ -private fun dayLabel(day: LocalDate, today: LocalDate): String = +internal fun dayLabel(day: LocalDate, today: LocalDate): String = when (day) { today -> "Today" today.minusDays(1) -> "Yesterday" @@ -334,56 +332,8 @@ private fun dayLabel(day: LocalDate, today: LocalDate): String = } } -/** - * The entry time, followed by the swim duration (old entries) and the logged distance, whichever - * were recorded: "18:42 · 52min · 1200 m". - */ -private fun entryRowLabel(entry: Entry): String = - 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 -@Composable -private fun EntryRow( - entry: Entry, - onClick: () -> Unit, - onDelete: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - onClick = onClick, - modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.ENTRY_ITEM), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = entryRowLabel(entry), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), - ) - } - IconButton( - onClick = onDelete, - modifier = Modifier.testTag(HistoryScreenTestTags.DELETE_BUTTON), - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete entry", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } -} +/** 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/HistoryViewModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt index 6bec86a..f7d955b 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt @@ -96,6 +96,11 @@ class HistoryViewModel( viewModelScope.launch { repository.deleteEntry(entry) } } + /** Puts back [entry] exactly as it was, to undo [onDeleteEntry]. */ + fun onRestoreEntry(entry: Entry) { + viewModelScope.launch { repository.addEntry(entry) } + } + /** * Records an entry that happened at [timestamp], e.g. one that was forgotten at the time. It is * refused if [timestamp] is after [now] or an entry already exists at that exact instant (see diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt new file mode 100644 index 0000000..6998974 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapLegend.kt @@ -0,0 +1,72 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 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, 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 +fun HeatmapLegend(modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Less", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + (0..MAX_LEVEL).forEach { level -> + Box( + modifier = + Modifier.padding(start = 4.dp) + .size(12.dp) + .background( + if (level == 0) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else heatmapColor(level), + CircleShape, + ) + ) + } + Text( + text = "More", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt new file mode 100644 index 0000000..d126ded --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModel.kt @@ -0,0 +1,27 @@ +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.LocalDate +import java.time.ZoneId + +/** Number of intensity steps above "no visit"; levels run from 0 (none) to [MAX_LEVEL]. */ +const val MAX_LEVEL = 4 + +/** Counts [entries] per calendar day, resolving days in [zone]. */ +fun entryCountsByDay( + entries: List, + zone: ZoneId = ZoneId.systemDefault(), +): Map = entries.groupingBy { it.timestamp.atZone(zone).toLocalDate() }.eachCount() + +/** + * The intensity step of a day with [count] entries. The scale is absolute (one entry is level 1, + * two are level 2, ...) until a day beats [scaleFloor], after which it stretches so that the + * busiest day is [MAX_LEVEL]. An absolute scale matters here: most days have a single visit, and a + * purely relative one would paint every cell with the darkest color. + */ +fun intensityLevel(count: Int, maxCount: Int, scaleFloor: Int = MAX_LEVEL): Int { + if (count <= 0) return 0 + val scale = maxOf(maxCount, scaleFloor) + return ((count * MAX_LEVEL + scale - 1) / scale).coerceIn(1, MAX_LEVEL) +} 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 new file mode 100644 index 0000000..a3d5c37 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverview.kt @@ -0,0 +1,115 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.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 +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +object HistoryOverviewTestTags { + const val CARD = "HistoryOverviewCard" + const val SUMMARY = "HistoryOverviewSummary" + const val TOGGLE = "HistoryOverviewToggle" +} + +/** The one-line summary: "5 this week". */ +fun overviewSummary(visitsThisWeek: Int): String = "$visitsThisWeek this week" + +/** + * The card on top of the History list. By default it is compact: a one-line summary and the current + * 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 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( + countsByDay: Map, + today: LocalDate, + firstDayOfWeek: DayOfWeek, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val visitsThisWeek = + remember(countsByDay, today, firstDayOfWeek) { + buildWeekStrip(today, countsByDay, firstDayOfWeek).sumOf { it.count } + } + val months = + remember(countsByDay, today) { + monthsBetween(YearMonth.from(countsByDay.keys.minOrNull() ?: today), YearMonth.from(today)) + } + + Card(modifier = modifier.fillMaxWidth().testTag(HistoryOverviewTestTags.CARD)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + 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, + ) + } + } + 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 new file mode 100644 index 0000000..2ded563 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthCalendar.kt @@ -0,0 +1,237 @@ +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 +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.launch + +object MonthCalendarTestTags { + const val CALENDAR = "MonthCalendar" + const val TITLE = "MonthCalendarTitle" + const val PREVIOUS = "MonthCalendarPrevious" + const val NEXT = "MonthCalendarNext" + const val ZOOM_OUT = "MonthCalendarZoomOut" + + fun day(date: LocalDate) = "MonthCalendarDay_$date" +} + +/** + * A month calendar in the spirit of the iPhone's: a title with previous/next buttons, a row of + * weekday initials and a grid of round days that fills the width. Days with entries are filled, + * darker for more entries. Swipe sideways or use the buttons to change month. + * + * @param months The months that can be shown, oldest first; it opens on the last one. + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param today The current day, marked with a ring; later days can't be selected. + * @param selectedDate The highlighted day, if any. + * @param onDayClick Called with the tapped day. + * @param firstDayOfWeek The weekday the columns start on, usually the locale's. + * @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( + months: List, + countsByDay: Map, + today: LocalDate, + selectedDate: LocalDate?, + onDayClick: (LocalDate) -> Unit, + 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 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, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = + "${current.month.getDisplayName(TextStyle.FULL_STANDALONE, locale)} ${current.year}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(MonthCalendarTestTags.TITLE), + ) + val visits = countsByDay.filterKeys { YearMonth.from(it) == current }.values.sum() + Text( + text = + when (visits) { + 0 -> "No visits" + 1 -> "1 visit" + else -> "$visits visits" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, + enabled = pagerState.currentPage > 0, + modifier = Modifier.testTag(MonthCalendarTestTags.PREVIOUS), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous month") + } + IconButton( + onClick = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, + enabled = pagerState.currentPage < months.lastIndex, + modifier = Modifier.testTag(MonthCalendarTestTags.NEXT), + ) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next month") + } + } + + Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + (0 until 7).forEach { offset -> + Text( + text = firstDayOfWeek.plus(offset.toLong()).getDisplayName(TextStyle.NARROW, locale), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + + // 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()) { + grid.weeks.forEach { week -> + Row(modifier = Modifier.fillMaxWidth()) { + (0 until 7).forEach { column -> + val day = week[column] + Box(modifier = Modifier.weight(1f).aspectRatio(1f).padding(3.dp)) { + if (day != null) { + DayCell( + day, + selected = day.date == selectedDate, + onClick = { onDayClick(day.date) }, + ) + } + } + } + } + } + } + } + } +} + +@Composable +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) + else -> scheme.onSurface + } + val ring = + when { + selected -> BorderStroke(2.dp, scheme.onSurface) + day.isToday -> BorderStroke(1.5.dp, scheme.primary) + else -> null + } + val description = + when (day.count) { + 0 -> "${day.date}: no entries" + 1 -> "${day.date}: 1 entry" + else -> "${day.date}: ${day.count} entries" + } + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.fillMaxWidth() + .aspectRatio(1f) + .background(heatmapColor(day.level), CircleShape) + .then(if (ring != null) Modifier.border(ring, CircleShape) else Modifier) + .then(if (day.isFuture) Modifier else Modifier.clickable(onClick = onClick)) + .semantics { contentDescription = description } + .testTag(testTag), + ) { + Text( + text = day.date.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (day.isToday) FontWeight.Bold else FontWeight.Normal, + color = textColor, + ) + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt new file mode 100644 index 0000000..9d96954 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/MonthModel.kt @@ -0,0 +1,93 @@ +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]. + * + * @property level The intensity step from 0 to [MAX_LEVEL], see [intensityLevel]. + * @property isFuture Whether the day is after today, so nothing can have happened yet. + */ +data class MonthDay( + val date: LocalDate, + val count: Int, + val level: Int, + val isToday: Boolean, + val isFuture: Boolean, +) + +/** + * A month laid out like a wall calendar: [weeks] of seven slots each, starting on a given weekday, + * with `null` for the slots before the 1st and after the last day. + */ +data class MonthGrid(val month: YearMonth, val weeks: List>) { + /** Entries confirmed during the month. */ + val visits: Int + get() = weeks.sumOf { week -> week.sumOf { it?.count ?: 0 } } +} + +/** + * Lays out [month]. + * + * @param countsByDay Entries per day, see [entryCountsByDay]. + * @param maxCount The busiest day to scale the intensity against; pass the same value for every + * month so that colors mean the same thing from one month to the next. + */ +fun buildMonthGrid( + month: YearMonth, + countsByDay: Map, + today: LocalDate, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + maxCount: Int = countsByDay.values.maxOrNull() ?: 0, +): MonthGrid { + val leadingBlanks = (month.atDay(1).dayOfWeek.value - firstDayOfWeek.value + 7) % 7 + val slots: List = + List(leadingBlanks) { null } + + (1..month.lengthOfMonth()).map { dayOfMonth -> + val date = month.atDay(dayOfMonth) + val count = countsByDay[date] ?: 0 + MonthDay( + date = date, + count = count, + level = intensityLevel(count, maxCount), + isToday = date == today, + isFuture = date.isAfter(today), + ) + } + val padded = slots + List((7 - slots.size % 7) % 7) { null } + return MonthGrid(month, padded.chunked(7)) +} + +/** Every month from [first] to [last], oldest first; just [last] if [first] is after it. */ +fun monthsBetween(first: YearMonth, last: YearMonth): List { + if (first.isAfter(last)) return listOf(last) + return generateSequence(first) { it.plusMonths(1) }.takeWhile { !it.isAfter(last) }.toList() +} + +/** + * 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/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/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 new file mode 100644 index 0000000..3629ec5 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/heatmap/YearCalendar.kt @@ -0,0 +1,192 @@ +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) + 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, YearPalette), + 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 new file mode 100644 index 0000000..cd16b75 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt @@ -0,0 +1,66 @@ +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 +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryListItemsTest { + + // Wednesday 2026-10-07; with Monday first, this week started on 2026-10-05. + private val today = LocalDate.of(2026, 10, 7) + private val monday = DayOfWeek.MONDAY + + private fun label(weekStart: LocalDate, first: DayOfWeek = monday, now: LocalDate = today) = + weekLabel(weekStart, now, first, Locale.ENGLISH) + + @Test + fun weekStart_isTheFirstDayOfWeekOnOrBefore() { + assertEquals(LocalDate.of(2026, 10, 5), weekStart(today, monday)) + assertEquals(LocalDate.of(2026, 10, 5), weekStart(LocalDate.of(2026, 10, 5), monday)) + assertEquals(LocalDate.of(2026, 10, 5), weekStart(LocalDate.of(2026, 10, 11), monday)) + assertEquals(LocalDate.of(2026, 10, 4), weekStart(today, DayOfWeek.SUNDAY)) + } + + @Test + fun weekLabel_namesThisWeekAndLastWeek() { + assertEquals("This week", label(LocalDate.of(2026, 10, 5))) + assertEquals("Last week", label(LocalDate.of(2026, 9, 28))) + } + + @Test + fun weekLabel_showsTheDayRange_withinAMonth() { + assertEquals("Sep 14 – 20", label(LocalDate.of(2026, 9, 14))) + } + + @Test + fun weekLabel_showsBothMonths_whenTheWeekCrossesOne() { + assertEquals("Aug 31 – Sep 6", label(LocalDate.of(2026, 8, 31))) + } + + @Test + fun weekLabel_addsTheYear_forOtherYears() { + assertEquals("Oct 6 – 12, 2025", label(LocalDate.of(2025, 10, 6))) + } + + @Test + fun weekLabel_showsBothYears_whenTheWeekSpansNewYear() { + assertEquals("Dec 28, 2025 – Jan 3, 2026", label(LocalDate.of(2025, 12, 28), now = today)) + } + + @Test + fun weekLabel_followsTheFirstDayOfWeek() { + // With Sunday first, "this week" began on Sunday 2026-10-04. + assertEquals("This week", label(LocalDate.of(2026, 10, 4), DayOfWeek.SUNDAY)) + assertEquals("Last week", label(LocalDate.of(2026, 9, 27), DayOfWeek.SUNDAY)) + } + + @Test + fun visitsLabel_isSingularOnlyForOne() { + assertEquals("1 visit", visitsLabel(1)) + assertEquals("2 visits", visitsLabel(2)) + assertEquals("0 visits", visitsLabel(0)) + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/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 a85bdc4..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 @@ -2,6 +2,8 @@ package com.github.se.pooltrack.ui.history import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag @@ -10,10 +12,16 @@ import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.Subscription 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 import com.github.se.pooltrack.ui.navigation.Screen @@ -24,6 +32,7 @@ import io.mockk.mockk import io.mockk.verify import java.time.Instant import java.time.LocalDate +import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -34,7 +43,10 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +// A tall screen: the heatmap card sits above the list and would push the rows out of view. +@Config(qualifiers = "w360dp-h1200dp") @RunWith(RobolectricTestRunner::class) class HistoryScreenRobolectricTest { @@ -72,72 +84,60 @@ class HistoryScreenRobolectricTest { } @Test - fun historyScreen_showsEntriesGroupedByDay_whenEntriesRecorded() { - val yesterdayEntry = entryAt(today.minusDays(1), hour = 9) - val todayMorning = entryAt(today, hour = 8) - val todayEvening = entryAt(today, hour = 18) - show(FakeEntryRepository(listOf(yesterdayEntry, todayMorning, todayEvening))) + fun historyScreen_listsEntriesUnderWeekHeaders_withTheirVisitCounts() { + val thisWeekMorning = entryAt(today, hour = 8) + val thisWeekEvening = entryAt(today, hour = 18) + val older = entryAt(today.minusWeeks(3), hour = 9) + show(FakeEntryRepository(listOf(older, thisWeekMorning, thisWeekEvening))) composeTestRule.onNodeWithTag(HistoryScreenTestTags.ENTRY_LIST).assertIsDisplayed() composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.EMPTY_MESSAGE).assertCountEquals(0) - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(2) + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.WEEK_HEADER).assertCountEquals(2) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(3) - composeTestRule.onNodeWithText("Today").assertIsDisplayed() - composeTestRule.onNodeWithText("Yesterday").assertIsDisplayed() - composeTestRule.onNodeWithText("2 entries").assertIsDisplayed() - composeTestRule.onNodeWithText("1 entry").assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(todayMorning)).assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(todayEvening)).assertIsDisplayed() - composeTestRule.onNodeWithText(timeLabel(yesterdayEntry)).assertIsDisplayed() + composeTestRule.onNodeWithText("This week").assertIsDisplayed() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and hasAnyDescendant(hasText("2 visits")) + ) + .assertExists() + composeTestRule + .onNode( + hasTestTag(HistoryScreenTestTags.WEEK_HEADER) and hasAnyDescendant(hasText("1 visit")) + ) + .assertExists() + composeTestRule.onNodeWithText(timeLabel(thisWeekMorning)).assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(thisWeekEvening)).assertIsDisplayed() + composeTestRule.onNodeWithText(timeLabel(older)).assertIsDisplayed() } @Test - fun historyScreen_labelsOlderDaysWithoutYear_whenSameYear() { - val sameYearDay = listOf(today.minusDays(2), today.plusDays(2)).first { it.year == today.year } - show(FakeEntryRepository(listOf(entryAt(sameYearDay)))) + fun historyScreen_namesTheWeekBeforeThisOne_lastWeek() { + show(FakeEntryRepository(listOf(entryAt(today.minusWeeks(1))))) - val expected = - sameYearDay.format(DateTimeFormatter.ofPattern("EEEE, MMM d", Locale.getDefault())) - composeTestRule.onNodeWithText(expected).assertIsDisplayed() + composeTestRule.onNodeWithText("Last week").assertIsDisplayed() } - @Test - fun historyScreen_labelsOlderDaysWithYear_whenDifferentYear() { - val lastYearDay = today.minusYears(1) - show(FakeEntryRepository(listOf(entryAt(lastYearDay)))) - - val expected = - lastYearDay.format(DateTimeFormatter.ofPattern("EEEE, MMM d, yyyy", Locale.getDefault())) - composeTestRule.onNodeWithText(expected).assertIsDisplayed() - } - - @Test - fun historyScreen_showsConfirmationDialog_whenDeleteTapped() { - val entry = entryAt(today) - val repository = FakeEntryRepository(listOf(entry)) - show(repository) - - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - - composeTestRule.onNodeWithText("Delete this entry?").assertIsDisplayed() - composeTestRule.onNode(hasText("permanently removed.", substring = true)).assertIsDisplayed() - assertEquals(listOf(entry), repository.storedEntries) + private fun swipeAwayFirstEntry() { + composeTestRule + .onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM) + .onFirst() + .performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() } @Test - fun historyScreen_deletesOnlyThatEntry_whenDeleteConfirmed() { + fun historyScreen_deletesOnlyThatEntry_whenSwipedAway() { val keep = entryAt(today, hour = 8) val remove = entryAt(today, hour = 18) val repository = FakeEntryRepository(listOf(keep, remove)) show(repository) - // Most recent first, so the first delete button belongs to the 18:00 entry. - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DELETE_BUTTON).onFirst().performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON).performClick() + // Most recent first, so the first row is the 18:00 entry. + swipeAwayFirstEntry() assertEquals(listOf(keep), repository.storedEntries) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - composeTestRule.onAllNodesWithText("Delete this entry?").assertCountEquals(0) + composeTestRule.onNodeWithText("Entry deleted").assertIsDisplayed() } @Test @@ -145,27 +145,37 @@ class HistoryScreenRobolectricTest { val repository = FakeEntryRepository(listOf(entryAt(today))) show(repository) - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CONFIRM_DELETE_BUTTON).performClick() + swipeAwayFirstEntry() assertEquals(emptyList(), repository.storedEntries) composeTestRule.onNodeWithTag(HistoryScreenTestTags.EMPTY_MESSAGE).assertIsDisplayed() } @Test - fun historyScreen_keepsEntry_whenDeleteCancelled() { - val entry = entryAt(today) + fun historyScreen_restoresTheEntryExactly_whenDeleteUndone() { + val entry = entryAt(today).copy(swimDistanceMeters = 900, swimDurationMillis = 60_000L) val repository = FakeEntryRepository(listOf(entry)) show(repository) + swipeAwayFirstEntry() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.DELETE_BUTTON).performClick() - composeTestRule.onNodeWithTag(HistoryScreenTestTags.CANCEL_DELETE_BUTTON).performClick() + composeTestRule.onNodeWithText(HistoryScreenTestTags.UNDO_ADD_ACTION).performClick() + composeTestRule.waitForIdle() assertEquals(listOf(entry), repository.storedEntries) - composeTestRule.onAllNodesWithText("Delete this entry?").assertCountEquals(0) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) } + @Test + fun historyScreen_doesNotDelete_whenTheRowIsOnlyTapped() { + val entry = entryAt(today) + val repository = FakeEntryRepository(listOf(entry)) + show(repository) + + composeTestRule.onNodeWithTag(HistoryScreenTestTags.ENTRY_ITEM).performClick() + + assertEquals(listOf(entry), repository.storedEntries) + } + @Test fun historyScreen_showsNewEntry_whenRepositoryChangesAfterComposition() { val repository = FakeEntryRepository() @@ -179,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 @@ -354,4 +387,91 @@ class HistoryScreenRobolectricTest { composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.LINK_BANNER).assertCountEquals(0) } + + private fun expandCalendar() { + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.TOGGLE).performClick() + composeTestRule.waitForIdle() + } + + @Test + fun historyScreen_showsACompactOverview_withTheWeekAndOneSummaryLine() { + show(FakeEntryRepository(listOf(entryAt(today)))) + + composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CARD).assertIsDisplayed() + 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_hidesTheOverview_whenThereAreNoEntries() { + show(FakeEntryRepository()) + + composeTestRule.onAllNodesWithTag(HistoryOverviewTestTags.CARD).assertCountEquals(0) + } + + @Test + fun historyScreen_expandsTheOverviewToTheMonthCalendar_andCollapsesIt() { + show(FakeEntryRepository(listOf(entryAt(today)))) + + expandCalendar() + + composeTestRule.onNodeWithTag(MonthCalendarTestTags.CALENDAR).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(WeekStripTestTags.STRIP).assertCountEquals(0) + + expandCalendar() + + composeTestRule.onNodeWithTag(WeekStripTestTags.STRIP).assertIsDisplayed() + composeTestRule.onAllNodesWithTag(MonthCalendarTestTags.CALENDAR).assertCountEquals(0) + } + + @Test + fun historyScreen_keepsEveryEntryListed_whenADayIsTapped() { + show(FakeEntryRepository(listOf(entryAt(today), entryAt(today.minusDays(40))))) + + 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_saysSoWhenTheTappedDayHasNoVisit() { + show(FakeEntryRepository(listOf(entryAt(today.minusDays(40))))) + + composeTestRule.onNodeWithTag(WeekStripTestTags.day(today)).performClick() + composeTestRule.waitForIdle() + + 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() + + 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)))) + expandCalendar() + 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/HistoryViewModelTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt index 289dfe4..4bd9cb1 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryViewModelTest.kt @@ -58,6 +58,18 @@ class HistoryViewModelTest { assertEquals(listOf(older), viewModel.entries.value) } + @Test + fun onRestoreEntry_putsBackADeletedEntryWithAllItsData() { + val detailed = newer.copy(swimDistanceMeters = 1200, swimDurationMillis = 3_000_000L) + val repository = FakeEntryRepository(listOf(older, detailed)) + val viewModel = HistoryViewModel(repository, FakeSubscriptionRepository()) + viewModel.onDeleteEntry(detailed) + + viewModel.onRestoreEntry(detailed) + + assertEquals(listOf(older, detailed), repository.storedEntries) + } + private val now = Instant.parse("2026-10-06T12:00:00Z") private val past = Instant.parse("2025-09-15T15:49:34Z") 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 new file mode 100644 index 0000000..9c03591 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HeatmapModelTest.kt @@ -0,0 +1,44 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import com.github.se.pooltrack.model.entry.Entry +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class HeatmapModelTest { + + @Test + fun intensityLevel_isZero_withoutEntries() { + assertEquals(0, intensityLevel(count = 0, maxCount = 10)) + } + + @Test + fun intensityLevel_isAbsolute_untilTheScaleFloor() { + assertEquals(listOf(1, 2, 3, 4), (1..4).map { intensityLevel(it, maxCount = 4) }) + // A single visit stays light even when it is the busiest day. + assertEquals(1, intensityLevel(count = 1, maxCount = 1)) + } + + @Test + fun intensityLevel_stretches_whenBusierDaysExist() { + assertEquals(4, intensityLevel(count = 8, maxCount = 8)) + assertEquals(2, intensityLevel(count = 4, maxCount = 8)) + assertEquals(1, intensityLevel(count = 1, maxCount = 8)) + } + + @Test + fun entryCountsByDay_groupsEntriesOfTheSameDay() { + val zone = ZoneId.of("UTC") + val entries = + listOf( + Entry(LocalDate.of(2026, 10, 5).atTime(8, 0).atZone(zone).toInstant().toEpochMilli()), + Entry(LocalDate.of(2026, 10, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli()), + Entry(LocalDate.of(2026, 10, 6).atTime(9, 0).atZone(zone).toInstant().toEpochMilli()), + ) + + val counts = entryCountsByDay(entries, zone) + + assertEquals(mapOf(LocalDate.of(2026, 10, 5) to 2, LocalDate.of(2026, 10, 6) to 1), counts) + } +} diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt new file mode 100644 index 0000000..06ce702 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/HistoryOverviewTextTest.kt @@ -0,0 +1,14 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryOverviewTextTest { + + @Test + 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 new file mode 100644 index 0000000..66cad4b --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/heatmap/MonthModelTest.kt @@ -0,0 +1,156 @@ +package com.github.se.pooltrack.ui.history.heatmap + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MonthModelTest { + + private val october = YearMonth.of(2026, 10) // 1st is a Thursday, 31 days + private val today = LocalDate.of(2026, 10, 7) + + private fun grid( + counts: Map = emptyMap(), + month: YearMonth = october, + firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY, + ) = buildMonthGrid(month, counts, today, firstDayOfWeek) + + @Test + fun buildMonthGrid_padsEveryWeekToSevenSlots() { + assertEquals(listOf(7, 7, 7, 7, 7), grid().weeks.map { it.size }) + } + + @Test + fun buildMonthGrid_leavesBlanksBeforeTheFirst_accordingToFirstDayOfWeek() { + val mondayFirst = grid().weeks.first() + assertEquals(listOf(null, null, null), mondayFirst.take(3)) + assertEquals(october.atDay(1), mondayFirst[3]?.date) + + val sundayFirst = grid(firstDayOfWeek = DayOfWeek.SUNDAY).weeks.first() + assertEquals(4, sundayFirst.indexOfFirst { it != null }) + } + + @Test + fun buildMonthGrid_startsRightAway_whenTheFirstIsTheFirstWeekday() { + // 2026-06-01 is a Monday. + val june = grid(month = YearMonth.of(2026, 6)) + assertEquals(LocalDate.of(2026, 6, 1), june.weeks.first().first()?.date) + } + + @Test + fun buildMonthGrid_containsEveryDayOnce_inOrder() { + val dates = grid().weeks.flatten().filterNotNull().map { it.date } + + assertEquals((1..31).map { october.atDay(it) }, dates) + } + + @Test + fun buildMonthGrid_leavesBlanksAfterTheLastDay() { + // 31 October is a Saturday, so with Monday first the last row has one blank (Sunday). + val lastWeek = grid().weeks.last() + assertEquals(october.atEndOfMonth(), lastWeek[5]?.date) + assertNull(lastWeek[6]) + } + + @Test + fun buildMonthGrid_needsFourWeeks_forAFebruaryThatFitsExactly() { + // 2027-02 has 28 days and starts on a Monday. + assertEquals(4, grid(month = YearMonth.of(2027, 2)).weeks.size) + } + + @Test + fun buildMonthGrid_marksTodayAndTheFuture() { + val days = grid().weeks.flatten().filterNotNull().associateBy { it.date.dayOfMonth } + + assertEquals(true, days.getValue(7).isToday) + assertEquals(false, days.getValue(6).isToday) + assertEquals(false, days.getValue(7).isFuture) + assertEquals(true, days.getValue(8).isFuture) + assertEquals(false, days.getValue(6).isFuture) + } + + @Test + fun buildMonthGrid_assignsCountAndLevel() { + val day = LocalDate.of(2026, 10, 5) + val days = grid(mapOf(day to 2)).weeks.flatten().filterNotNull().associateBy { it.date } + + assertEquals(2, days.getValue(day).count) + assertEquals(2, days.getValue(day).level) + assertEquals(0, days.getValue(day.plusDays(1)).level) + } + + @Test + fun buildMonthGrid_scalesAgainstTheGivenMaxCount() { + val day = LocalDate.of(2026, 10, 5) + val grid = buildMonthGrid(october, mapOf(day to 2), today, maxCount = 8) + + assertEquals(1, grid.weeks.flatten().filterNotNull().first { it.date == day }.level) + } + + @Test + fun monthGrid_visits_sumsTheMonthOnly() { + val counts = + mapOf( + LocalDate.of(2026, 10, 1) to 1, + LocalDate.of(2026, 10, 5) to 2, + LocalDate.of(2026, 9, 30) to 5, + ) + + assertEquals(3, grid(counts).visits) + assertEquals(0, grid().visits) + } + + @Test + fun monthsBetween_listsEveryMonthOldestFirst() { + val months = monthsBetween(YearMonth.of(2025, 11), YearMonth.of(2026, 2)) + + assertEquals( + listOf( + YearMonth.of(2025, 11), + YearMonth.of(2025, 12), + YearMonth.of(2026, 1), + YearMonth.of(2026, 2), + ), + months, + ) + } + + @Test + fun monthsBetween_returnsASingleMonth_whenBothAreEqual() { + assertEquals(listOf(october), monthsBetween(october, october)) + } + + @Test + fun monthsBetween_fallsBackToTheLastMonth_whenFirstIsAfterIt() { + assertEquals(listOf(october), monthsBetween(october.plusMonths(2), october)) + } + + @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) + } +} 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))), + ) + } +}