Compare commits
3 Commits
v0.2.1
...
fix/all-da
| Author | SHA1 | Date | |
|---|---|---|---|
| 396e538903 | |||
| 75b90d1b3b | |||
| b4954713ff |
@@ -102,8 +102,18 @@ fun <T> OptionPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trailing check that marks the chosen row in a full-screen picker — the
|
||||||
|
* family's single selection affordance, so every picker (option, reminder,
|
||||||
|
* agenda range, timezone, calendar, recurrence) marks its selection the same
|
||||||
|
* way. Pair it with `GroupedRow(selected = true)`: the tonal highlight carries
|
||||||
|
* the state at a glance, the check confirms it unambiguously.
|
||||||
|
*
|
||||||
|
* Purely decorative — the row itself owns the semantics, so it takes no
|
||||||
|
* content description.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectedCheck() {
|
fun SelectedCheck() {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Check,
|
imageVector = Icons.Rounded.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
|||||||
@@ -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()}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user