Skip to content

Commit b29df00

Browse files
Respect system "First day of week" setting in week calculations (#2352)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyppcLmiTZZ4xMedBYoysx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96399a5 commit b29df00

7 files changed

Lines changed: 234 additions & 35 deletions

File tree

app/src/androidTest/java/at/techbee/jtx/util/DateTimeUtilsAndroidTest.kt

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,126 @@ package at.techbee.jtx.util
1010

1111
import androidx.test.ext.junit.runners.AndroidJUnit4
1212
import androidx.test.filters.SmallTest
13-
import at.techbee.jtx.util.DateTimeUtils.isLocalizedWeekstartMonday
13+
import at.techbee.jtx.util.DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek
14+
import at.techbee.jtx.util.DateTimeUtils.getLocalizedDaysOfWeek
15+
import at.techbee.jtx.util.DateTimeUtils.getLocalizedFirstDayOfWeek
16+
import org.junit.After
1417
import org.junit.Assert.assertEquals
1518
import org.junit.Test
1619
import org.junit.runner.RunWith
20+
import java.time.DayOfWeek
21+
import java.time.temporal.WeekFields
1722
import java.util.Locale
1823

1924

2025
@RunWith(AndroidJUnit4::class)
2126
@SmallTest
2227
class DateTimeUtilsAndroidTest {
2328

29+
private val defaultLocale: Locale = Locale.getDefault()
30+
31+
@After
32+
fun tearDown() {
33+
Locale.setDefault(defaultLocale)
34+
}
2435

2536
@Test
26-
fun isLocalizedWeekstartMonday_GERMAN() {
37+
fun getLocalizedFirstDayOfWeek_GERMAN() {
2738
Locale.setDefault(Locale.GERMAN)
28-
assertEquals(true, isLocalizedWeekstartMonday())
39+
assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek())
2940
}
3041

3142
@Test
32-
fun isLocalizedWeekstartMonday_US() {
43+
fun getLocalizedFirstDayOfWeek_US() {
3344
Locale.setDefault(Locale.US)
34-
assertEquals(false, isLocalizedWeekstartMonday())
45+
assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek())
46+
}
47+
48+
@Test
49+
fun getLocalizedFirstDayOfWeek_US_withFirstDayOfWeekOverrideMonday() {
50+
// Emulates the "Regional preferences -> First day of week = Monday" system setting,
51+
// which is exposed as the "-u-fw-mon" unicode extension on the default locale.
52+
Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon"))
53+
assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek())
54+
}
55+
56+
@Test
57+
fun getLocalizedFirstDayOfWeek_GERMAN_withFirstDayOfWeekOverrideSunday() {
58+
Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun"))
59+
assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek())
60+
}
61+
62+
@Test
63+
fun getLocalizedDaysOfWeek_GERMAN_startsWithMonday() {
64+
Locale.setDefault(Locale.GERMAN)
65+
assertEquals(
66+
listOf(
67+
DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY,
68+
DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY
69+
),
70+
getLocalizedDaysOfWeek()
71+
)
72+
}
73+
74+
@Test
75+
fun getLocalizedDaysOfWeek_US_startsWithSunday() {
76+
Locale.setDefault(Locale.US)
77+
assertEquals(
78+
listOf(
79+
DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
80+
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY
81+
),
82+
getLocalizedDaysOfWeek()
83+
)
84+
}
85+
86+
@Test
87+
fun getLocalizedDaysOfWeek_US_withFirstDayOfWeekOverrideSaturday() {
88+
Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-sat"))
89+
assertEquals(
90+
listOf(
91+
DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY,
92+
DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY
93+
),
94+
getLocalizedDaysOfWeek()
95+
)
96+
}
97+
98+
// The DatePicker workaround has to produce a locale whose WeekFields.of(...) resolves to the
99+
// device's first day of the week, because that is how Material3 derives it.
100+
101+
@Test
102+
fun getLocaleForLocalizedFirstDayOfWeek_US_weekFieldsStartSunday() {
103+
Locale.setDefault(Locale.US)
104+
val locale = getLocaleForLocalizedFirstDayOfWeek()
105+
assertEquals(DayOfWeek.SUNDAY, WeekFields.of(locale).firstDayOfWeek)
106+
}
107+
108+
@Test
109+
fun getLocaleForLocalizedFirstDayOfWeek_GERMAN_weekFieldsStartMonday() {
110+
Locale.setDefault(Locale.GERMAN)
111+
val locale = getLocaleForLocalizedFirstDayOfWeek()
112+
assertEquals(DayOfWeek.MONDAY, WeekFields.of(locale).firstDayOfWeek)
113+
}
114+
115+
@Test
116+
fun getLocaleForLocalizedFirstDayOfWeek_US_withOverrideMonday_weekFieldsStartMonday() {
117+
Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon"))
118+
val locale = getLocaleForLocalizedFirstDayOfWeek()
119+
assertEquals(DayOfWeek.MONDAY, WeekFields.of(locale).firstDayOfWeek)
120+
}
121+
122+
@Test
123+
fun getLocaleForLocalizedFirstDayOfWeek_GERMAN_withOverrideSunday_weekFieldsStartSunday() {
124+
Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun"))
125+
val locale = getLocaleForLocalizedFirstDayOfWeek()
126+
assertEquals(DayOfWeek.SUNDAY, WeekFields.of(locale).firstDayOfWeek)
127+
}
128+
129+
@Test
130+
fun getLocaleForLocalizedFirstDayOfWeek_keepsLanguage() {
131+
Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun"))
132+
assertEquals("de", getLocaleForLocalizedFirstDayOfWeek().language)
35133
}
36134

