Compare commits
2 Commits
fix/all-da
...
b4d3ead8f0
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d3ead8f0 | |||
| 9bbef911c0 |
@@ -39,6 +39,8 @@ 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)
|
||||
|
||||
@@ -13,6 +13,8 @@ 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
|
||||
@@ -41,14 +43,28 @@ fun FullScreenPicker(
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// The dialog window pans by default when the keyboard opens, which —
|
||||
// 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 —
|
||||
// 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
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
(view.parent as? DialogWindowProvider)?.window?.let { window ->
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -23,28 +22,15 @@ 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(allDay: Boolean = false): String =
|
||||
atDisplayZone(allDay).format(dateFormatter)
|
||||
fun Instant.formatDate(): String = atSystemZone().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(allDay = true) else "${formatDate()} · ${formatTime()}"
|
||||
if (allDay) formatDate() 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.
|
||||
@@ -63,10 +49,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 = atDisplayZone(allDay)
|
||||
val zdt = atSystemZone()
|
||||
val datePart =
|
||||
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
|
||||
else formatDate(allDay)
|
||||
else formatDate()
|
||||
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
|
||||
else "$datePart · ${formatTime()}"
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,19 @@ 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
|
||||
@@ -43,10 +54,12 @@ fun FloretExpressiveTheme(
|
||||
else -> lightScheme
|
||||
}
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) {
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user