Merge "Adds a model to be used internally by date pickers" into androidx-main
diff --git a/compose/material3/material3/api/current.txt b/compose/material3/material3/api/current.txt
index bc57514..f96b55a 100644
--- a/compose/material3/material3/api/current.txt
+++ b/compose/material3/material3/api/current.txt
@@ -103,6 +103,9 @@
method @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
+ public final class CalendarModelKt {
+ }
+
@androidx.compose.runtime.Immutable public final class CardColors {
}
@@ -681,6 +684,9 @@
package androidx.compose.material3.internal {
+ public final class AndroidDatePickerModel_androidKt {
+ }
+
public final class ExposedDropdownMenuPopupKt {
}
diff --git a/compose/material3/material3/api/public_plus_experimental_current.txt b/compose/material3/material3/api/public_plus_experimental_current.txt
index 906e393..c815b0b 100644
--- a/compose/material3/material3/api/public_plus_experimental_current.txt
+++ b/compose/material3/material3/api/public_plus_experimental_current.txt
@@ -132,6 +132,9 @@
method @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
+ public final class CalendarModelKt {
+ }
+
@androidx.compose.runtime.Immutable public final class CardColors {
}
@@ -1013,6 +1016,9 @@
package androidx.compose.material3.internal {
+ public final class AndroidDatePickerModel_androidKt {
+ }
+
public final class ExposedDropdownMenuPopupKt {
}
diff --git a/compose/material3/material3/api/restricted_current.txt b/compose/material3/material3/api/restricted_current.txt
index bc57514..f96b55a 100644
--- a/compose/material3/material3/api/restricted_current.txt
+++ b/compose/material3/material3/api/restricted_current.txt
@@ -103,6 +103,9 @@
method @androidx.compose.runtime.Composable public static void TextButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.material3.ButtonColors colors, optional androidx.compose.material3.ButtonElevation? elevation, optional androidx.compose.foundation.BorderStroke? border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
}
+ public final class CalendarModelKt {
+ }
+
@androidx.compose.runtime.Immutable public final class CardColors {
}
@@ -681,6 +684,9 @@
package androidx.compose.material3.internal {
+ public final class AndroidDatePickerModel_androidKt {
+ }
+
public final class ExposedDropdownMenuPopupKt {
}
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/CalendarModelTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/CalendarModelTest.kt
new file mode 100644
index 0000000..9b5997e
--- /dev/null
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/CalendarModelTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.material3.internal.CalendarModelImpl
+import androidx.compose.material3.internal.LegacyCalendarModelImpl
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import java.util.Locale
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@MediumTest
+@RunWith(Parameterized::class)
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(Build.VERSION_CODES.O)
+internal class CalendarModelTest(private val model: CalendarModel) {
+
+ @Test
+ fun dateCreation() {
+ val date = model.getDate(January2022Millis) // 1/1/2022
+ assertThat(date.year).isEqualTo(2022)
+ assertThat(date.month).isEqualTo(1)
+ assertThat(date.dayOfMonth).isEqualTo(1)
+ assertThat(date.utcTimeMillis).isEqualTo(January2022Millis)
+ }
+
+ @Test
+ fun dateRestore() {
+ val date =
+ CalendarDate(year = 2022, month = 1, dayOfMonth = 1, utcTimeMillis = January2022Millis)
+ assertThat(model.getDate(date.utcTimeMillis)).isEqualTo(date)
+ }
+
+ @Test
+ fun monthCreation() {
+ val date =
+ CalendarDate(year = 2022, month = 1, dayOfMonth = 1, utcTimeMillis = January2022Millis)
+ val monthFromDate = model.getMonth(date)
+ val monthFromMilli = model.getMonth(January2022Millis)
+ val monthFromYearMonth = model.getMonth(year = 2022, month = 1)
+ assertThat(monthFromDate).isEqualTo(monthFromMilli)
+ assertThat(monthFromDate).isEqualTo(monthFromYearMonth)
+ }
+
+ @Test
+ fun monthRestore() {
+ val month = model.getMonth(year = 1999, month = 12)
+ assertThat(model.getMonth(month.startUtcTimeMillis)).isEqualTo(month)
+ }
+
+ @Test
+ fun plusMinusMonth() {
+ val month = model.getMonth(January2022Millis) // 1/1/2022
+ val expectedNextMonth = model.getMonth(month.endUtcTimeMillis + 1) // 2/1/2022
+ val plusMonth = model.plusMonths(from = month, addedMonthsCount = 1)
+ assertThat(plusMonth).isEqualTo(expectedNextMonth)
+ assertThat(model.minusMonths(from = plusMonth, subtractedMonthsCount = 1)).isEqualTo(month)
+ }
+
+ @Test
+ fun parseDate() {
+ val expectedDate =
+ CalendarDate(year = 2022, month = 1, dayOfMonth = 1, utcTimeMillis = January2022Millis)
+ val parsedDate = model.parse("1/1/2022", "M/d/yyyy")
+ assertThat(parsedDate).isEqualTo(expectedDate)
+ }
+
+ @Test
+ fun formatDate() {
+ val date =
+ CalendarDate(year = 2022, month = 1, dayOfMonth = 1, utcTimeMillis = January2022Millis)
+ val month = model.plusMonths(model.getMonth(date), 2)
+ assertThat(model.format(date, "MM/dd/yyyy")).isEqualTo("01/01/2022")
+ assertThat(model.format(month, "MM/dd/yyyy")).isEqualTo("03/01/2022")
+ }
+
+ @Test
+ fun weekdayNames() {
+ // Ensure we are running on a US locale for this test.
+ Locale.setDefault(Locale.US)
+ val weekDays = model.weekdayNames
+ assertThat(weekDays).hasSize(DaysInWeek)
+ // Check that the first day is always "Monday", per ISO-8601 standard.
+ assertThat(weekDays.first().first).ignoringCase().contains("Monday")
+ weekDays.forEach {
+ assertThat(it.second.first().lowercaseChar()).isEqualTo(
+ it.first.first().lowercaseChar()
+ )
+ }
+ }
+
+ @Test
+ fun equalModelsOutput() {
+ // Note: This test ignores the parameters and just runs a few equality tests for the output.
+ // It will execute twice, but that should to tolerable :)
+ val newModel = CalendarModelImpl()
+ val legacyModel = LegacyCalendarModelImpl()
+
+ val date = newModel.getDate(January2022Millis) // 1/1/2022
+ val legacyDate = legacyModel.getDate(January2022Millis)
+ val month = newModel.getMonth(date)
+ val legacyMonth = legacyModel.getMonth(date)
+
+ assertThat(newModel.today).isEqualTo(legacyModel.today)
+ assertThat(month).isEqualTo(legacyMonth)
+ assertThat(newModel.plusMonths(month, 3)).isEqualTo(legacyModel.plusMonths(month, 3))
+ assertThat(date).isEqualTo(legacyDate)
+ assertThat(newModel.getDayOfWeek(date)).isEqualTo(legacyModel.getDayOfWeek(date))
+ assertThat(newModel.format(date, "MMM d, yyyy")).isEqualTo(
+ legacyModel.format(
+ date,
+ "MMM d, yyyy"
+ )
+ )
+ assertThat(newModel.format(month, "MMM yyyy")).isEqualTo(
+ legacyModel.format(
+ month,
+ "MMM yyyy"
+ )
+ )
+ }
+
+ internal companion object {
+ @Parameterized.Parameters(name = "{0}")
+ @JvmStatic
+ fun parameters() = arrayOf(
+ CalendarModelImpl(),
+ LegacyCalendarModelImpl()
+ )
+ }
+}
+
+private const val January2022Millis = 1640995200000
diff --git a/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/AndroidDatePickerModel.android.kt b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/AndroidDatePickerModel.android.kt
new file mode 100644
index 0000000..089aeb0
--- /dev/null
+++ b/compose/material3/material3/src/androidMain/kotlin/androidx/compose/material3/internal/AndroidDatePickerModel.android.kt
@@ -0,0 +1,357 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3.internal
+
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.material3.CalendarDate
+import androidx.compose.material3.CalendarModel
+import androidx.compose.material3.CalendarMonth
+import androidx.compose.material3.DaysInWeek
+import androidx.compose.material3.ExperimentalMaterial3Api
+import java.text.DateFormatSymbols
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.time.DayOfWeek
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
+import java.time.format.TextStyle
+import java.time.temporal.WeekFields
+import java.util.Calendar
+import java.util.Locale
+import java.util.TimeZone
+
+/**
+ * Creates a [CalendarModel] to be used by the date picker.
+ */
+@ExperimentalMaterial3Api
+internal fun createDefaultCalendarModel(): CalendarModel {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ CalendarModelImpl()
+ } else {
+ LegacyCalendarModelImpl()
+ }
+}
+
+/**
+ * A [CalendarModel] implementation for API < 26.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+internal class LegacyCalendarModelImpl : CalendarModel {
+
+ override val today
+ get(): CalendarDate {
+ val systemCalendar = Calendar.getInstance()
+ systemCalendar[Calendar.HOUR_OF_DAY] = 0
+ systemCalendar[Calendar.MINUTE] = 0
+ systemCalendar[Calendar.SECOND] = 0
+ systemCalendar[Calendar.MILLISECOND] = 0
+ val utcOffset =
+ systemCalendar.get(Calendar.ZONE_OFFSET) + systemCalendar.get(Calendar.DST_OFFSET)
+ return CalendarDate(
+ year = systemCalendar[Calendar.YEAR],
+ month = systemCalendar[Calendar.MONTH] + 1,
+ dayOfMonth = systemCalendar[Calendar.DAY_OF_MONTH],
+ utcTimeMillis = systemCalendar.timeInMillis + utcOffset
+ )
+ }
+
+ override val firstDayOfWeek: Int = dayInISO8601(Calendar.getInstance().firstDayOfWeek)
+
+ override val weekdayNames: List<Pair<String, String>> = buildList {
+ val weekdays = DateFormatSymbols(Locale.getDefault()).weekdays
+ val shortWeekdays = DateFormatSymbols(Locale.getDefault()).shortWeekdays
+ // Skip the first item, as it's empty, and the second item, as it represents Sunday while it
+ // should be last according to ISO-8601.
+ weekdays.drop(2).forEachIndexed { index, day ->
+ add(Pair(day, shortWeekdays[index + 2]))
+ }
+ // Add Sunday to the end.
+ add(Pair(weekdays[1], shortWeekdays[1]))
+ }
+
+ override fun getDate(timeInMillis: Long): CalendarDate {
+ val calendar = Calendar.getInstance(utcTimeZone)
+ calendar.timeInMillis = timeInMillis
+ return CalendarDate(
+ year = calendar[Calendar.YEAR],
+ month = calendar[Calendar.MONTH] + 1,
+ dayOfMonth = calendar[Calendar.DAY_OF_MONTH],
+ utcTimeMillis = timeInMillis
+ )
+ }
+
+ override fun getMonth(timeInMillis: Long): CalendarMonth {
+ val firstDayCalendar = Calendar.getInstance(utcTimeZone)
+ firstDayCalendar.timeInMillis = timeInMillis
+ firstDayCalendar[Calendar.DAY_OF_MONTH] = 1
+ return getMonth(firstDayCalendar)
+ }
+
+ override fun getMonth(date: CalendarDate): CalendarMonth {
+ return getMonth(date.year, date.month)
+ }
+
+ override fun getMonth(year: Int, month: Int): CalendarMonth {
+ val firstDayCalendar = Calendar.getInstance(utcTimeZone)
+ firstDayCalendar.clear()
+ firstDayCalendar[Calendar.YEAR] = year
+ firstDayCalendar[Calendar.MONTH] = month - 1
+ firstDayCalendar[Calendar.DAY_OF_MONTH] = 1
+ return getMonth(firstDayCalendar)
+ }
+
+ override fun getDayOfWeek(date: CalendarDate): Int {
+ return dayInISO8601(date.toCalendar(TimeZone.getDefault())[Calendar.DAY_OF_WEEK])
+ }
+
+ override fun plusMonths(from: CalendarMonth, addedMonthsCount: Int): CalendarMonth {
+ if (addedMonthsCount <= 0) return from
+
+ val laterMonth = from.toCalendar()
+ laterMonth.add(Calendar.MONTH, addedMonthsCount)
+ return getMonth(laterMonth)
+ }
+
+ override fun minusMonths(from: CalendarMonth, subtractedMonthsCount: Int): CalendarMonth {
+ if (subtractedMonthsCount <= 0) return from
+
+ val earlierMonth = from.toCalendar()
+ earlierMonth.add(Calendar.MONTH, -subtractedMonthsCount)
+ return getMonth(earlierMonth)
+ }
+
+ override fun format(month: CalendarMonth, pattern: String): String {
+ val dateFormat = SimpleDateFormat(pattern, Locale.getDefault())
+ dateFormat.timeZone = utcTimeZone
+ dateFormat.isLenient = false
+ return dateFormat.format(month.toCalendar().timeInMillis)
+ }
+
+ override fun format(date: CalendarDate, pattern: String): String {
+ val dateFormat = SimpleDateFormat(pattern, Locale.getDefault())
+ dateFormat.timeZone = utcTimeZone
+ dateFormat.isLenient = false
+ return dateFormat.format(date.toCalendar(utcTimeZone).timeInMillis)
+ }
+
+ override fun parse(date: String, pattern: String): CalendarDate? {
+ val dateFormat = SimpleDateFormat(pattern)
+ dateFormat.timeZone = utcTimeZone
+ dateFormat.isLenient = false
+ return try {
+ val parsedDate = dateFormat.parse(date) ?: return null
+ val calendar = Calendar.getInstance(utcTimeZone)
+ calendar.time = parsedDate
+ CalendarDate(
+ year = calendar[Calendar.YEAR],
+ month = calendar[Calendar.MONTH] + 1,
+ dayOfMonth = calendar[Calendar.DAY_OF_MONTH],
+ utcTimeMillis = calendar.timeInMillis
+ )
+ } catch (pe: ParseException) {
+ null
+ }
+ }
+
+ /**
+ * Returns a given [Calendar] day number as a day representation under ISO-8601, where the first
+ * day is defined as Monday.
+ */
+ private fun dayInISO8601(day: Int): Int {
+ val shiftedDay = (day + 6) % 7
+ return if (shiftedDay == 0) return /* Sunday */ 7 else shiftedDay
+ }
+
+ private fun getMonth(firstDayCalendar: Calendar): CalendarMonth {
+ val difference = dayInISO8601(firstDayCalendar[Calendar.DAY_OF_WEEK]) - firstDayOfWeek
+ val daysFromStartOfWeekToFirstOfMonth = if (difference < 0) {
+ difference + DaysInWeek
+ } else {
+ difference
+ }
+ return CalendarMonth(
+ year = firstDayCalendar[Calendar.YEAR],
+ month = firstDayCalendar[Calendar.MONTH] + 1,
+ numberOfDays = firstDayCalendar.getActualMaximum(Calendar.DAY_OF_MONTH),
+ daysFromStartOfWeekToFirstOfMonth = daysFromStartOfWeekToFirstOfMonth,
+ startUtcTimeMillis = firstDayCalendar.timeInMillis
+ )
+ }
+
+ private fun CalendarMonth.toCalendar(): Calendar {
+ val calendar = Calendar.getInstance(utcTimeZone)
+ calendar.timeInMillis = this.startUtcTimeMillis
+ return calendar
+ }
+
+ private fun CalendarDate.toCalendar(timeZone: TimeZone): Calendar {
+ val calendar = Calendar.getInstance(timeZone)
+ calendar.clear()
+ calendar[Calendar.YEAR] = this.year
+ calendar[Calendar.MONTH] = this.month - 1
+ calendar[Calendar.DAY_OF_MONTH] = this.dayOfMonth
+ return calendar
+ }
+
+ private var utcTimeZone = TimeZone.getTimeZone("UTC")
+}
+
+/**
+ * A [CalendarModel] implementation for API >= 26.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@RequiresApi(Build.VERSION_CODES.O)
+internal class CalendarModelImpl : CalendarModel {
+
+ override val today
+ get(): CalendarDate {
+ val systemLocalDate = LocalDate.now()
+ return CalendarDate(
+ year = systemLocalDate.year,
+ month = systemLocalDate.monthValue,
+ dayOfMonth = systemLocalDate.dayOfMonth,
+ utcTimeMillis = systemLocalDate.atTime(LocalTime.MIDNIGHT)
+ .atZone(utcTimeZoneId).toInstant().toEpochMilli()
+ )
+ }
+
+ override val firstDayOfWeek: Int = WeekFields.of(Locale.getDefault()).firstDayOfWeek.value
+
+ override val weekdayNames: List<Pair<String, String>> =
+ // This will start with Monday as the first day, according to ISO-8601.
+ with(Locale.getDefault()) {
+ DayOfWeek.values().map {
+ it.getDisplayName(
+ TextStyle.FULL,
+ /* locale = */ this
+ ) to it.getDisplayName(
+ TextStyle.NARROW,
+ /* locale = */ this
+ )
+ }
+ }
+
+ override fun getDate(timeInMillis: Long): CalendarDate {
+ val localDate =
+ Instant.ofEpochMilli(timeInMillis).atZone(utcTimeZoneId).toLocalDate()
+ return CalendarDate(
+ year = localDate.year,
+ month = localDate.monthValue,
+ dayOfMonth = localDate.dayOfMonth,
+ utcTimeMillis = timeInMillis
+ )
+ }
+
+ override fun getMonth(timeInMillis: Long): CalendarMonth {
+ return getMonth(
+ Instant.ofEpochMilli(timeInMillis).atZone(utcTimeZoneId).toLocalDate()
+ )
+ }
+
+ override fun getMonth(date: CalendarDate): CalendarMonth {
+ return getMonth(LocalDate.of(date.year, date.month, 1))
+ }
+
+ override fun getMonth(year: Int, month: Int): CalendarMonth {
+ return getMonth(LocalDate.of(year, month, 1))
+ }
+
+ override fun getDayOfWeek(date: CalendarDate): Int {
+ return date.toLocalDate().dayOfWeek.value
+ }
+
+ override fun plusMonths(from: CalendarMonth, addedMonthsCount: Int): CalendarMonth {
+ if (addedMonthsCount <= 0) return from
+
+ val firstDayLocalDate = from.toLocalDate()
+ val laterMonth = firstDayLocalDate.plusMonths(addedMonthsCount.toLong())
+ return getMonth(laterMonth)
+ }
+
+ override fun minusMonths(from: CalendarMonth, subtractedMonthsCount: Int): CalendarMonth {
+ if (subtractedMonthsCount <= 0) return from
+
+ val firstDayLocalDate = from.toLocalDate()
+ val earlierMonth = firstDayLocalDate.minusMonths(subtractedMonthsCount.toLong())
+ return getMonth(earlierMonth)
+ }
+
+ override fun format(month: CalendarMonth, pattern: String): String {
+ val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern(pattern)
+ return month.toLocalDate().format(formatter)
+ }
+
+ override fun format(date: CalendarDate, pattern: String): String {
+ val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern(pattern)
+ return date.toLocalDate().format(formatter)
+ }
+
+ override fun parse(date: String, pattern: String): CalendarDate? {
+ // TODO: A DateTimeFormatter can be reused.
+ val formatter = DateTimeFormatter.ofPattern(pattern)
+ return try {
+ val localDate = LocalDate.parse(date, formatter)
+ CalendarDate(
+ year = localDate.year,
+ month = localDate.month.value,
+ dayOfMonth = localDate.dayOfMonth,
+ utcTimeMillis = localDate.atTime(LocalTime.MIDNIGHT)
+ .atZone(utcTimeZoneId).toInstant().toEpochMilli()
+ )
+ } catch (pe: DateTimeParseException) {
+ null
+ }
+ }
+
+ private fun getMonth(firstDayLocalDate: LocalDate): CalendarMonth {
+ val difference = firstDayLocalDate.dayOfWeek.value - firstDayOfWeek
+ val daysFromStartOfWeekToFirstOfMonth = if (difference < 0) {
+ difference + DaysInWeek
+ } else {
+ difference
+ }
+ val firstDayEpochMillis =
+ firstDayLocalDate.atTime(LocalTime.MIDNIGHT).atZone(utcTimeZoneId).toInstant()
+ .toEpochMilli()
+ return CalendarMonth(
+ year = firstDayLocalDate.year,
+ month = firstDayLocalDate.monthValue,
+ numberOfDays = firstDayLocalDate.lengthOfMonth(),
+ daysFromStartOfWeekToFirstOfMonth = daysFromStartOfWeekToFirstOfMonth,
+ startUtcTimeMillis = firstDayEpochMillis
+ )
+ }
+
+ private fun CalendarMonth.toLocalDate(): LocalDate {
+ return Instant.ofEpochMilli(startUtcTimeMillis).atZone(utcTimeZoneId).toLocalDate()
+ }
+
+ private fun CalendarDate.toLocalDate(): LocalDate {
+ return LocalDate.of(
+ this.year,
+ this.month,
+ this.dayOfMonth
+ )
+ }
+
+ private val utcTimeZoneId = ZoneId.of("UTC")
+}
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/CalendarModel.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/CalendarModel.kt
new file mode 100644
index 0000000..54cbd7b
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/CalendarModel.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+@ExperimentalMaterial3Api
+internal interface CalendarModel {
+
+ /**
+ * A [CalendarDate] representing the current day.
+ */
+ val today: CalendarDate
+
+ /**
+ * Hold the first day of the week at the current `Locale` as an integer. The integer value
+ * follows the ISO-8601 standard and refer to Monday as 1, and Sunday as 7.
+ */
+ val firstDayOfWeek: Int
+
+ /**
+ * Holds a list of weekday names, starting from Monday as the first day in the list.
+ *
+ * Each item in this list is a [Pair] that holds the full name of the day, and its short
+ * abbreviation letter(s).
+ *
+ * Newer APIs (i.e. API 26+), a [Pair] will hold a full name and the first letter of the
+ * day.
+ * Older APIs that predate API 26 will hold a full name and the first three letters of the day.
+ */
+ val weekdayNames: List<Pair<String, String>>
+
+ /**
+ * Returns a [CalendarDate] from a given _UTC_ time in milliseconds.
+ *
+ * @param timeInMillis UTC milliseconds from the epoch
+ */
+ fun getDate(timeInMillis: Long): CalendarDate
+
+ /**
+ * Returns a [CalendarMonth] from a given _UTC_ time in milliseconds.
+ *
+ * @param timeInMillis UTC milliseconds from the epoch for the first day the month
+ */
+ fun getMonth(timeInMillis: Long): CalendarMonth
+
+ /**
+ * Returns a [CalendarMonth] from a given [CalendarDate].
+ *
+ * Note: This function ignores the [CalendarDate.dayOfMonth] value and just uses the date's
+ * year and month to resolve a [CalendarMonth].
+ *
+ * @param date a [CalendarDate] to resolve into a month
+ */
+ fun getMonth(date: CalendarDate): CalendarMonth
+
+ /**
+ * Returns a [CalendarMonth] from a given [year] and [month].
+ *
+ * @param year the month's year
+ * @param month an integer representing a month (e.g. JANUARY as 1, December as 12)
+ */
+ fun getMonth(year: Int, /* @IntRange(from = 1, to = 12) */ month: Int): CalendarMonth
+
+ /**
+ * Returns a day of week from a given [CalendarDate].
+ *
+ * @param date a [CalendarDate] to resolve
+ */
+ fun getDayOfWeek(date: CalendarDate): Int
+
+ /**
+ * Returns a [CalendarMonth] that is computed by adding a number of months, given as
+ * [addedMonthsCount], to a given month.
+ *
+ * @param from the [CalendarMonth] to add to
+ * @param addedMonthsCount the number of months to add
+ */
+ fun plusMonths(from: CalendarMonth, addedMonthsCount: Int): CalendarMonth
+
+ /**
+ * Returns a [CalendarMonth] that is computed by subtracting a number of months, given as
+ * [subtractedMonthsCount], from a given month.
+ *
+ * @param from the [CalendarMonth] to subtract from
+ * @param subtractedMonthsCount the number of months to subtract
+ */
+ fun minusMonths(from: CalendarMonth, subtractedMonthsCount: Int): CalendarMonth
+
+ /**
+ * Formats a [CalendarMonth] into a string with a given date format pattern.
+ *
+ * @param month a [CalendarMonth] to format
+ * @param pattern a date format pattern
+ */
+ fun format(month: CalendarMonth, pattern: String): String
+
+ /**
+ * Formats a [CalendarDate] into a string with a given date format pattern.
+ *
+ * @param date a [CalendarDate] to format
+ * @param pattern a date format pattern
+ */
+ fun format(date: CalendarDate, pattern: String): String
+
+ /**
+ * Parses a date string into a [CalendarDate].
+ *
+ * @param date a date string
+ * @param pattern the expected date pattern to be used for parsing the date string
+ * @return a [CalendarDate], or a `null` in case the parsing failed
+ */
+ fun parse(date: String, pattern: String): CalendarDate?
+}
+
+/**
+ * Represents a calendar date.
+ *
+ * @param year the date's year
+ * @param month the date's month
+ * @param dayOfMonth the date's day of month
+ * @param utcTimeMillis the date representation in _UTC_ milliseconds from the epoch
+ */
+@ExperimentalMaterial3Api
+internal data class CalendarDate(
+ val year: Int,
+ val month: Int,
+ val dayOfMonth: Int,
+ val utcTimeMillis: Long
+) : Comparable<CalendarDate> {
+ override operator fun compareTo(other: CalendarDate): Int =
+ this.utcTimeMillis.compareTo(other.utcTimeMillis)
+}
+
+/**
+ * Represents a calendar month.
+ *
+ * @param year the month's year
+ * @param month the calendar month as an integer (e.g. JANUARY as 1, December as 12)
+ * @param numberOfDays the number of days in the month
+ * @param daysFromStartOfWeekToFirstOfMonth the number of days from the start of the week to the
+ * first day of the month
+ * @param startUtcTimeMillis the first day of the month in _UTC_ milliseconds from the epoch
+ */
+@ExperimentalMaterial3Api
+internal data class CalendarMonth(
+ val year: Int,
+ val month: Int,
+ val numberOfDays: Int,
+ val daysFromStartOfWeekToFirstOfMonth: Int,
+ val startUtcTimeMillis: Long
+) {
+
+ /**
+ * The last _UTC_ milliseconds from the epoch of the month (i.e. the last millisecond of the
+ * last day of the month)
+ */
+ val endUtcTimeMillis: Long = startUtcTimeMillis + (numberOfDays * MillisecondsIn24Hours) - 1
+
+ /**
+ * Returns the position of a [CalendarMonth] within given years range.
+ */
+ internal fun indexIn(years: IntRange): Int {
+ return (year - years.first) * 12 + month - 1
+ }
+}
+
+internal const val DaysInWeek: Int = 7
+internal const val MillisecondsIn24Hours = 86400000L