1 Commits

Author SHA1 Message Date
396e538903 fix(core-time): render all-day values in UTC, not the device zone
All-day items are date-only in iCalendar and are stored as UTC midnight of
their calendar date — the convention the tasks and calendar providers read
back (OpenTasks resolves them through DateTime.toAllDay(), which discards the
time and resolves the remaining date against UTC).

formatDate/formatDateTime/formatDateTimeCompact resolved every instant against
ZoneId.systemDefault(), so an all-day value rendered as the *previous* day for
anyone west of UTC: a task stored as 2026-07-20T00:00Z showed as 19 Jul in New
York. East of UTC it happened to land on the right day, which is why this went
unnoticed.

formatDate now takes an allDay flag (defaulting to false, so existing call
sites are unaffected) and all-day values resolve against UTC.

Callers must pass allDay at the call site — agendula and calendula both have
sites that need updating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:09:28 +02:00
7 changed files with 108 additions and 89 deletions

View File

@@ -39,8 +39,6 @@ dependencies {
// core-locale backs the language-picker recipe (AppLanguage). // core-locale backs the language-picker recipe (AppLanguage).
implementation(project(":core-locale")) implementation(project(":core-locale"))
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.foundation) implementation(libs.androidx.foundation)

View File

@@ -37,16 +37,6 @@ fun positionOf(index: Int, count: Int): Position = when {
else -> Position.Middle else -> Position.Middle
} }
/**
* The inset a grouped list keeps from the screen edge.
*
* Exported because it is a *shared* line, not a private detail of [GroupedRow]:
* everything that sits outside the cards but belongs to them — section headers,
* hints, picker descriptions — has to start on the same edge, or the page reads
* as two ragged columns. Take this value rather than writing 16.dp again.
*/
val GroupedListInset = 16.dp
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */ /** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
Position.Alone -> RoundedCornerShape(full) Position.Alone -> RoundedCornerShape(full)
@@ -150,7 +140,7 @@ fun GroupedRow(
} }
GroupedSurface( GroupedSurface(
position = position, position = position,
modifier = modifier.padding(horizontal = GroupedListInset), modifier = modifier.padding(horizontal = 16.dp),
onClick = onClick, onClick = onClick,
color = containerColor, color = containerColor,
gapBelow = gapBelow, gapBelow = gapBelow,

View File

@@ -13,8 +13,6 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/** /**
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses * Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
@@ -26,20 +24,6 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
* [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content] * [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content]
* scrolls itself, which a picker only needs for an option list long enough to * scrolls itself, which a picker only needs for an option list long enough to
* warrant a lazy container. * warrant a lazy container.
*
* **Picker richness.** Three shapes, in ascending order — take the cheapest one
* that makes the choice obvious:
* 1. plain option rows, when the label *is* the meaning ("English", "Dark");
* 2. rows plus a per-option summary of the concrete effect, when the meaning is
* a date, a time or a number the label only names ("Next 7 days" →
* "30 Jul 5 Aug"). [OptionPicker]'s `summary` covers this;
* 3. a live preview above the rows, when the effect is *visual* and no words
* carry it (month grid style, dimmed past events, week start).
*
* A picker of the third kind **must not close when an option is tapped** — it
* applies immediately and stays open, because closing would hide the very thing
* the screen exists to show. The user leaves via back. The first two kinds close
* on tap as usual.
*/ */
@Composable @Composable
fun FullScreenPicker( fun FullScreenPicker(
@@ -57,28 +41,14 @@ fun FullScreenPicker(
decorFitsSystemWindows = false, decorFitsSystemWindows = false,
), ),
) { ) {
// A Compose Dialog owns a separate android.view.Window whose bar-icon // The dialog window pans by default when the keyboard opens, which —
// appearance is seeded from the XML theme, not from the activity window's
// runtime edge-to-edge state — so with decorFitsSystemWindows = false it
// draws edge-to-edge under bars whose icons don't match the active theme
// (dark icons on a dark picker). Drive the appearance from the runtime
// [LocalFloretDarkTheme] so it tracks the in-app light/dark choice even
// when that diverges from the system night qualifier.
//
// Same window also pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap // combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays // above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field. // full-screen and imePadding alone lifts the focused field.
val view = LocalView.current val view = LocalView.current
val darkTheme = LocalFloretDarkTheme.current
SideEffect { SideEffect {
(view.parent as? DialogWindowProvider)?.window?.let { window -> (view.parent as? DialogWindowProvider)?.window
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) ?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
} }
CollapsingScaffold( CollapsingScaffold(
title = title, title = title,
@@ -96,15 +66,6 @@ fun FullScreenPicker(
* General single-select picker, full-screen: each option is a connected grouped * General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. The drop-in for the family's option * row and the current one carries a check. The drop-in for the family's option
* dialogs (theme, default list, reminder offset, …). * dialogs (theme, default list, reminder offset, …).
*
* [summary] adds a second line per option — reserve it for the option's
* *concrete effect*, the thing its label names but doesn't spell out: the dates
* a range resolves to, the time a format renders as, what "follow the system"
* currently means. Returning null leaves that row single-line, so one option
* (typically the automatic one) can carry a note the others don't need. It is
* the cheap middle rung of the richness ladder described on [FullScreenPicker]:
* every option shows its effect at once and stays comparable, without the cost
* of a live preview.
*/ */
@Composable @Composable
fun <T> OptionPicker( fun <T> OptionPicker(
@@ -114,7 +75,6 @@ fun <T> OptionPicker(
label: @Composable (T) -> String, label: @Composable (T) -> String,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
summary: (@Composable (T) -> String?)? = null,
leading: (@Composable (T) -> Unit)? = null, leading: (@Composable (T) -> Unit)? = null,
header: (@Composable ColumnScope.() -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
@@ -125,7 +85,6 @@ fun <T> OptionPicker(
val isSelected = option == selected val isSelected = option == selected
GroupedRow( GroupedRow(
title = label(option), title = label(option),
summary = summary?.invoke(option),
position = positionOf(index, options.size), position = positionOf(index, options.size),
selected = isSelected, selected = isSelected,
leading = leading?.let { { it(option) } }, leading = leading?.let { { it(option) } },

View File

@@ -13,10 +13,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -39,12 +41,6 @@ data class AboutLink(val icon: ImageVector, val label: String, val url: String)
* outlined buttons (e.g. source, licence) and an optional full-width * outlined buttons (e.g. source, licence) and an optional full-width
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the * [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
* browser. The app supplies its own logo, strings and links. * browser. The app supplies its own logo, strings and links.
*
* [position] lets the card join a grouped list instead of standing alone: with
* [Position.Top] it takes the group's top corners and the 2dp gap below, so a
* [GroupedRow] underneath continues the same container. Use that when the card's
* call to action reads better as a row of its own than as a button inside the
* card — the two then form one block rather than a card with a lid on it.
*/ */
@Composable @Composable
fun AboutCard( fun AboutCard(
@@ -53,16 +49,15 @@ fun AboutCard(
author: String, author: String,
primaryLinks: List<AboutLink>, primaryLinks: List<AboutLink>,
highlightLink: AboutLink? = null, highlightLink: AboutLink? = null,
position: Position = Position.Alone,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val open = { url: String -> val open = { url: String ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Unit Unit
} }
GroupedSurface( Surface(
position = position,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.chrono.IsoChronology import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeFormatterBuilder
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()) java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
/**
* The zone an instant should be read in to recover the date it denotes.
*
* All-day items are date-only in iCalendar and are stored as **UTC midnight** of
* their calendar date (the convention the tasks/calendar providers read back).
* Rendering one in the device zone shows the previous day for anyone west of
* UTC — so all-day values resolve against UTC, timed values against the device.
*/
private fun Instant.atDisplayZone(allDay: Boolean) =
if (allDay) java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(ZoneOffset.UTC)
else atSystemZone()
/** Medium localized date, e.g. "18 Jun 2026". */ /** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter) fun Instant.formatDate(allDay: Boolean = false): String =
atDisplayZone(allDay).format(dateFormatter)
/** Short localized time, e.g. "14:30". */ /** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter) fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */ /** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String = fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}" if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime()}"
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" / // The locale's medium date pattern with the year token stripped, e.g. "20 Jun" /
// "Jun 20" — derived from the pattern so day/month order still follows the locale. // "Jun 20" — derived from the pattern so day/month order still follows the locale.
@@ -49,10 +63,10 @@ private val dateNoYearFormatter: DateTimeFormatter = run {
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027". * items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
*/ */
fun Instant.formatDateTimeCompact(allDay: Boolean): String { fun Instant.formatDateTimeCompact(allDay: Boolean): String {
val zdt = atSystemZone() val zdt = atDisplayZone(allDay)
val datePart = val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter) if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate() else formatDate(allDay)
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
else "$datePart · ${formatTime()}" else "$datePart · ${formatTime()}"
} }

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.floret.time
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.TimeZone
import kotlin.time.Instant
class DateTimeFormatTest {
private val originalZone: TimeZone = TimeZone.getDefault()
@AfterEach
fun restoreZone() = TimeZone.setDefault(originalZone)
private val july20: LocalDate = LocalDate.of(2026, 7, 20)
/** The all-day storage form: UTC midnight of the calendar date. */
private val allDayJuly20: Instant = Instant.fromEpochMilliseconds(
july20.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
/** What the locale renders for a given date — so the test doesn't pin a format. */
private fun medium(date: LocalDate): String =
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
@Test
fun `an all-day date reads the same whatever the device zone`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")) // UTC-4 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin")) // UTC+2 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland")) // UTC+12
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `reading an all-day value as timed slips a day west of UTC`() {
// Guards the regression directly: this is what the all-day flag prevents.
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
assertThat(allDayJuly20.formatDate()).isEqualTo(medium(july20.minusDays(1)))
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `timed values still render in the device zone`() {
// 2026-07-20T23:30Z — the next day already, in Auckland.
val lateUtc = Instant.fromEpochMilliseconds(
july20.atTime(23, 30).atZone(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland"))
assertThat(lateUtc.formatDate()).isEqualTo(medium(july20.plusDays(1)))
}
@Test
fun `formatDateTime drops the time for all-day and keeps it otherwise`() {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"))
assertThat(allDayJuly20.formatDateTime(allDay = true)).isEqualTo(medium(july20))
assertThat(allDayJuly20.formatDateTime(allDay = false)).contains("·")
}
@Test
fun `compact form is zone-correct for all-day too`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
val compact = allDayJuly20.formatDateTimeCompact(allDay = true)
// No time part, and the day must not have slipped to the 19th.
assertThat(compact).doesNotContain("·")
assertThat(compact).contains("20")
}
}

View File

@@ -10,19 +10,8 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
/**
* The resolved light/dark decision the current [FloretExpressiveTheme] is running
* with, carried down the tree so components can align window chrome (e.g. status-
* and navigation-bar icon appearance) with the *runtime* theme rather than the
* system night resource qualifier — the two diverge whenever an app overrides
* light/dark from settings. Defaults to `false` (light) outside a Floret theme.
*/
val LocalFloretDarkTheme = staticCompositionLocalOf { false }
/** /**
* The family's Material 3 Expressive theme factory. Honours system light/dark * The family's Material 3 Expressive theme factory. Honours system light/dark
* and Material You dynamic colour (API 31+), falling back to the app-supplied * and Material You dynamic colour (API 31+), falling back to the app-supplied
@@ -54,7 +43,6 @@ fun FloretExpressiveTheme(
else -> lightScheme else -> lightScheme
} }
CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) {
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = typography, typography = typography,
@@ -62,4 +50,3 @@ fun FloretExpressiveTheme(
content = content, content = content,
) )
} }
}