Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ internal fun weekLabel(

/** The sticky title above the entries of one week, with how many visits it had. */
@Composable
internal fun WeekHeader(label: String, visits: Int, modifier: Modifier = Modifier) {
internal fun WeekHeader(label: String, visits: Int, meters: Int, modifier: Modifier = Modifier) {
Surface(
modifier = modifier.fillMaxWidth().testTag(HistoryScreenTestTags.WEEK_HEADER),
color = MaterialTheme.colorScheme.background,
Expand All @@ -91,7 +91,7 @@ internal fun WeekHeader(label: String, visits: Int, modifier: Modifier = Modifie
fontWeight = FontWeight.Bold,
)
Text(
text = visitsLabel(visits),
text = weekTotals(visits, meters),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down Expand Up @@ -136,7 +136,8 @@ private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale.
*
* @param day The day of the entry, shown in the date block.
* @param isToday Whether [day] is today.
* @param label The text of the row, see [entryRowLabel].
* @param texts What the row says, see [entryRowTexts].
* @param highlighted Whether to tint the row, e.g. because its day was picked in the calendar.
* @param onClick Called when the row is tapped.
* @param onDelete Called once the row was swiped away.
*/
Expand All @@ -145,7 +146,8 @@ private fun DateBlock(day: LocalDate, isToday: Boolean, locale: Locale = Locale.
internal fun SwipeableEntryRow(
day: LocalDate,
isToday: Boolean,
label: String,
texts: EntryRowTexts,
highlighted: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -186,18 +188,32 @@ internal fun SwipeableEntryRow(
}
},
) {
Surface(onClick = onClick, color = MaterialTheme.colorScheme.background) {
Surface(
onClick = onClick,
color =
if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
else MaterialTheme.colorScheme.background,
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DateBlock(day = day, isToday = isToday)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
)
Column(modifier = Modifier.weight(1f).padding(horizontal = 16.dp)) {
Text(
text = texts.primary,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
texts.secondary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
Expand All @@ -209,3 +225,8 @@ internal fun SwipeableEntryRow(
}
}
}

/** "5 visits · 4.2 km"; the distance is left out when none was logged that week. */
internal fun weekTotals(visits: Int, meters: Int): String =
listOfNotNull(visitsLabel(visits), if (meters > 0) formatTotalDistance(meters) else null)
.joinToString(" · ")
Original file line number Diff line number Diff line change
@@ -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<Entry>,
firstDayOfWeek: DayOfWeek,
zone: ZoneId = ZoneId.systemDefault(),
): List<HistoryRow> =
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<HistoryRow>.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)
120 changes: 65 additions & 55 deletions app/src/main/java/com/github/se/pooltrack/ui/history/HistoryScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.outlined.DateRange
Expand All @@ -34,27 +34,27 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.github.se.pooltrack.model.entry.Entry
import com.github.se.pooltrack.model.entry.timestamp
import com.github.se.pooltrack.model.swim.formatSwimDistance
import com.github.se.pooltrack.model.swim.formatSwimDuration
import com.github.se.pooltrack.ui.history.heatmap.HistoryOverview
import com.github.se.pooltrack.ui.history.heatmap.entryCountsByDay
import com.github.se.pooltrack.ui.navigation.BottomNavigationMenu
import com.github.se.pooltrack.ui.navigation.NavigationActions
import com.github.se.pooltrack.ui.navigation.Screen
import com.github.se.pooltrack.ui.navigation.Tab
import com.github.se.pooltrack.ui.navigation.TopNavigationMenu
import java.time.Duration
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.WeekFields
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

object HistoryScreenTestTags {
Expand All @@ -66,7 +66,6 @@ object HistoryScreenTestTags {
const val UNDO_ADD_ACTION = "Undo"
const val LINK_BANNER = "HistoryScreenLinkBanner"
const val LINK_BUTTON = "HistoryScreenLinkButton"
const val NO_ENTRIES_THAT_DAY = "HistoryScreenNoEntriesThatDay"
}

private val ZONE = ZoneId.systemDefault()
Expand All @@ -86,7 +85,7 @@ fun HistoryScreen(
) {
val entries by viewModel.entries.collectAsState()
var showAddSheet by remember { mutableStateOf(false) }
var selectedDate by remember { mutableStateOf<LocalDate?>(null) }
var highlightedDate by remember { mutableStateOf<LocalDate?>(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)) }
Expand Down Expand Up @@ -191,12 +190,37 @@ fun HistoryScreen(
} else {
val today = LocalDate.now(ZONE)
val firstDayOfWeek = WeekFields.of(LocalConfiguration.current.locales[0]).firstDayOfWeek
val allEntriesByDay = entries.groupBy { it.timestamp.atZone(ZONE).toLocalDate() }
val countsByDay = remember(allEntriesByDay) { allEntriesByDay.mapValues { it.value.size } }
val entriesByDay =
selectedDate?.let { day -> allEntriesByDay.filterKeys { it == day } } ?: allEntriesByDay
val countsByDay = remember(entries) { entryCountsByDay(entries, ZONE) }
val rows =
remember(entries, firstDayOfWeek) { buildHistoryRows(entries, firstDayOfWeek, ZONE) }
val listState = rememberLazyListState()
val headerClearance = with(LocalDensity.current) { STICKY_HEADER_CLEARANCE.roundToPx() }
// The overview, and the link banner when there is one, come before the rows.
val leadingItems = if (linkSuggestions.isEmpty()) 1 else 2

// A picked day stays highlighted for a moment, so the eye can find its row.
LaunchedEffect(highlightedDate) {
if (highlightedDate != null) {
delay(HIGHLIGHT_MILLIS)
highlightedDate = null
}
}

val onDayPicked: (LocalDate) -> Unit = { day ->
highlightedDate = day
val index = rows.indexOfDay(day)
scope.launch {
if (index != null) {
listState.animateScrollToItem(leadingItems + index, scrollOffset = -headerClearance)
} else {
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar("No visit on ${dayLabel(day, today)}")
}
}
}

LazyColumn(
state = listState,
modifier =
Modifier.fillMaxSize()
.padding(paddingValues)
Expand All @@ -208,25 +232,11 @@ fun HistoryScreen(
countsByDay = countsByDay,
today = today,
firstDayOfWeek = firstDayOfWeek,
selectedDate = selectedDate,
onDayClick = { day -> selectedDate = if (day == selectedDate) null else day },
onClearSelection = { selectedDate = null },
selectedLabel = { dayLabel(it, today) },
selectedDate = highlightedDate,
onDayClick = onDayPicked,
modifier = Modifier.padding(vertical = 4.dp),
)
}
if (selectedDate != null && entriesByDay.isEmpty()) {
item(key = "no-entries-that-day") {
Text(
text = "No entries on this day.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(vertical = 16.dp)
.testTag(HistoryScreenTestTags.NO_ENTRIES_THAT_DAY),
)
}
}
if (linkSuggestions.isNotEmpty()) {
item(key = "link-banner") {
LinkBanner(
Expand All @@ -251,29 +261,35 @@ fun HistoryScreen(
)
}
}
entriesByDay.entries
.groupBy { weekStart(it.key, firstDayOfWeek) }
.forEach { (start, days) ->
stickyHeader(key = "week-$start") {
WeekHeader(
label = weekLabel(start, today, firstDayOfWeek),
visits = days.sumOf { it.value.size },
)
}
days.forEach { (day, entriesForDay) ->
items(entriesForDay, key = { it.timestamp.toEpochMilli() }) { entry ->
rows.forEach { row ->
when (row) {
is HistoryRow.Week ->
stickyHeader(key = row.key) {
WeekHeader(
label = weekLabel(row.start, today, firstDayOfWeek),
visits = row.visits,
meters = row.meters,
)
}
is HistoryRow.Item ->
item(key = row.key) {
SwipeableEntryRow(
day = day,
isToday = day == today,
label = entryRowLabel(entry),
day = row.day,
isToday = row.day == today,
texts =
entryRowTexts(
row.entry,
entryTimeFormatter().format(row.entry.timestamp),
),
highlighted = row.day == highlightedDate,
onClick = {
navigationActions?.navigateToEntryDetails(entry.timestampEpochMilli)
navigationActions?.navigateToEntryDetails(row.entry.timestampEpochMilli)
},
onDelete = { onDeleteEntry(entry) },
onDelete = { onDeleteEntry(row.entry) },
)
}
}
}
}
}
}
}
}
Expand Down Expand Up @@ -316,14 +332,8 @@ internal fun dayLabel(day: LocalDate, today: LocalDate): String =
}
}

/**
* The entry time, followed by the swim duration (old entries) and the logged distance, whichever
* were recorded: "18:42 · 52min · 1200 m".
*/
internal fun entryRowLabel(entry: Entry): String =
listOfNotNull(
entryTimeFormatter().format(entry.timestamp),
entry.swimDurationMillis?.let { formatSwimDuration(Duration.ofMillis(it)) },
entry.swimDistanceMeters?.let { formatSwimDistance(it) },
)
.joinToString(" · ")
/** How long a day picked in the calendar keeps its row tinted. */
private const val HIGHLIGHT_MILLIS = 2_500L

/** How far below the top a scrolled-to row stops, so the sticky week header doesn't cover it. */
private val STICKY_HEADER_CLEARANCE = 56.dp
Loading
Loading