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 @@ -32,15 +32,13 @@ 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

/** "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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<YearMonth>,
countsByDay: Map<LocalDate, Int>,
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
},
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ fun HistoryOverview(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MonthCalendar(
HistoryCalendar(
months = months,
countsByDay = countsByDay,
today = today,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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
Expand All @@ -44,6 +45,7 @@ object MonthCalendarTestTags {
const val TITLE = "MonthCalendarTitle"
const val PREVIOUS = "MonthCalendarPrevious"
const val NEXT = "MonthCalendarNext"
const val ZOOM_OUT = "MonthCalendarZoomOut"

fun day(date: LocalDate) = "MonthCalendarDay_$date"
}
Expand All @@ -62,6 +64,9 @@ private const val ROWS = 6
* @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(
Expand All @@ -73,14 +78,38 @@ fun MonthCalendar(
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 pagerState = rememberPagerState(initialPage = months.lastIndex) { months.size }
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading
Loading