Skip to content
Merged
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
@@ -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(" · ")
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)
Loading
Loading