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..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,10 +10,15 @@ package at.techbee.jtx.util import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest -import at.techbee.jtx.util.DateTimeUtils.isLocalizedWeekstartMonday +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 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 @@ -21,17 +26,110 @@ import java.util.Locale @SmallTest class DateTimeUtilsAndroidTest { + private val defaultLocale: Locale = Locale.getDefault() + + @After + fun tearDown() { + Locale.setDefault(defaultLocale) + } @Test - fun isLocalizedWeekstartMonday_GERMAN() { + fun getLocalizedFirstDayOfWeek_GERMAN() { Locale.setDefault(Locale.GERMAN) - assertEquals(true, isLocalizedWeekstartMonday()) + assertEquals(DayOfWeek.MONDAY, getLocalizedFirstDayOfWeek()) } @Test - fun isLocalizedWeekstartMonday_US() { + fun getLocalizedFirstDayOfWeek_US() { Locale.setDefault(Locale.US) - assertEquals(false, isLocalizedWeekstartMonday()) + 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()) + } + + @Test + fun getLocalizedFirstDayOfWeek_GERMAN_withFirstDayOfWeekOverrideSunday() { + Locale.setDefault(Locale.forLanguageTag("de-DE-u-fw-sun")) + assertEquals(DayOfWeek.SUNDAY, getLocalizedFirstDayOfWeek()) + } + + @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() + ) + } + + // 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) } -} \ 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/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/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/ui/reusable/dialogs/DatePickerDialog.kt b/app/src/main/java/at/techbee/jtx/ui/reusable/dialogs/DatePickerDialog.kt index 5566bb3a2..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 @@ -28,6 +28,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 @@ -38,7 +39,6 @@ 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.LaunchedEffect @@ -50,6 +50,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 +105,26 @@ 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 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 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 { + 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..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 @@ -14,19 +14,22 @@ 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.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 +42,18 @@ fun DateRangePickerDialog( onDismiss: () -> Unit ) { - val dateRangePickerState = rememberDateRangePickerState( - initialSelectedStartDateMillis = dateRangeStart, - initialSelectedEndDateMillis = dateRangeEnd - ) + // 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 dateRangePickerState = remember(configuration) { + DateRangePickerState( + locale = DateTimeUtils.getLocaleForLocalizedFirstDayOfWeek(configuration.locales[0]), + 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 db222b1cf..ef4428793 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 @@ -186,10 +187,81 @@ object DateTimeUtils { /** - * @return true if the first day of the week is monday for the local device, else false + * 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 isLocalizedWeekstartMonday() = - WeekFields.of(Locale.getDefault()).firstDayOfWeek == DayOfWeek.MONDAY + 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 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 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 + * "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 + ) + + /** + * 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 (see the limitation above). + */ + 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 // Tue/Wed/Thu: no region uses these, Material3 can't show them + } + return Locale.Builder().setLocale(baseLocale).setRegion(region).build() + } fun addLongToCSVString(listAsString: String?, value: Long?): String? {