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
5 changed files with 103 additions and 44 deletions

View File

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

View File

@@ -13,8 +13,6 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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
@@ -43,28 +41,14 @@ fun FullScreenPicker(
decorFitsSystemWindows = false,
),
) {
// A Compose Dialog owns a separate android.view.Window whose bar-icon
// 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 —
// The dialog window pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field.
val view = LocalView.current
val darkTheme = LocalFloretDarkTheme.current
SideEffect {
(view.parent as? DialogWindowProvider)?.window?.let { window ->
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(
title = title,

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.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". */
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". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */
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" /
// "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".
*/
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
val zdt = atSystemZone()
val zdt = atDisplayZone(allDay)
val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate()
else formatDate(allDay)
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
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.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
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
* and Material You dynamic colour (API 31+), falling back to the app-supplied
@@ -54,7 +43,6 @@ fun FloretExpressiveTheme(
else -> lightScheme
}
CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = typography,
@@ -62,4 +50,3 @@ fun FloretExpressiveTheme(
content = content,
)
}
}