37-
}
135+
}

app/src/main/java/at/techbee/jtx/database/relations/ICal4ListRel.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import at.techbee.jtx.util.DateTimeUtils
3232
import java.time.Instant
3333
import java.time.ZonedDateTime
3434
import java.time.format.TextStyle
35-
import java.time.temporal.WeekFields
3635
import java.util.Locale
3736

3837

@@ -194,7 +193,7 @@ data class ICal4ListRel(
194193
val date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(ical4ListRel.iCal4List.dtstartTimezone)).toLocalDate()
195194
context.getString(
196195
R.string.week_number_year,
197-
date[WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear()],
196+
date[DateTimeUtils.getLocalizedWeekFields().weekOfWeekBasedYear()],
198197
date.year
199198
)
200199
}
@@ -229,7 +228,7 @@ data class ICal4ListRel(
229228
val date = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(ical4ListRel.iCal4List.dueTimezone)).toLocalDate()
230229
context.getString(
231230
R.string.week_number_year,
232-
date[WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear()],
231+
date[DateTimeUtils.getLocalizedWeekFields().weekOfWeekBasedYear()],
233232
date.year
234233
)
235234
}

app/src/main/java/at/techbee/jtx/ui/detail/DetailsCardRecur.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ import java.util.Locale
8080
import kotlin.math.absoluteValue
8181

8282

83+
/**
84+
* @return the ical4j [WeekDay] that corresponds to this [DayOfWeek]
85+
*/
86+
private fun DayOfWeek.toICal4jWeekDay(): WeekDay = when (this) {
87+
DayOfWeek.MONDAY -> WeekDay.MO
88+
DayOfWeek.TUESDAY -> WeekDay.TU
89+
DayOfWeek.WEDNESDAY -> WeekDay.WE
90+
DayOfWeek.THURSDAY -> WeekDay.TH
91+
DayOfWeek.FRIDAY -> WeekDay.FR
92+
DayOfWeek.SATURDAY -> WeekDay.SA
93+
DayOfWeek.SUNDAY -> WeekDay.SU
94+
}
95+
96+
8397
@SuppressLint("LocalContextGetResourceValueCall")
8498
@OptIn(ExperimentalLayoutApi::class)
8599
@Composable
@@ -128,10 +142,7 @@ fun DetailsCardRecur(
128142
var showDetachSingleFromSeriesDialog by rememberSaveable { mutableStateOf(false) }
129143
var showDetachAllFromSeriesDialog by rememberSaveable { mutableStateOf(false) }
130144

131-
val weekdays = if (DateTimeUtils.isLocalizedWeekstartMonday())
132-
listOf(WeekDay.MO, WeekDay.TU, WeekDay.WE, WeekDay.TH, WeekDay.FR, WeekDay.SA, WeekDay.SU)
133-
else
134-
listOf(WeekDay.SU, WeekDay.MO, WeekDay.TU, WeekDay.WE, WeekDay.TH, WeekDay.FR, WeekDay.SA)
145+
val weekdays = DateTimeUtils.getLocalizedDaysOfWeek().map { it.toICal4jWeekDay() }
135146

136147

137148
fun buildRRule(): Recur<Temporal>? {

app/src/main/java/at/techbee/jtx/ui/list/ListScreenWeek.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ fun ListScreenWeek(
8484
val currentMonth = remember(currentDate) { currentDate.yearMonth }
8585
val startMonth = remember(currentDate) { currentMonth.minusMonths(500) }
8686
val endMonth = remember(currentDate) { currentMonth.plusMonths(500) }
87-
val daysOfWeek = remember { daysOfWeek() }
87+
val daysOfWeek = remember { daysOfWeek(firstDayOfWeek = DateTimeUtils.getLocalizedFirstDayOfWeek()) }
8888

8989
val scrollId by scrollOnceId.observeAsState(null)
9090
val weekState = rememberWeekCalendarState(

app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.outlined.TravelExplore
2828
import androidx.compose.material3.AlertDialog
2929
import androidx.compose.material3.Checkbox
3030
import androidx.compose.material3.DatePicker
31+
import androidx.compose.material3.DatePickerState
3132
import androidx.compose.material3.DisplayMode
3233
import androidx.compose.material3.ExperimentalMaterial3Api
3334
import androidx.compose.material3.Icon
@@ -38,7 +39,6 @@ import androidx.compose.material3.Tab
3839
import androidx.compose.material3.Text
3940
import androidx.compose.material3.TextButton
4041
import androidx.compose.material3.TimePicker
41-
import androidx.compose.material3.rememberDatePickerState
4242
import androidx.compose.material3.rememberTimePickerState
4343
import androidx.compose.runtime.Composable
4444
import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +50,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
5050
import androidx.compose.runtime.setValue
5151
import androidx.compose.ui.Alignment
5252
import androidx.compose.ui.Modifier
53+
import androidx.compose.ui.platform.LocalConfiguration
5354
import androidx.compose.ui.platform.LocalInspectionMode
5455
import androidx.compose.ui.res.stringResource
5556
import androidx.compose.ui.text.font.FontStyle
@@ -104,19 +105,26 @@ fun DatePickerDialog(
104105
?.let { ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), DateTimeUtils.requireTzId(timezone)) }
105106
?: minDate
106107

107-
val datePickerState = rememberDatePickerState(
108-
initialSelectedDateMillis = initialZonedDateTime?.toInstant()?.toEpochMilli()?.plus(initialZonedDateTime.offset.totalSeconds*1000),
109-
selectableDates = object: SelectableDates {
110-
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
111-
return if (allowedDates.isNotEmpty())
112-
allowedDates.any {
113-
utcTimeMillis == it.toLocalDate().atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli()
114-
}
115-
else
116-
true
108+
// Material3's DatePicker derives the first day of the week from WeekFields.of(locale), which
109+
// ignores the system "first day of week" setting. Pass a locale that reflects that setting
110+
// (see DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on the right day.
111+
val configuration = LocalConfiguration.current
112+
val datePickerState = remember(configuration) {
113+
DatePickerState(
114+
locale = DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0]),
115+
initialSelectedDateMillis = initialZonedDateTime?.toInstant()?.toEpochMilli()?.plus(initialZonedDateTime.offset.totalSeconds*1000),
116+
selectableDates = object: SelectableDates {
117+
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
118+
return if (allowedDates.isNotEmpty())
119+
allowedDates.any {
120+
utcTimeMillis == it.toLocalDate().atStartOfDay().atZone(ZoneId.of("UTC")).toInstant().toEpochMilli()
121+
}
122+
else
123+
true
124+
}
117125
}
118-
}
119-
)
126+
)
127+
}
120128
val timePickerState = rememberTimePickerState(initialZonedDateTime?.hour?:0, initialZonedDateTime?.minute?:0)
121129
val showTabs = !dateOnly || allowNull
122130
val pagerState = rememberPagerState(initialPage = 0, pageCount = { if(showTabs) 3 else 1 })

