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..3cfc3f7 --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryListItems.kt @@ -0,0 +1,213 @@ +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 java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters +import java.util.Locale + +/** "5 visits" (or "1 visit"). */ +internal fun visitsLabel(count: Int): String = if (count == 1) "1 visit" else "$count visits" + +/** The first day of the week that contains [day], where weeks start on [firstDayOfWeek]. */ +internal fun weekStart(day: LocalDate, firstDayOfWeek: DayOfWeek): LocalDate = + day.with(TemporalAdjusters.previousOrSame(firstDayOfWeek)) + +/** + * 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, 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 = visitsLabel(visits), + 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 label The text of the row, see [entryRowLabel]. + * @param onClick Called when the row is tapped. + * @param onDelete Called once the row was swiped away. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SwipeableEntryRow( + day: LocalDate, + isToday: Boolean, + label: String, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val dismissState = + rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onDelete() + true + } else false + } + ) + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + modifier = + modifier.testTag(HistoryScreenTestTags.ENTRY_ITEM).semantics { + customActions = + listOf( + CustomAccessibilityAction("Delete entry") { + onDelete() + true + } + ) + }, + backgroundContent = { + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.error), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onError, + modifier = Modifier.padding(end = 20.dp), + ) + } + }, + ) { + Surface(onClick = onClick, color = MaterialTheme.colorScheme.background) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DateBlock(day = day, isToday = isToday) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } +} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt index d88d878..0ae881e 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt @@ -12,20 +12,15 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.DateRange -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -40,7 +35,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.github.se.pooltrack.model.entry.Entry @@ -58,7 +52,6 @@ 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 @@ -67,11 +60,8 @@ 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" @@ -88,13 +78,6 @@ private fun entryTimeFormatter(): DateTimeFormatter = .withLocale(Locale.getDefault()) .withZone(ZONE) -private fun entryDateTimeFormatter(): DateTimeFormatter = - DateTimeFormatterBuilder() - .appendPattern("EEEE, ") - .append(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) - .toFormatter(Locale.getDefault()) - .withZone(ZONE) - /** HistoryScreen lists every confirmed pool entry, grouped by day, most recent first. */ @Composable fun HistoryScreen( @@ -102,7 +85,6 @@ fun HistoryScreen( navigationActions: NavigationActions? = null, ) { val entries by viewModel.entries.collectAsState() - var entryPendingDeletion by remember { mutableStateOf(null) } var showAddSheet by remember { mutableStateOf(false) } var selectedDate by remember { mutableStateOf(null) } // Remembered across openings, so adding several entries from the same day takes one tap each. @@ -131,6 +113,21 @@ fun HistoryScreen( } } + // Deleting needs no confirmation: the swipe is deliberate and the snackbar offers an Undo. + val onDeleteEntry: (Entry) -> Unit = { entry -> + viewModel.onDeleteEntry(entry) + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val outcome = + snackbarHostState.showSnackbar( + message = "Entry deleted", + actionLabel = HistoryScreenTestTags.UNDO_ADD_ACTION, + withDismissAction = true, + ) + if (outcome == SnackbarResult.ActionPerformed) viewModel.onRestoreEntry(entry) + } + } + if (showAddSheet) { AddPastEntrySheet( initialDate = lastAddedDate, @@ -149,38 +146,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) }, @@ -225,6 +190,7 @@ fun HistoryScreen( } } else { val today = LocalDate.now(ZONE) + val firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() } val countsByDay = remember(allEntriesByDay) { allEntriesByDay.mapValues { it.value.size } } val entriesByDay = @@ -241,7 +207,7 @@ fun HistoryScreen( HistoryOverview( countsByDay = countsByDay, today = today, - firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek, + firstDayOfWeek = firstDayOfWeek, selectedDate = selectedDate, onDayClick = { day -> selectedDate = if (day == selectedDate) null else day }, onClearSelection = { selectedDate = null }, @@ -285,19 +251,29 @@ fun HistoryScreen( ) } } - entriesByDay.forEach { (day, entriesForDay) -> - item(key = day.toEpochDay()) { - DayHeader(label = dayLabel(day, today), entryCount = entriesForDay.size) - } - items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> - EntryRow( - entry = entry, - onClick = { navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) }, - onDelete = { entryPendingDeletion = entry }, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - } + entriesByDay.entries + .groupBy { weekStart(it.key, firstDayOfWeek) } + .forEach { (start, days) -> + stickyHeader(key = "week-$start") { + WeekHeader( + label = weekLabel(start, today, firstDayOfWeek), + visits = days.sumOf { it.value.size }, + ) + } + days.forEach { (day, entriesForDay) -> + items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry -> + SwipeableEntryRow( + day = day, + isToday = day == today, + label = entryRowLabel(entry), + onClick = { + navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli) + }, + onDelete = { onDeleteEntry(entry) }, + ) + } + } + } } } } @@ -329,34 +305,8 @@ private fun LinkBanner(count: Int, onLink: () -> Unit, modifier: Modifier = Modi } } -/** Section header grouping entries from the same calendar day. */ -@Composable -private fun DayHeader(label: String, entryCount: Int) { - Surface( - modifier = Modifier.fillMaxWidth().testTag(HistoryScreenTestTags.DAY_HEADER), - color = MaterialTheme.colorScheme.background, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = if (entryCount == 1) "1 entry" else "$entryCount entries", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - /** "Today", "Yesterday", or a weekday/date, omitting the year unless [day] isn't this year. */ -private fun dayLabel(day: LocalDate, today: LocalDate): String = +internal fun dayLabel(day: LocalDate, today: LocalDate): String = when (day) { today -> "Today" today.minusDays(1) -> "Yesterday" @@ -370,52 +320,10 @@ private fun dayLabel(day: LocalDate, today: LocalDate): String = * The entry time, followed by the swim duration (old entries) and the logged distance, whichever * were recorded: "18:42 · 52min · 1200 m". */ -private fun entryRowLabel(entry: Entry): String = +internal fun entryRowLabel(entry: Entry): String = listOfNotNull( entryTimeFormatter().format(entry.timestamp), entry.swimDurationMillis?.let { formatSwimDuration(Duration.ofMillis(it)) }, entry.swimDistanceMeters?.let { formatSwimDistance(it) }, ) .joinToString(" · ") - -@Composable -private fun EntryRow( - entry: Entry, - onClick: () -> Unit, - onDelete: () -> Unit, - modifier: Modifier = Modifier, -) { - Card( - onClick = onClick, - modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.ENTRY_ITEM), - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = entryRowLabel(entry), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp), - ) - } - IconButton( - onClick = onDelete, - modifier = Modifier.testTag(HistoryScreenTestTags.DELETE_BUTTON), - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete entry", - tint = MaterialTheme.colorScheme.error, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt index 6bec86a..f7d955b 100644 --- a/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt +++ b/app/src/main/java/com/github/se/pooltrack/ui/history/HistoryViewModel.kt @@ -96,6 +96,11 @@ class HistoryViewModel( viewModelScope.launch { repository.deleteEntry(entry) } } + /** Puts back [entry] exactly as it was, to undo [onDeleteEntry]. */ + fun onRestoreEntry(entry: Entry) { + viewModelScope.launch { repository.addEntry(entry) } + } + /** * Records an entry that happened at [timestamp], e.g. one that was forgotten at the time. It is * refused if [timestamp] is after [now] or an entry already exists at that exact instant (see diff --git a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt new file mode 100644 index 0000000..dd118e3 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryListItemsTest.kt @@ -0,0 +1,65 @@ +package com.github.se.pooltrack.ui.history + +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/HistoryScreenRobolectricTest.kt b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt index 1b75ea0..6157530 100644 --- a/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt +++ b/app/src/test/java/com/github/se/pooltrack/ui/history/HistoryScreenRobolectricTest.kt @@ -2,14 +2,17 @@ package com.github.se.pooltrack.ui.history import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import com.github.se.pooltrack.model.entry.Entry import com.github.se.pooltrack.model.entry.EntryRepositoryProvider import com.github.se.pooltrack.model.subscription.Subscription @@ -78,72 +81,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 @@ -151,27 +142,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() @@ -394,7 +395,7 @@ class HistoryScreenRobolectricTest { tapDay(older) composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.ENTRY_ITEM).assertCountEquals(1) - composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.DAY_HEADER).assertCountEquals(1) + composeTestRule.onAllNodesWithTag(HistoryScreenTestTags.WEEK_HEADER).assertCountEquals(1) composeTestRule.onNodeWithTag(HistoryOverviewTestTags.CLEAR_FILTER).performClick() 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")