From 34feece25dbc486858d6d7d0c0e97d48a2fffce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:46:36 +0000 Subject: [PATCH 1/5] Respect system "first day of week" setting The first day of the week was derived solely from the language/region locale via WeekFields.of(Locale.getDefault()).firstDayOfWeek. This ignored Android 13+'s "Regional preferences -> First day of week" system setting, so e.g. an en-US device with the setting set to Monday still showed Sunday as the first day. Use androidx LocalePreferences.getFirstDayOfWeek(), which reads that setting through the locale's "-u-fw-" unicode extension and falls back to the locale's default when unset. This is applied to the recurrence weekday order, the week calendar view, and week-number grouping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --- .../jtx/util/DateTimeUtilsAndroidTest.kt | 37 +++++++++++++++++++ .../jtx/database/relations/ICal4ListRel.kt | 5 +-- .../at/techbee/jtx/ui/list/ListScreenWeek.kt | 2 +- .../java/at/techbee/jtx/util/DateTimeUtils.kt | 36 +++++++++++++++++- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt index 8e1e5c5db..dcdd8ca52 100644 --- a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt +++ b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt @@ -10,10 +10,13 @@ package at.techbee.jtx.util import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import at.techbee.jtx.util.DateTimeUtils.getLocalizedFirstDayOfWeek import at.techbee.jtx.util.DateTimeUtils.isLocalizedWeekstartMonday +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith +import java.time.DayOfWeek import java.util.Locale @@ -21,6 +24,12 @@ import java.util.Locale @SmallTest class DateTimeUtilsAndroidTest { + private val defaultLocale: Locale = Locale.getDefault() + + @After + fun tearDown() { + Locale.setDefault(defaultLocale) + } @Test fun isLocalizedWeekstartMonday_GERMAN() { @@ -34,4 +43,32 @@ class DateTimeUtilsAndroidTest { assertEquals(false, isLocalizedWeekstartMonday()) } + @Test + fun getLocalizedFirstDayOfWeek_GERMAN() { + Locale.setDefault(Locale.GERMAN) + assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek()) + } + + @Test + fun getLocalizedFirstDayOfWeek_US() { + Locale.setDefault(Locale.US) + assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek()) + } + + @Test + fun getLocalizedFirstDayOfWeek_US_withFirstDayOfWeekOverrideMonday() { + // Emulates the "Regional preferences -> First day of week = Monday" system setting, + // which is exposed as the "-u-fw-mon" unicode extension on the default locale. + Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon")) + assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek()) + assertEquals(true, isLocalizedWeekstartMonday()) + } + + @Test + fun getLocalizedFirstDayOfWeek_GERMAN_withFirstDayOfWeekOverrideSunday() { + Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun")) + assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek()) + assertEquals(false, isLocalizedWeekstartMonday()) + } + } \ No newline at end of file diff --git a/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt b/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt index ac8d9a6e9..bd31e5ae7 100644 --- a/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt +++ b/app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt @@ -32,7 +32,6 @@ import at.techbee.jtx.util.DateTimeUtils import java.time.Instant import java.time.ZonedDateTime import java.time.format.TextStyle -import java.time.temporal.WeekFields import java.util.Locale @@ -194,7 +193,7 @@ data class ICal4ListRel( val date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(ical4ListRel.iCal4List.dtstartTimezone)).toLocalDate() context.getString( R.string.week_number_year, - date[WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear()], + date[DateTimeUtils.getLocalizedWeekFields().weekOfWeekBasedYear()], date.year ) } @@ -229,7 +228,7 @@ data class ICal4ListRel( val date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(ical4ListRel.iCal4List.dueTimezone)).toLocalDate() context.getString( R.string.week_number_year, - date[WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear()], + date[DateTimeUtils.getLocalizedWeekFields().weekOfWeekBasedYear()], date.year ) } diff --git a/app/src/main/java/at/techbee/jtx/ui/list/ListScreenWeek.kt b/app/src/main/java/at/techbee/jtx/ui/list/ListScreenWeek.kt index ffe4bf0c1..04ba06147 100644 --- a/app/src/main/java/at/techbee/jtx/ui/list/ListScreenWeek.kt +++ b/app/src/main/java/at/techbee/jtx/ui/list/ListScreenWeek.kt @@ -84,7 +84,7 @@ fun ListScreenWeek( val currentMonth = remember(currentDate) { currentDate.yearMonth } val startMonth = remember(currentDate) { currentMonth.minusMonths(500) } val endMonth = remember(currentDate) { currentMonth.plusMonths(500) } - val daysOfWeek = remember { daysOfWeek() } + val daysOfWeek = remember { daysOfWeek(firstDayOfWeek = DateTimeUtils.getLocalizedFirstDayOfWeek()) } val scrollId by scrollOnceId.observeAsState(null) val weekState = rememberWeekCalendarState( diff --git a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt index db222b1cf..6f9885ebc 100644 --- a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt +++ b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt @@ -10,6 +10,7 @@ package at.techbee.jtx.util import android.icu.text.MessageFormat import android.util.Log +import androidx.core.text.util.LocalePreferences import at.techbee.jtx.database.ICalObject.Companion.TZ_ALLDAY import java.time.DateTimeException import java.time.DayOfWeek @@ -185,11 +186,44 @@ object DateTimeUtils { } + /** + * Determines the first day of the week for the current device. + * + * In contrast to [WeekFields.of] (which only derives the week start from the language/region + * of the locale, e.g. en-US -> Sunday), this respects the user's system setting under + * "Regional preferences -> First day of week" (available since Android 13). That setting is + * exposed through the locale's "-u-fw-" unicode extension, which is read by + * [LocalePreferences.getFirstDayOfWeek]. If the user did not override the setting, it falls + * back to the locale's default (ICU) value. + * + * @return the first [DayOfWeek] of the week for the local device + */ + fun getLocalizedFirstDayOfWeek(): DayOfWeek = when (LocalePreferences.getFirstDayOfWeek()) { + LocalePreferences.FirstDayOfWeek.MONDAY -> DayOfWeek.MONDAY + LocalePreferences.FirstDayOfWeek.TUESDAY -> DayOfWeek.TUESDAY + LocalePreferences.FirstDayOfWeek.WEDNESDAY -> DayOfWeek.WEDNESDAY + LocalePreferences.FirstDayOfWeek.THURSDAY -> DayOfWeek.THURSDAY + LocalePreferences.FirstDayOfWeek.FRIDAY -> DayOfWeek.FRIDAY + LocalePreferences.FirstDayOfWeek.SATURDAY -> DayOfWeek.SATURDAY + LocalePreferences.FirstDayOfWeek.SUNDAY -> DayOfWeek.SUNDAY + else -> WeekFields.of(Locale.getDefault()).firstDayOfWeek // fallback if the value is unknown/DEFAULT + } + /** * @return true if the first day of the week is monday for the local device, else false */ fun isLocalizedWeekstartMonday() = - WeekFields.of(Locale.getDefault()).firstDayOfWeek == DayOfWeek.MONDAY + getLocalizedFirstDayOfWeek() == DayOfWeek.MONDAY + + /** + * @return [WeekFields] that use the device's first day of the week (respecting the + * "Regional preferences -> First day of week" system setting, see [getLocalizedFirstDayOfWeek]) + * while keeping the locale's minimal days in the first week for week numbering. + */ + fun getLocalizedWeekFields(): WeekFields = WeekFields.of( + getLocalizedFirstDayOfWeek(), + WeekFields.of(Locale.getDefault()).minimalDaysInFirstWeek + ) fun addLongToCSVString(listAsString: String?, value: Long?): String? { From eabc0e90348477b38b462273a06004b8e929101e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 08:34:01 +0000 Subject: [PATCH 2/5] Replace isLocalizedWeekstartMonday with ordered weekday list isLocalizedWeekstartMonday() was only used to order the recurrence weekday buttons, and it collapsed the first day of the week to a Monday/Sunday choice. Replace it with getLocalizedDaysOfWeek(), which returns the seven days ordered from the device's first day of the week, so any first day (e.g. Saturday) is honoured. The recur card maps this to ical4j WeekDay via a small reusable helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --- .../jtx/util/DateTimeUtilsAndroidTest.kt | 54 +++++++++++++------ .../techbee/jtx/ui/detail/DetailsCardRecur.kt | 19 +++++-- .../java/at/techbee/jtx/util/DateTimeUtils.kt | 10 ++-- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt index dcdd8ca52..36ec1c0e3 100644 --- a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt +++ b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt @@ -10,8 +10,8 @@ package at.techbee.jtx.util import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import at.techbee.jtx.util.DateTimeUtils.getLocalizedDaysOfWeek import at.techbee.jtx.util.DateTimeUtils.getLocalizedFirstDayOfWeek -import at.techbee.jtx.util.DateTimeUtils.isLocalizedWeekstartMonday import org.junit.After import org.junit.Assert.assertEquals import org.junit.Test @@ -31,18 +31,6 @@ class DateTimeUtilsAndroidTest { Locale.setDefault(defaultLocale) } - @Test - fun isLocalizedWeekstartMonday_GERMAN() { - Locale.setDefault(Locale.GERMAN) - assertEquals(true, isLocalizedWeekstartMonday()) - } - - @Test - fun isLocalizedWeekstartMonday_US() { - Locale.setDefault(Locale.US) - assertEquals(false, isLocalizedWeekstartMonday()) - } - @Test fun getLocalizedFirstDayOfWeek_GERMAN() { Locale.setDefault(Locale.GERMAN) @@ -61,14 +49,48 @@ class DateTimeUtilsAndroidTest { // which is exposed as the "-u-fw-mon" unicode extension on the default locale. Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon")) assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek()) - assertEquals(true, isLocalizedWeekstartMonday()) } @Test fun getLocalizedFirstDayOfWeek_GERMAN_withFirstDayOfWeekOverrideSunday() { Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun")) assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek()) - assertEquals(false, isLocalizedWeekstartMonday()) } -} \ No newline at end of file + @Test + fun getLocalizedDaysOfWeek_GERMAN_startsWithMonday() { + Locale.setDefault(Locale.GERMAN) + assertEquals( + listOf( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY + ), + getLocalizedDaysOfWeek() + ) + } + + @Test + fun getLocalizedDaysOfWeek_US_startsWithSunday() { + Locale.setDefault(Locale.US) + assertEquals( + listOf( + DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY + ), + getLocalizedDaysOfWeek() + ) + } + + @Test + fun getLocalizedDaysOfWeek_US_withFirstDayOfWeekOverrideSaturday() { + Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-sat")) + assertEquals( + listOf( + DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY + ), + getLocalizedDaysOfWeek() + ) + } + +} diff --git a/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt b/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt index 86ff9afa2..ee628fe8a 100644 --- a/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt +++ b/app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt @@ -80,6 +80,20 @@ import java.util.Locale import kotlin.math.absoluteValue +/** + * @return the ical4j [WeekDay] that corresponds to this [DayOfWeek] + */ +private fun DayOfWeek.toICal4jWeekDay(): WeekDay = when (this) { + DayOfWeek.MONDAY -> WeekDay.MO + DayOfWeek.TUESDAY -> WeekDay.TU + DayOfWeek.WEDNESDAY -> WeekDay.WE + DayOfWeek.THURSDAY -> WeekDay.TH + DayOfWeek.FRIDAY -> WeekDay.FR + DayOfWeek.SATURDAY -> WeekDay.SA + DayOfWeek.SUNDAY -> WeekDay.SU +} + + @SuppressLint("LocalContextGetResourceValueCall") @OptIn(ExperimentalLayoutApi::class) @Composable @@ -128,10 +142,7 @@ fun DetailsCardRecur( var showDetachSingleFromSeriesDialog by rememberSaveable { mutableStateOf(false) } var showDetachAllFromSeriesDialog by rememberSaveable { mutableStateOf(false) } - val weekdays = if (DateTimeUtils.isLocalizedWeekstartMonday()) - listOf(WeekDay.MO, WeekDay.TU, WeekDay.WE, WeekDay.TH, WeekDay.FR, WeekDay.SA, WeekDay.SU) - else - listOf(WeekDay.SU, WeekDay.MO, WeekDay.TU, WeekDay.WE, WeekDay.TH, WeekDay.FR, WeekDay.SA) + val weekdays = DateTimeUtils.getLocalizedDaysOfWeek().map { it.toICal4jWeekDay() } fun buildRRule(): Recur? { diff --git a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt index 6f9885ebc..183d5f53e 100644 --- a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt +++ b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt @@ -210,10 +210,14 @@ object DateTimeUtils { } /** - * @return true if the first day of the week is monday for the local device, else false + * @return the seven [DayOfWeek]s of the week, ordered starting with the device's first day of + * the week (respecting the "Regional preferences -> First day of week" system setting, see + * [getLocalizedFirstDayOfWeek]). */ - fun isLocalizedWeekstartMonday() = - getLocalizedFirstDayOfWeek() == DayOfWeek.MONDAY + fun getLocalizedDaysOfWeek(): List { + val firstDayOfWeek = getLocalizedFirstDayOfWeek() + return (0L until 7L).map { firstDayOfWeek.plus(it) } + } /** * @return [WeekFields] that use the device's first day of the week (respecting the From bde4583d97172e25b0482f20708749242ac8edd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:08:13 +0000 Subject: [PATCH 3/5] Make DatePicker respect system first day of week Material3's DatePicker and DateRangePicker derive their first day of the week from WeekFields.of(locale), which only considers the language/region of the locale and ignores the "Regional preferences -> First day of week" system setting (the locale's "-u-fw-" unicode extension). The composables expose no way to set the first day of the week directly, and the locale is baked into the picker state at rememberDatePickerState() time via the active LocalConfiguration. Add DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(), which returns a locale whose region makes WeekFields.of(...) resolve to the device's first day of the week (getLocalizedFirstDayOfWeek()) while keeping the language so month/weekday names are unchanged. Create the picker states under a CompositionLocalProvider that supplies this locale so the calendars start on the correct day. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --- .../jtx/util/DateTimeUtilsAndroidTest.kt | 39 ++++++++++++++++++ .../ui/reusable/dialogs/DatePickerDialog.kt | 41 +++++++++++++------ .../reusable/dialogs/DateRangePickerDialog.kt | 27 ++++++++++-- .../java/at/techbee/jtx/util/DateTimeUtils.kt | 28 +++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt index 36ec1c0e3..d739cb8bb 100644 --- a/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt +++ b/app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt @@ -10,6 +10,7 @@ package at.techbee.jtx.util import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import at.techbee.jtx.util.DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek import at.techbee.jtx.util.DateTimeUtils.getLocalizedDaysOfWeek import at.techbee.jtx.util.DateTimeUtils.getLocalizedFirstDayOfWeek import org.junit.After @@ -17,6 +18,7 @@ import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import java.time.DayOfWeek +import java.time.temporal.WeekFields import java.util.Locale @@ -93,4 +95,41 @@ class DateTimeUtilsAndroidTest { ) } + // The DatePicker workaround has to produce a locale whose WeekFields.of(...) resolves to the + // device's first day of the week, because that is how Material3 derives it. + + @Test + fun getLocaleForLocalizedFirstDayOfWeek_US_weekFieldsStartSunday() { + Locale.setDefault(Locale.US) + val locale = getLocaleForLocalizedFirstDayOfWeek() + assertEquals(DayOfWeek.SUNDAY, WeekFields.of(locale).firstDayOfWeek) + } + + @Test + fun getLocaleForLocalizedFirstDayOfWeek_GERMAN_weekFieldsStartMonday() { + Locale.setDefault(Locale.GERMAN) + val locale = getLocaleForLocalizedFirstDayOfWeek() + assertEquals(DayOfWeek.MONDAY, WeekFields.of(locale).firstDayOfWeek) + } + + @Test + fun getLocaleForLocalizedFirstDayOfWeek_US_withOverrideMonday_weekFieldsStartMonday() { + Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon")) + val locale = getLocaleForLocalizedFirstDayOfWeek() + assertEquals(DayOfWeek.MONDAY, WeekFields.of(locale).firstDayOfWeek) + } + + @Test + fun getLocaleForLocalizedFirstDayOfWeek_GERMAN_withOverrideSunday_weekFieldsStartSunday() { + Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun")) + val locale = getLocaleForLocalizedFirstDayOfWeek() + assertEquals(DayOfWeek.SUNDAY, WeekFields.of(locale).firstDayOfWeek) + } + + @Test + fun getLocaleForLocalizedFirstDayOfWeek_keepsLanguage() { + Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun")) + assertEquals("de", getLocaleForLocalizedFirstDayOfWeek().language) + } + } diff --git a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt index 5566bb3a2..4df288fdf 100644 --- a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt +++ b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt @@ -8,6 +8,7 @@ package at.techbee.jtx.ui.reusable.dialogs +import android.content.res.Configuration import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement @@ -28,6 +29,7 @@ import androidx.compose.material.icons.outlined.TravelExplore import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerState import androidx.compose.material3.DisplayMode import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -41,6 +43,7 @@ import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -50,6 +53,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -104,19 +108,32 @@ fun DatePickerDialog( ?.let { ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(timezone)) } ?: minDate - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = initialZonedDateTime?.toInstant()?.toEpochMilli()?.plus(initialZonedDateTime.offset.totalSeconds*1000), - selectableDates = object: SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return if (allowedDates.isNotEmpty()) - allowedDates.any { - utcTimeMillis == it.toLocalDate().atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli() - } - else - true - } + // Material3's DatePicker takes the first day of the week from the locale that is active when + // its state is created, and that derivation ignores the system "first day of week" setting. + // Create the state under a configuration whose locale reflects that setting (see + // DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day. + val configuration = LocalConfiguration.current + val firstDayOfWeekConfiguration = remember(configuration) { + Configuration(configuration).apply { + setLocale(DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0])) } - ) + } + lateinit var datePickerState: DatePickerState + CompositionLocalProvider(LocalConfiguration provides firstDayOfWeekConfiguration) { + datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialZonedDateTime?.toInstant()?.toEpochMilli()?.plus(initialZonedDateTime.offset.totalSeconds*1000), + selectableDates = object: SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return if (allowedDates.isNotEmpty()) + allowedDates.any { + utcTimeMillis == it.toLocalDate().atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli() + } + else + true + } + } + ) + } val timePickerState = rememberTimePickerState(initialZonedDateTime?.hour?:0, initialZonedDateTime?.minute?:0) val showTabs = !dateOnly || allowNull val pagerState = rememberPagerState(initialPage = 0, pageCount = { if(showTabs) 3 else 1 }) diff --git a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt index d1031e219..b938c961b 100644 --- a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt +++ b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt @@ -8,25 +8,31 @@ package at.techbee.jtx.ui.reusable.dialogs +import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.requiredWidth import androidx.compose.material3.AlertDialog import androidx.compose.material3.DateRangePicker +import androidx.compose.material3.DateRangePickerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import at.techbee.jtx.R +import at.techbee.jtx.util.DateTimeUtils import kotlin.time.Duration.Companion.days @@ -39,10 +45,23 @@ fun DateRangePickerDialog( onDismiss: () -> Unit ) { - val dateRangePickerState = rememberDateRangePickerState( - initialSelectedStartDateMillis = dateRangeStart, - initialSelectedEndDateMillis = dateRangeEnd - ) + // Material3's DateRangePicker takes the first day of the week from the locale that is active + // when its state is created, and that derivation ignores the system "first day of week" + // setting. Create the state under a configuration whose locale reflects that setting (see + // DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day. + val configuration = LocalConfiguration.current + val firstDayOfWeekConfiguration = remember(configuration) { + Configuration(configuration).apply { + setLocale(DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0])) + } + } + lateinit var dateRangePickerState: DateRangePickerState + CompositionLocalProvider(LocalConfiguration provides firstDayOfWeekConfiguration) { + dateRangePickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = dateRangeStart, + initialSelectedEndDateMillis = dateRangeEnd + ) + } AlertDialog( properties = DialogProperties(usePlatformDefaultWidth = false), // Workaround due to Google Issue: https://issuetracker.google.com/issues/194911971?pli=1 diff --git a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt index 183d5f53e..187c8fbea 100644 --- a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt +++ b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt @@ -229,6 +229,34 @@ object DateTimeUtils { WeekFields.of(Locale.getDefault()).minimalDaysInFirstWeek ) + /** + * Material3's DatePicker/DateRangePicker derive their first day of the week from + * WeekFields.of(locale), which only looks at the language/region of the locale and ignores the + * "Regional preferences -> First day of week" system setting (the locale's "-u-fw-" unicode + * extension). As those composables offer no way to set the first day of the week directly, this + * returns a locale, based on [baseLocale], whose region makes WeekFields.of(...) resolve to + * [getLocalizedFirstDayOfWeek]. Only the region is changed, so the language (and therefore the + * month/weekday names) of [baseLocale] is preserved. + * + * @return the adjusted locale, or [baseLocale] unchanged if it already starts the week on the + * desired day, or if that day cannot be represented by a region (WeekFields only knows Monday, + * Friday, Saturday and Sunday as first days of the week). + */ + fun getLocaleForLocalizedFirstDayOfWeek(baseLocale: Locale = Locale.getDefault()): Locale { + val firstDayOfWeek = getLocalizedFirstDayOfWeek() + if (WeekFields.of(baseLocale).firstDayOfWeek == firstDayOfWeek) + return baseLocale + // representative regions whose CLDR week data uses the respective day as first day of week + val region = when (firstDayOfWeek) { + DayOfWeek.MONDAY -> "GB" + DayOfWeek.FRIDAY -> "MV" + DayOfWeek.SATURDAY -> "SA" + DayOfWeek.SUNDAY -> "US" + else -> return baseLocale + } + return Locale.Builder().setLocale(baseLocale).setRegion(region).build() + } + fun addLongToCSVString(listAsString: String?, value: Long?): String? { From 88ac8cf032e9cf3ab390fa30a2fbc5e7e8c971a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:16:18 +0000 Subject: [PATCH 4/5] Use DatePickerState locale parameter instead of config override Material3 exposes an explicit `locale` parameter via the DatePickerState and DateRangePickerState factory functions. Use it to pass the first-day-of-week-aware locale directly, replacing the more verbose CompositionLocalProvider workaround. Note that the underlying library still derives the first day of the week from WeekFields.of(locale), so the region-adjusting getLocaleForLocalizedFirstDayOfWeek() is still required. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --- .../ui/reusable/dialogs/DatePickerDialog.kt | 21 +++++------------- .../reusable/dialogs/DateRangePickerDialog.kt | 22 ++++++------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt index 4df288fdf..e7b96a796 100644 --- a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt +++ b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt @@ -8,7 +8,6 @@ package at.techbee.jtx.ui.reusable.dialogs -import android.content.res.Configuration import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement @@ -40,10 +39,8 @@ import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -108,19 +105,13 @@ fun DatePickerDialog( ?.let { ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(timezone)) } ?: minDate - // Material3's DatePicker takes the first day of the week from the locale that is active when - // its state is created, and that derivation ignores the system "first day of week" setting. - // Create the state under a configuration whose locale reflects that setting (see - // DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day. + // Material3's DatePicker derives the first day of the week from WeekFields.of(locale), which + // ignores the system "first day of week" setting. Pass a locale that reflects that setting + // (see DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day. val configuration = LocalConfiguration.current - val firstDayOfWeekConfiguration = remember(configuration) { - Configuration(configuration).apply { - setLocale(DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0])) - } - } - lateinit var datePickerState: DatePickerState - CompositionLocalProvider(LocalConfiguration provides firstDayOfWeekConfiguration) { - datePickerState = rememberDatePickerState( + val datePickerState = remember(configuration) { + DatePickerState( + locale = DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0]), initialSelectedDateMillis = initialZonedDateTime?.toInstant()?.toEpochMilli()?.plus(initialZonedDateTime.offset.totalSeconds*1000), selectableDates = object: SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { diff --git a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt index b938c961b..545bd468d 100644 --- a/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt +++ b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt @@ -8,7 +8,6 @@ package at.techbee.jtx.ui.reusable.dialogs -import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,9 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,19 +42,14 @@ fun DateRangePickerDialog( onDismiss: () -> Unit ) { - // Material3's DateRangePicker takes the first day of the week from the locale that is active - // when its state is created, and that derivation ignores the system "first day of week" - // setting. Create the state under a configuration whose locale reflects that setting (see - // DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day. + // Material3's DateRangePicker derives the first day of the week from WeekFields.of(locale), + // which ignores the system "first day of week" setting. Pass a locale that reflects that + // setting (see DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on + // the right day. val configuration = LocalConfiguration.current - val firstDayOfWeekConfiguration = remember(configuration) { - Configuration(configuration).apply { - setLocale(DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0])) - } - } - lateinit var dateRangePickerState: DateRangePickerState - CompositionLocalProvider(LocalConfiguration provides firstDayOfWeekConfiguration) { - dateRangePickerState = rememberDateRangePickerState( + val dateRangePickerState = remember(configuration) { + DateRangePickerState( + locale = DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0]), initialSelectedStartDateMillis = dateRangeStart, initialSelectedEndDateMillis = dateRangeEnd ) From e35b1bf52590089e9fdc6ab00227285aec3cf32a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 09:37:17 +0000 Subject: [PATCH 5/5] Document DatePicker first-day-of-week limitation Material3's DatePicker derives its first day of the week from WeekFields.of(locale), which can only ever yield a day that some region uses (Monday, Friday, Saturday or Sunday). Tuesday/Wednesday/Thursday cannot be represented, so the workaround falls back to the locale default for those. Make this explicit in the documentation so it is not mistaken for a bug. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --- .../java/at/techbee/jtx/util/DateTimeUtils.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt index 187c8fbea..ef4428793 100644 --- a/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt +++ b/app/src/main/java/at/techbee/jtx/util/DateTimeUtils.kt @@ -230,17 +230,23 @@ object DateTimeUtils { ) /** - * Material3's DatePicker/DateRangePicker derive their first day of the week from - * WeekFields.of(locale), which only looks at the language/region of the locale and ignores the - * "Regional preferences -> First day of week" system setting (the locale's "-u-fw-" unicode - * extension). As those composables offer no way to set the first day of the week directly, this - * returns a locale, based on [baseLocale], whose region makes WeekFields.of(...) resolve to - * [getLocalizedFirstDayOfWeek]. Only the region is changed, so the language (and therefore the - * month/weekday names) of [baseLocale] is preserved. + * Material3's DatePicker/DateRangePicker derive their first day of the week solely from + * `WeekFields.of(locale).firstDayOfWeek`, which only looks at the language/region of the locale + * and ignores the "Regional preferences -> First day of week" system setting (the locale's + * "-u-fw-" unicode extension). As those composables offer no way to set the first day of the + * week directly, this returns a locale, based on [baseLocale], whose region makes + * WeekFields.of(...) resolve to [getLocalizedFirstDayOfWeek]. Only the region is changed, so the + * language (and therefore the month/weekday names) of [baseLocale] is preserved. + * + * Limitation: `WeekFields.of(...)` can only ever yield a first day of the week that some region + * actually uses, and worldwide that is only Monday, Friday, Saturday or Sunday. If the user + * picks Tuesday, Wednesday or Thursday (Android allows any day), Material3's picker cannot + * represent it, so this falls back to [baseLocale] and the picker keeps the locale's default + * first day. Components that render the week themselves (see [getLocalizedDaysOfWeek]) are not + * affected by this and honour any first day. * * @return the adjusted locale, or [baseLocale] unchanged if it already starts the week on the - * desired day, or if that day cannot be represented by a region (WeekFields only knows Monday, - * Friday, Saturday and Sunday as first days of the week). + * desired day, or if that day cannot be represented by a region (see the limitation above). */ fun getLocaleForLocalizedFirstDayOfWeek(baseLocale: Locale = Locale.getDefault()): Locale { val firstDayOfWeek = getLocalizedFirstDayOfWeek() @@ -252,7 +258,7 @@ object DateTimeUtils { DayOfWeek.FRIDAY -> "MV" DayOfWeek.SATURDAY -> "SA" DayOfWeek.SUNDAY -> "US" - else -> return baseLocale + else -> return baseLocale // Tue/Wed/Thu: no region uses these, Material3 can't show them } return Locale.Builder().setLocale(baseLocale).setRegion(region).build() }