app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DateRangePickerDialog.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,22 @@ import androidx.compose.foundation.layout.Column
1414
import androidx.compose.foundation.layout.requiredWidth
1515
import androidx.compose.material3.AlertDialog
1616
import androidx.compose.material3.DateRangePicker
17+
import androidx.compose.material3.DateRangePickerState
1718
import androidx.compose.material3.ExperimentalMaterial3Api
1819
import androidx.compose.material3.MaterialTheme
1920
import androidx.compose.material3.Text
2021
import androidx.compose.material3.TextButton
21-
import androidx.compose.material3.rememberDateRangePickerState
2222
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.remember
2324
import androidx.compose.ui.Alignment
2425
import androidx.compose.ui.Modifier
26+
import androidx.compose.ui.platform.LocalConfiguration
2527
import androidx.compose.ui.res.stringResource
2628
import androidx.compose.ui.tooling.preview.Preview
2729
import androidx.compose.ui.unit.dp
2830
import androidx.compose.ui.window.DialogProperties
2931
import at.techbee.jtx.R
32+
import at.techbee.jtx.util.DateTimeUtils
3033
import kotlin.time.Duration.Companion.days
3134

3235

@@ -39,10 +42,18 @@ fun DateRangePickerDialog(
3942
onDismiss: () -> Unit
4043
) {
4144

42-
val dateRangePickerState = rememberDateRangePickerState(
43-
initialSelectedStartDateMillis = dateRangeStart,
44-
initialSelectedEndDateMillis = dateRangeEnd
45-
)
45+
// Material3's DateRangePicker derives the first day of the week from WeekFields.of(locale),
46+
// which ignores the system "first day of week" setting. Pass a locale that reflects that
47+
// setting (see DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek) so the calendar starts on
48+
// the right day.
49+
val configuration = LocalConfiguration.current
50+
val dateRangePickerState = remember(configuration) {
51+
DateRangePickerState(
52+
locale = DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0]),
53+
initialSelectedStartDateMillis = dateRangeStart,
54+
initialSelectedEndDateMillis = dateRangeEnd
55+
)
56+
}
4657

4758
AlertDialog(
4859
properties = DialogProperties(usePlatformDefaultWidth = false), // Workaround due to Google Issue: https://issuetracker.google.com/issues/194911971?pli=1

0 commit comments

Comments
 (0)