From bd543709c19026830af33f94a19e147c6680518d Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Fri, 6 Mar 2026 14:22:22 +0530 Subject: [PATCH 1/6] add: calender --- .../calender/core/Extensions.android.kt | 22 + .../components/calender/data/Utils.android.kt | 7 + .../compose/components/calender/Calendar.kt | 621 ++++++++++++++++++ .../components/calender/CalendarDefaults.kt | 50 ++ .../components/calender/CalendarInfo.kt | 12 + .../components/calender/CalendarLayoutInfo.kt | 33 + .../components/calender/CalendarMonths.kt | 169 +++++ .../components/calender/CalendarState.kt | 376 +++++++++++ .../components/calender/ContentHeightMode.kt | 28 + .../components/calender/ItemPlacementInfo.kt | 58 ++ .../components/calender/core/CalendarDay.kt | 13 + .../components/calender/core/CalendarMonth.kt | 44 ++ .../components/calender/core/CalendarYear.kt | 43 ++ .../components/calender/core/DayPosition.kt | 26 + .../calender/core/ExperimentalCalendarApi.kt | 9 + .../components/calender/core/Extensions.kt | 176 +++++ .../components/calender/core/OutDateStyle.kt | 22 + .../compose/components/calender/core/Week.kt | 37 ++ .../components/calender/core/WeekDay.kt | 13 + .../calender/core/WeekDayPosition.kt | 25 + .../compose/components/calender/core/Year.kt | 177 +++++ .../components/calender/core/format/Format.kt | 33 + .../core/serializers/YearSerializers.kt | 71 ++ .../components/calender/data/DataStore.kt | 21 + .../components/calender/data/Extensions.kt | 31 + .../components/calender/data/MonthData.kt | 92 +++ .../compose/components/calender/data/Utils.kt | 9 + .../calender/data/VisibleItemState.kt | 9 + .../components/calender/data/WeekData.kt | 65 ++ .../components/calender/data/YearData.kt | 37 ++ .../heatmapcalendar/HeatMapCalendar.kt | 92 +++ .../heatmapcalendar/HeatMapCalendarState.kt | 270 ++++++++ .../calender/heatmapcalendar/HeatMapWeek.kt | 18 + .../HeatMapWeekHeaderPosition.kt | 19 + .../calender/weekcalendar/WeekCalendar.kt | 109 +++ .../weekcalendar/WeekCalendarLayoutInfo.kt | 37 ++ .../weekcalendar/WeekCalendarState.kt | 372 +++++++++++ .../yearcalendar/YearCalendarLayoutInfo.kt | 37 ++ .../yearcalendar/YearCalendarMonths.kt | 304 +++++++++ .../yearcalendar/YearCalendarState.kt | 481 ++++++++++++++ .../yearcalendar/YearContentHeightMode.kt | 37 ++ .../yearcalendar/YearItemPlacementInfo.kt | 81 +++ .../components/calender/data/Utils.desktop.kt | 11 + .../calender/core/Extensions.ios.kt | 21 + .../components/calender/data/Utils.ios.kt | 6 + .../components/calender/data/Utils.js.kt | 4 + .../components/calender/data/Utils.wasmJs.kt | 7 + 47 files changed, 4235 insertions(+) create mode 100644 sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.android.kt create mode 100644 sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.android.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/Calendar.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarDefaults.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarInfo.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarLayoutInfo.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarMonths.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarState.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ContentHeightMode.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ItemPlacementInfo.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarDay.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarMonth.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarYear.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/DayPosition.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/ExperimentalCalendarApi.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/OutDateStyle.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Week.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDay.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDayPosition.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Year.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/format/Format.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/serializers/YearSerializers.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/DataStore.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Extensions.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/MonthData.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/VisibleItemState.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/WeekData.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/YearData.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendar.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendarState.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeek.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeekHeaderPosition.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendar.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarLayoutInfo.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarState.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarLayoutInfo.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarMonths.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarState.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearContentHeightMode.kt create mode 100644 sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearItemPlacementInfo.kt create mode 100644 sushi/sushi-compose/src/desktopMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.desktop.kt create mode 100644 sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.ios.kt create mode 100644 sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.ios.kt create mode 100644 sushi/sushi-compose/src/jsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.js.kt create mode 100644 sushi/sushi-compose/src/wasmJsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.wasmJs.kt diff --git a/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.android.kt b/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.android.kt new file mode 100644 index 00000000..7c8083a6 --- /dev/null +++ b/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.android.kt @@ -0,0 +1,22 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek +import java.util.Calendar + +actual fun firstDayOfWeekFromLocale(locale: Locale): DayOfWeek { + val javaLocale = java.util.Locale(locale.language, locale.region) + + val firstDay = Calendar.getInstance(javaLocale).firstDayOfWeek + + return when (firstDay) { + Calendar.MONDAY -> DayOfWeek.MONDAY + Calendar.TUESDAY -> DayOfWeek.TUESDAY + Calendar.WEDNESDAY -> DayOfWeek.WEDNESDAY + Calendar.THURSDAY -> DayOfWeek.THURSDAY + Calendar.FRIDAY -> DayOfWeek.FRIDAY + Calendar.SATURDAY -> DayOfWeek.SATURDAY + Calendar.SUNDAY -> DayOfWeek.SUNDAY + else -> DayOfWeek.MONDAY + } +} \ No newline at end of file diff --git a/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.android.kt b/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.android.kt new file mode 100644 index 00000000..dc6a4b65 --- /dev/null +++ b/sushi/sushi-compose/src/androidMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.android.kt @@ -0,0 +1,7 @@ +package com.zomato.sushi.compose.components.calender.data + +import android.util.Log + +internal actual fun log(tag: String, message: String) { + Log.w(tag, message) +} \ No newline at end of file diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/Calendar.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/Calendar.kt new file mode 100644 index 00000000..423d1186 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/Calendar.kt @@ -0,0 +1,621 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.annotation.IntRange +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.zomato.sushi.compose.components.calender.CalendarDefaults.flingBehavior +import com.zomato.sushi.compose.components.calender.heatmapcalendar.HeatMapCalendarImpl +import com.zomato.sushi.compose.components.calender.heatmapcalendar.HeatMapCalendarState +import com.zomato.sushi.compose.components.calender.heatmapcalendar.HeatMapWeek +import com.zomato.sushi.compose.components.calender.heatmapcalendar.HeatMapWeekHeaderPosition +import com.zomato.sushi.compose.components.calender.heatmapcalendar.rememberHeatMapCalendarState +import com.zomato.sushi.compose.components.calender.weekcalendar.WeekCalendarImpl +import com.zomato.sushi.compose.components.calender.weekcalendar.WeekCalendarState +import com.zomato.sushi.compose.components.calender.weekcalendar.rememberWeekCalendarState +import com.zomato.sushi.compose.components.calender.yearcalendar.YearCalendarMonths +import com.zomato.sushi.compose.components.calender.yearcalendar.YearCalendarState +import com.zomato.sushi.compose.components.calender.yearcalendar.YearContentHeightMode +import com.zomato.sushi.compose.components.calender.yearcalendar.rememberYearCalendarState +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.CalendarYear +import com.zomato.sushi.compose.components.calender.core.Week +import com.zomato.sushi.compose.components.calender.core.WeekDay +import kotlinx.datetime.DayOfWeek + +/** + * A horizontally scrolling month calendar. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startMonth`, `endMonth`, `firstDayOfWeek`, `firstVisibleMonth`, `outDateStyle`. + * @param calendarScrollPaged the scrolling behavior of the calendar. When `true`, the calendar will + * snap to the nearest month after a scroll or swipe action. When `false`, the calendar scrolls normally. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param reverseLayout reverse the direction of scrolling and layout. When `true`, months will be + * composed from the end to the start and [CalendarState.startMonth] will be located at the end. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first month or after the last one. If you want to add a spacing + * between each month, use the [monthContainer] composable. + * @param contentHeightMode Determines how the height of the day content is calculated. + * @param dayContent a composable block which describes the day content. + * @param monthHeader a composable block which describes the month header content. The header is + * placed above each month on the calendar. + * @param monthBody a composable block which describes the month body content. This is the container + * where all the month days are placed, excluding the header and footer. This is useful if you + * want to customize the day container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param monthFooter a composable block which describes the month footer content. The footer is + * placed below each month on the calendar. + * @param monthContainer a composable block which describes the entire month content. This is the + * container where all the month contents are placed (header => days => footer). This is useful if + * you want to customize the month container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + */ +@Composable +public fun HorizontalCalendar( + modifier: Modifier = Modifier, + state: CalendarState = rememberCalendarState(), + calendarScrollPaged: Boolean = true, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + contentHeightMode: ContentHeightMode = ContentHeightMode.Wrap, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)? = null, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthContainer: (@Composable LazyItemScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)? = null, +): Unit = Calendar( + modifier = modifier, + state = state, + calendarScrollPaged = calendarScrollPaged, + userScrollEnabled = userScrollEnabled, + isHorizontal = true, + reverseLayout = reverseLayout, + contentHeightMode = contentHeightMode, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + contentPadding = contentPadding, +) + +/** + * A vertically scrolling month calendar. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startMonth`, `endMonth`, `firstDayOfWeek`, `firstVisibleMonth`, `outDateStyle`. + * @param calendarScrollPaged the scrolling behavior of the calendar. When `true`, the calendar will + * snap to the nearest month after a scroll or swipe action. When `false`, the calendar scrolls normally. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param reverseLayout reverse the direction of scrolling and layout. When `true`, months will be + * composed from the end to the start and [CalendarState.startMonth] will be located at the end. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first month or after the last one. If you want to add a spacing + * between each month, use the [monthContainer] composable. + * @param contentHeightMode Determines how the height of the day content is calculated. + * @param dayContent a composable block which describes the day content. + * @param monthHeader a composable block which describes the month header content. The header is + * placed above each month on the calendar. + * @param monthBody a composable block which describes the month body content. This is the container + * where all the month days are placed, excluding the header and footer. This is useful if you + * want to customize the day container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param monthFooter a composable block which describes the month footer content. The footer is + * placed below each month on the calendar. + * @param monthContainer a composable block which describes the entire month content. This is the + * container where all the month contents are placed (header => days => footer). This is useful if + * you want to customize the month container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + */ +@Composable +public fun VerticalCalendar( + modifier: Modifier = Modifier, + state: CalendarState = rememberCalendarState(), + calendarScrollPaged: Boolean = false, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + contentHeightMode: ContentHeightMode = ContentHeightMode.Wrap, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)? = null, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthContainer: (@Composable LazyItemScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)? = null, +): Unit = Calendar( + modifier = modifier, + state = state, + calendarScrollPaged = calendarScrollPaged, + userScrollEnabled = userScrollEnabled, + isHorizontal = false, + reverseLayout = reverseLayout, + contentHeightMode = contentHeightMode, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + contentPadding = contentPadding, +) + +@Composable +private fun Calendar( + modifier: Modifier, + state: CalendarState, + calendarScrollPaged: Boolean, + userScrollEnabled: Boolean, + isHorizontal: Boolean, + reverseLayout: Boolean, + contentPadding: PaddingValues, + contentHeightMode: ContentHeightMode, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)?, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthContainer: (@Composable LazyItemScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)?, +) { + if (isHorizontal) { + LazyRow( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + CalendarMonths( + monthCount = state.calendarInfo.indexCount, + monthData = state.store::get, + contentHeightMode = contentHeightMode, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + onItemPlaced = state.placementInfo::onItemPlaced, + ) + } + } else { + LazyColumn( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + CalendarMonths( + monthCount = state.calendarInfo.indexCount, + monthData = state.store::get, + contentHeightMode = contentHeightMode, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + onItemPlaced = state.placementInfo::onItemPlaced, + ) + } + } +} + +/** + * A horizontally scrolling week calendar. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startDate`, `endDate`, `firstDayOfWeek`, `firstVisibleWeek`. + * @param calendarScrollPaged the scrolling behavior of the calendar. When `true`, the calendar will + * snap to the nearest week after a scroll or swipe action. When `false`, the calendar scrolls normally. + * Note that when `false`, you should set the desired day width on the [dayContent] composable. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param reverseLayout reverse the direction of scrolling and layout. When `true`, weeks will be + * composed from the end to the start and [WeekCalendarState.startDate] will be located at the end. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first week or after the last one. + * @param dayContent a composable block which describes the day content. + * @param weekHeader a composable block which describes the week header content. The header is + * placed above each week on the calendar. + * @param weekFooter a composable block which describes the week footer content. The footer is + * placed below each week on the calendar. + * @param weekContainer a composable block which describes the entire week content. This is the + * container where all the week contents are placed (header => days => footer). This is useful if + * you want to customize the week container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + */ +@Composable +public fun WeekCalendar( + modifier: Modifier = Modifier, + state: WeekCalendarState = rememberWeekCalendarState(), + calendarScrollPaged: Boolean = true, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + dayContent: @Composable BoxScope.(WeekDay) -> Unit, + weekHeader: (@Composable ColumnScope.(Week) -> Unit)? = null, + weekFooter: (@Composable ColumnScope.(Week) -> Unit)? = null, + weekContainer: (@Composable LazyItemScope.(Week, container: @Composable () -> Unit) -> Unit)? = null, +): Unit = WeekCalendarImpl( + modifier = modifier, + state = state, + calendarScrollPaged = calendarScrollPaged, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + dayContent = dayContent, + weekHeader = weekHeader, + weekFooter = weekFooter, + weekContainer = weekContainer, + contentPadding = contentPadding, + onItemPlaced = state.placementInfo::onItemPlaced, +) + +/** + * A horizontal scrolling heatmap calendar, useful for showing how data changes over time. + * A popular example is the user contribution chart on GitHub. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startMonth`, `endMonth`, `firstDayOfWeek`, `firstVisibleMonth`. + * @param weekHeaderPosition Determines the position for the [weekHeader] composable. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first month or after the last one. + * @param dayContent a composable block which describes the day content. + * @param weekHeader a composable block which describes the day of week (Mon, Tue, Wed...) on the + * horizontal axis of the calendar. The position is determined by the [weekHeaderPosition] property. + * @param monthHeader a composable block which describes the month header content. The header is + * placed above each month on the calendar. + */ +@Composable +public fun HeatMapCalendar( + modifier: Modifier = Modifier, + state: HeatMapCalendarState = rememberHeatMapCalendarState(), + weekHeaderPosition: HeatMapWeekHeaderPosition = HeatMapWeekHeaderPosition.Start, + userScrollEnabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(0.dp), + dayContent: @Composable ColumnScope.(day: CalendarDay, week: HeatMapWeek) -> Unit, + weekHeader: (@Composable ColumnScope.(DayOfWeek) -> Unit)? = null, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, +): Unit = HeatMapCalendarImpl( + modifier = modifier, + state = state, + weekHeaderPosition = weekHeaderPosition, + userScrollEnabled = userScrollEnabled, + dayContent = dayContent, + weekHeader = weekHeader, + monthHeader = monthHeader, + contentPadding = contentPadding, +) + +/** + * A horizontally scrolling year calendar. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startYear`, `endYear`, `firstDayOfWeek`, `firstVisibleYear`, `outDateStyle`. + * @param monthColumns the number of month columns in each year. Must be from 1 to 12. + * @param calendarScrollPaged the scrolling behavior of the calendar. When `true`, the calendar will + * snap to the nearest year after a scroll or swipe action. When `false`, the calendar scrolls normally. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param reverseLayout reverse the direction of scrolling and layout. When `true`, years will be + * composed from the end to the start and [YearCalendarState.startYear] will be located at the end. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first year or after the last one. If you want to add a spacing + * between each year, use the [yearContainer] composable or the [yearBodyContentPadding] parameter. + * @param yearBodyContentPadding a padding around the year body content, this is the grid in which + * the months in each year are shown, excluding the year header and footer. Alternatively, you can + * provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows in each year. + * @param monthHorizontalSpacing the horizontal spacing between month columns in each year. + * @param contentHeightMode Determines how the height of the month and day content is calculated. + * @param isMonthVisible Determines if a month is shown on the calendar grid. For example, you can + * use this to hide all past months. + * @param dayContent a composable block which describes the day content. + * @param monthHeader a composable block which describes the month header content. The header is + * placed above each month on the calendar. + * @param monthBody a composable block which describes the month body content. This is the container + * where all the month days are placed, excluding the header and footer. This is useful if you + * want to customize the day container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param monthFooter a composable block which describes the month footer content. The footer is + * placed below each month on the calendar. + * @param monthContainer a composable block which describes the entire month content. This is the + * container where all the month contents are placed (header => days => footer). This is useful if + * you want to customize the month container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + * @param yearHeader a composable block which describes the year header content. The header is + * placed above each year on the calendar. + * @param yearBody a composable block which describes the year body content. This is the container + * where all the months in the year are placed, excluding the year header and footer. This is useful + * if you want to customize the month container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param yearFooter a composable block which describes the year footer content. The footer is + * placed below each year on the calendar. + * @param yearContainer a composable block which describes the entire year content. This is the + * container where all the year contents are placed (header => months => footer). This is useful if + * you want to customize the year container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + */ +@Composable +public fun HorizontalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + @IntRange(from = 1, to = 12) monthColumns: Int = 3, + calendarScrollPaged: Boolean = true, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + yearBodyContentPadding: PaddingValues = PaddingValues(0.dp), + monthVerticalSpacing: Dp = 0.dp, + monthHorizontalSpacing: Dp = 0.dp, + contentHeightMode: YearContentHeightMode = YearContentHeightMode.Wrap, + isMonthVisible: ((month: CalendarMonth) -> Boolean)? = null, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)? = null, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthContainer: (@Composable BoxScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)? = null, + yearHeader: (@Composable ColumnScope.(CalendarYear) -> Unit)? = null, + yearBody: (@Composable ColumnScope.(CalendarYear, content: @Composable () -> Unit) -> Unit)? = null, + yearFooter: (@Composable ColumnScope.(CalendarYear) -> Unit)? = null, + yearContainer: (@Composable LazyItemScope.(CalendarYear, container: @Composable () -> Unit) -> Unit)? = null, +): Unit = YearCalendar( + modifier = modifier, + state = state, + monthColumns = monthColumns, + calendarScrollPaged = calendarScrollPaged, + userScrollEnabled = userScrollEnabled, + isHorizontal = true, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + yearBodyContentPadding = yearBodyContentPadding, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + contentHeightMode = contentHeightMode, + isMonthVisible = isMonthVisible, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + yearHeader = yearHeader, + yearBody = yearBody, + yearFooter = yearFooter, + yearContainer = yearContainer, +) + +/** + * A vertically scrolling year calendar. + * + * @param modifier the modifier to apply to this calendar. + * @param state the state object to be used to control or observe the calendar's properties. + * Examples: `startYear`, `endYear`, `firstDayOfWeek`, `firstVisibleYear`, `outDateStyle`. + * @param monthColumns the number of month columns in each year. Must be from 1 to 12. + * @param calendarScrollPaged the scrolling behavior of the calendar. When `true`, the calendar will + * snap to the nearest year after a scroll or swipe action. When `false`, the calendar scrolls normally. + * @param userScrollEnabled whether the scrolling via the user gestures or accessibility actions + * is allowed. You can still scroll programmatically using the state even when it is disabled. + * @param reverseLayout reverse the direction of scrolling and layout. When `true`, years will be + * composed from the end to the start and [YearCalendarState.startYear] will be located at the end. + * @param contentPadding a padding around the whole calendar. This will add padding for the + * content after it has been clipped, which is not possible via [modifier] param. You can use it + * to add a padding before the first year or after the last one. If you want to add a spacing + * between each year, use the [yearContainer] composable or the [yearBodyContentPadding] parameter. + * @param yearBodyContentPadding a padding around the year body content, this is the grid in which + * the months in each year are shown, excluding the year header and footer. Alternatively, you can + * provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows in each year. + * @param monthHorizontalSpacing the horizontal spacing between month columns in each year. + * @param contentHeightMode Determines how the height of the month and day content is calculated. + * @param isMonthVisible Determines if a month is shown on the calendar grid. For example, you can + * use this to hide all past months. + * @param dayContent a composable block which describes the day content. + * @param monthHeader a composable block which describes the month header content. The header is + * placed above each month on the calendar. + * @param monthBody a composable block which describes the month body content. This is the container + * where all the month days are placed, excluding the header and footer. This is useful if you + * want to customize the day container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param monthFooter a composable block which describes the month footer content. The footer is + * placed below each month on the calendar. + * @param monthContainer a composable block which describes the entire month content. This is the + * container where all the month contents are placed (header => days => footer). This is useful if + * you want to customize the month container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + * @param yearHeader a composable block which describes the year header content. The header is + * placed above each year on the calendar. + * @param yearBody a composable block which describes the year body content. This is the container + * where all the months in the year are placed, excluding the year header and footer. This is useful + * if you want to customize the month container, for example, with a background color or other effects. + * The actual body content is provided in the block and must be called after your desired + * customisations are rendered. + * @param yearFooter a composable block which describes the year footer content. The footer is + * placed below each year on the calendar. + * @param yearContainer a composable block which describes the entire year content. This is the + * container where all the year contents are placed (header => months => footer). This is useful if + * you want to customize the year container, for example, with a background color or other effects. + * The actual container content is provided in the block and must be called after your desired + * customisations are rendered. + */ +@Composable +public fun VerticalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + @IntRange(from = 1, to = 12) monthColumns: Int = 3, + calendarScrollPaged: Boolean = true, + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + yearBodyContentPadding: PaddingValues = PaddingValues(0.dp), + monthVerticalSpacing: Dp = 0.dp, + monthHorizontalSpacing: Dp = 0.dp, + contentHeightMode: YearContentHeightMode = YearContentHeightMode.Wrap, + isMonthVisible: ((month: CalendarMonth) -> Boolean)? = null, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)? = null, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, + monthContainer: (@Composable BoxScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)? = null, + yearHeader: (@Composable ColumnScope.(CalendarYear) -> Unit)? = null, + yearBody: (@Composable ColumnScope.(CalendarYear, content: @Composable () -> Unit) -> Unit)? = null, + yearFooter: (@Composable ColumnScope.(CalendarYear) -> Unit)? = null, + yearContainer: (@Composable LazyItemScope.(CalendarYear, container: @Composable () -> Unit) -> Unit)? = null, +): Unit = YearCalendar( + modifier = modifier, + state = state, + monthColumns = monthColumns, + calendarScrollPaged = calendarScrollPaged, + userScrollEnabled = userScrollEnabled, + isHorizontal = false, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + yearBodyContentPadding = yearBodyContentPadding, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + contentHeightMode = contentHeightMode, + isMonthVisible = isMonthVisible, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + yearHeader = yearHeader, + yearBody = yearBody, + yearFooter = yearFooter, + yearContainer = yearContainer, +) + +@Composable +private fun YearCalendar( + modifier: Modifier, + state: YearCalendarState, + monthColumns: Int, + calendarScrollPaged: Boolean, + userScrollEnabled: Boolean, + isHorizontal: Boolean, + reverseLayout: Boolean, + contentPadding: PaddingValues, + contentHeightMode: YearContentHeightMode, + monthVerticalSpacing: Dp, + monthHorizontalSpacing: Dp, + yearBodyContentPadding: PaddingValues, + isMonthVisible: ((month: CalendarMonth) -> Boolean)?, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)?, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthContainer: (@Composable BoxScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)?, + yearHeader: (@Composable ColumnScope.(CalendarYear) -> Unit)?, + yearBody: (@Composable ColumnScope.(CalendarYear, content: @Composable () -> Unit) -> Unit)?, + yearFooter: (@Composable ColumnScope.(CalendarYear) -> Unit)?, + yearContainer: (@Composable LazyItemScope.(CalendarYear, container: @Composable () -> Unit) -> Unit)?, +) { + require(monthColumns in 1..12) { "Param `monthColumns` must be 1..12" } + state.placementInfo.isMonthVisible = isMonthVisible + state.placementInfo.monthColumns = monthColumns + state.placementInfo.contentHeightMode = contentHeightMode + val density = LocalDensity.current + // Intentionally not creating a coroutine scope with LaunchedEffect + DisposableEffect(monthVerticalSpacing, monthHorizontalSpacing) { + with(density) { + state.placementInfo.monthVerticalSpacingPx = monthVerticalSpacing.roundToPx() + state.placementInfo.monthHorizontalSpacingPx = monthHorizontalSpacing.roundToPx() + } + onDispose {} + } + if (isHorizontal) { + LazyRow( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + YearCalendarMonths( + yearCount = state.calendarInfo.indexCount, + yearData = state.store::get, + monthColumns = monthColumns, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + yearBodyContentPadding = yearBodyContentPadding, + contentHeightMode = contentHeightMode, + isMonthVisible = isMonthVisible, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + yearHeader = yearHeader, + yearBody = yearBody, + yearFooter = yearFooter, + yearContainer = yearContainer, + onItemPlaced = state.placementInfo::onItemPlaced, + ) + } + } else { + LazyColumn( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + YearCalendarMonths( + yearCount = state.calendarInfo.indexCount, + yearData = state.store::get, + monthColumns = monthColumns, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + yearBodyContentPadding = yearBodyContentPadding, + contentHeightMode = contentHeightMode, + isMonthVisible = isMonthVisible, + dayContent = dayContent, + monthHeader = monthHeader, + monthBody = monthBody, + monthFooter = monthFooter, + monthContainer = monthContainer, + yearHeader = yearHeader, + yearBody = yearBody, + yearFooter = yearFooter, + yearContainer = yearContainer, + onItemPlaced = state.placementInfo::onItemPlaced, + ) + } + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarDefaults.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarDefaults.kt new file mode 100644 index 00000000..dc65cfe1 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarDefaults.kt @@ -0,0 +1,50 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +internal object CalendarDefaults { + /** + * The default implementation in [rememberSnapFlingBehavior] snaps to the center of the layout + * but we want to snap to the start. For example, in a vertical calendar, when the layout size + * is larger than the item size(e.g two or more visible months), we don't want the item's + * center to be at the center of the layout when it snaps, instead we want the item's top + * to be at the top of the layout. + */ + @OptIn(ExperimentalFoundationApi::class) + @Composable + private fun pagedFlingBehavior(state: LazyListState): FlingBehavior { + val snappingLayout = remember(state) { + val provider = SnapLayoutInfoProvider(state, SnapPosition.Start) + CalendarSnapLayoutInfoProvider(provider) + } + return rememberSnapFlingBehavior(snappingLayout) + } + + @Composable + private fun continuousFlingBehavior(): FlingBehavior = ScrollableDefaults.flingBehavior() + + @Composable + fun flingBehavior(isPaged: Boolean, state: LazyListState): FlingBehavior { + return if (isPaged) pagedFlingBehavior(state) else continuousFlingBehavior() + } +} + +@ExperimentalFoundationApi +@Suppress("FunctionName") +private fun CalendarSnapLayoutInfoProvider( + snapLayoutInfoProvider: SnapLayoutInfoProvider, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider by snapLayoutInfoProvider { + /** + * In compose 1.3, the default was single page snapping (zero), but this changed + * in compose 1.4 to decayed page snapping which is not great for calendar usage. + */ + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarInfo.kt new file mode 100644 index 00000000..fe567bef --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarInfo.kt @@ -0,0 +1,12 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.runtime.Immutable +import com.zomato.sushi.compose.components.calender.core.OutDateStyle +import kotlinx.datetime.DayOfWeek + +@Immutable +internal data class CalendarInfo( + val indexCount: Int, + private val firstDayOfWeek: DayOfWeek? = null, + private val outDateStyle: OutDateStyle? = null, +) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarLayoutInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarLayoutInfo.kt new file mode 100644 index 00000000..dfc2de74 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarLayoutInfo.kt @@ -0,0 +1,33 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.zomato.sushi.compose.components.calender.core.CalendarMonth + +/** + * Contains useful information about the currently displayed layout state of the calendar. + * For example you can get the list of currently displayed months. + * + * Use [CalendarState.layoutInfo] to retrieve this. + * @see LazyListLayoutInfo + */ +public class CalendarLayoutInfo(info: LazyListLayoutInfo, private val month: (Int) -> CalendarMonth) : + LazyListLayoutInfo by info { + /** + * The list of [CalendarItemInfo] representing all the currently visible months. + */ + public val visibleMonthsInfo: List + get() = visibleItemsInfo.map { + CalendarItemInfo(it, month(it.index)) + } +} + +/** + * Contains useful information about an individual [CalendarMonth] on the calendar. + * + * @param month The month in the list. + * + * @see CalendarLayoutInfo + * @see LazyListItemInfo + */ +public class CalendarItemInfo(info: LazyListItemInfo, public val month: CalendarMonth) : LazyListItemInfo by info diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarMonths.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarMonths.kt new file mode 100644 index 00000000..1481b0cc --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarMonths.kt @@ -0,0 +1,169 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String + +@Suppress("FunctionName") +internal fun LazyListScope.CalendarMonths( + monthCount: Int, + monthData: (offset: Int) -> CalendarMonth, + contentHeightMode: ContentHeightMode, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)?, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthContainer: (@Composable LazyItemScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)?, + onItemPlaced: (itemCoordinates: ItemCoordinates) -> Unit, +) { + items( + count = monthCount, + key = { offset -> monthData(offset).yearMonth.toIso8601String() }, + ) { offset -> + val month = monthData(offset) + val fillHeight = when (contentHeightMode) { + ContentHeightMode.Wrap -> false + ContentHeightMode.Fill -> true + } + val hasMonthContainer = monthContainer != null + val currentOnItemPlaced by rememberUpdatedState(onItemPlaced) + val itemCoordinatesStore = remember(month.yearMonth) { + ItemCoordinatesStore(currentOnItemPlaced) + } + Box(Modifier.onPlaced(itemCoordinatesStore::onItemRootPlaced)) { + monthContainer.or(defaultMonthContainer)(month) { + Column( + modifier = Modifier + .then( + if (hasMonthContainer) { + Modifier.fillMaxWidth() + } else { + Modifier.fillParentMaxWidth() + }, + ) + .then( + if (fillHeight) { + if (hasMonthContainer) { + Modifier.fillMaxHeight() + } else { + Modifier.fillParentMaxHeight() + } + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + monthHeader?.invoke(this, month) + monthBody.or(defaultMonthBody)(month) { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (fillHeight) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for ((row, week) in month.weekDays.withIndex()) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (fillHeight) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for ((column, day) in week.withIndex()) { + Box( + modifier = Modifier + .weight(1f) + .clipToBounds() + .onFirstDayPlaced( + dateRow = row, + dateColumn = column, + onFirstDayPlaced = itemCoordinatesStore::onFirstDayPlaced, + ), + ) { + dayContent(day) + } + } + } + } + } + } + monthFooter?.invoke(this, month) + } + } + } + } +} + +@Stable +internal class ItemCoordinatesStore( + private val onItemPlaced: (itemCoordinates: ItemCoordinates) -> Unit, +) { + private var itemRootCoordinates: LayoutCoordinates? = null + private var firstDayCoordinates: LayoutCoordinates? = null + + fun onItemRootPlaced(coordinates: LayoutCoordinates) { + itemRootCoordinates = coordinates + check() + } + + fun onFirstDayPlaced(coordinates: LayoutCoordinates) { + firstDayCoordinates = coordinates + check() + } + + private fun check() { + val itemRootCoordinates = itemRootCoordinates ?: return + val firstDayCoordinates = firstDayCoordinates ?: return + val itemCoordinates = ItemCoordinates( + itemRootCoordinates = itemRootCoordinates, + firstDayCoordinates = firstDayCoordinates, + ) + onItemPlaced(itemCoordinates) + } +} + +private inline fun Modifier.onFirstDayPlaced( + dateRow: Int, + dateColumn: Int, + noinline onFirstDayPlaced: (coordinates: LayoutCoordinates) -> Unit, +) = if (dateRow == 0 && dateColumn == 0) { + onPlaced(onFirstDayPlaced) +} else { + this +} + +private val defaultMonthContainer: (@Composable LazyItemScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit) = + { _, container -> container() } + +private val defaultMonthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit) = + { _, content -> content() } + +internal inline fun T?.or(default: T) = this ?: default diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarState.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarState.kt new file mode 100644 index 00000000..8b283779 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/CalendarState.kt @@ -0,0 +1,376 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.DayPosition +import com.zomato.sushi.compose.components.calender.core.OutDateStyle +import com.zomato.sushi.compose.components.calender.core.firstDayOfWeekFromLocale +import com.zomato.sushi.compose.components.calender.core.format.fromIso8601YearMonth +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import com.zomato.sushi.compose.components.calender.core.now +import com.zomato.sushi.compose.components.calender.data.DataStore +import com.zomato.sushi.compose.components.calender.data.VisibleItemState +import com.zomato.sushi.compose.components.calender.data.checkRange +import com.zomato.sushi.compose.components.calender.data.daysUntil +import com.zomato.sushi.compose.components.calender.data.getCalendarMonthData +import com.zomato.sushi.compose.components.calender.data.getMonthIndex +import com.zomato.sushi.compose.components.calender.data.getMonthIndicesCount +import com.zomato.sushi.compose.components.calender.data.indexOfFirstOrNull +import com.zomato.sushi.compose.components.calender.data.log +import com.zomato.sushi.compose.components.calender.data.positionYearMonth +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlin.time.ExperimentalTime + +/** + * Creates a [CalendarState] that is remembered across compositions. + * + * @param startMonth the initial value for [CalendarState.startMonth] + * @param endMonth the initial value for [CalendarState.endMonth] + * @param firstDayOfWeek the initial value for [CalendarState.firstDayOfWeek] + * @param firstVisibleMonth the initial value for [CalendarState.firstVisibleMonth] + * @param outDateStyle the initial value for [CalendarState.outDateStyle] + */ +@OptIn(ExperimentalTime::class) +@Composable +public fun rememberCalendarState( + startMonth: YearMonth = YearMonth.now(), + endMonth: YearMonth = startMonth, + firstVisibleMonth: YearMonth = startMonth, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + outDateStyle: OutDateStyle = OutDateStyle.EndOfRow, +): CalendarState { + return rememberSaveable( + inputs = arrayOf( + startMonth, + endMonth, + firstVisibleMonth, + firstDayOfWeek, + outDateStyle, + ), + saver = CalendarState.Saver, + ) { + CalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstDayOfWeek = firstDayOfWeek, + firstVisibleMonth = firstVisibleMonth, + outDateStyle = outDateStyle, + visibleItemState = null, + ) + } +} + +/** + * A state object that can be hoisted to control and observe calendar properties. + * + * This should be created via [rememberCalendarState]. + * + * @param startMonth the first month on the calendar. + * @param endMonth the last month on the calendar. + * @param firstDayOfWeek the first day of week on the calendar. + * @param firstVisibleMonth the initial value for [CalendarState.firstVisibleMonth] + * @param outDateStyle the preferred style for out date generation. + */ +@Stable +public class CalendarState internal constructor( + startMonth: YearMonth, + endMonth: YearMonth, + firstDayOfWeek: DayOfWeek, + firstVisibleMonth: YearMonth, + outDateStyle: OutDateStyle, + visibleItemState: VisibleItemState?, +) : ScrollableState { + /** Backing state for [startMonth] */ + private var _startMonth by mutableStateOf(startMonth) + + /** The first month on the calendar. */ + public var startMonth: YearMonth + get() = _startMonth + set(value) { + if (value != startMonth) { + _startMonth = value + monthDataChanged() + } + } + + /** Backing state for [endMonth] */ + private var _endMonth by mutableStateOf(endMonth) + + /** The last month on the calendar. */ + public var endMonth: YearMonth + get() = _endMonth + set(value) { + if (value != endMonth) { + _endMonth = value + monthDataChanged() + } + } + + /** Backing state for [firstDayOfWeek] */ + private var _firstDayOfWeek by mutableStateOf(firstDayOfWeek) + + /** The first day of week on the calendar. */ + public var firstDayOfWeek: DayOfWeek + get() = _firstDayOfWeek + set(value) { + if (value != firstDayOfWeek) { + _firstDayOfWeek = value + monthDataChanged() + } + } + + /** Backing state for [outDateStyle] */ + private var _outDateStyle by mutableStateOf(outDateStyle) + + /** The preferred style for out date generation. */ + public var outDateStyle: OutDateStyle + get() = _outDateStyle + set(value) { + if (value != outDateStyle) { + _outDateStyle = value + monthDataChanged() + } + } + + /** + * The first month that is visible. + * + * @see [lastVisibleMonth] + */ + public val firstVisibleMonth: com.zomato.sushi.compose.components.calender.core.CalendarMonth by derivedStateOf { + store[listState.firstVisibleItemIndex] + } + + /** + * The last month that is visible. + * + * @see [firstVisibleMonth] + */ + public val lastVisibleMonth: com.zomato.sushi.compose.components.calender.core.CalendarMonth by derivedStateOf { + store[listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0] + } + + /** + * The object of [CalendarLayoutInfo] calculated during the last layout pass. For example, + * you can use it to calculate what items are currently visible. + * + * Note that this property is observable and is updated after every scroll or remeasure. + * If you use it in the composable function it will be recomposed on every change causing + * potential performance issues including infinity recomposition loop. + * Therefore, avoid using it in the composition. + * + * If you need to use it in the composition then consider wrapping the calculation into a + * derived state in order to only have recompositions when the derived value changes. + * See Example6Page in the sample app for usage. + * + * If you want to run some side effects like sending an analytics event or updating a state + * based on this value consider using "snapshotFlow". + * + * see [LazyListLayoutInfo] + */ + public val layoutInfo: CalendarLayoutInfo + get() = CalendarLayoutInfo(listState.layoutInfo) { index -> store[index] } + + /** + * [InteractionSource] that will be used to dispatch drag events when this + * calendar is being dragged. If you want to know whether the fling (or animated scroll) is in + * progress, use [isScrollInProgress]. + */ + public val interactionSource: InteractionSource + get() = listState.interactionSource + + internal val listState = LazyListState( + firstVisibleItemIndex = visibleItemState?.firstVisibleItemIndex + ?: getScrollIndex(firstVisibleMonth) ?: 0, + firstVisibleItemScrollOffset = visibleItemState?.firstVisibleItemScrollOffset ?: 0, + ) + + internal val placementInfo = ItemPlacementInfo() + + internal var calendarInfo by mutableStateOf(CalendarInfo(indexCount = 0)) + + internal val store = DataStore { offset -> + getCalendarMonthData( + startMonth = this.startMonth, + offset = offset, + firstDayOfWeek = this.firstDayOfWeek, + outDateStyle = this.outDateStyle, + ).calendarMonth + } + + init { + monthDataChanged() // Update indexCount initially. + } + + private fun monthDataChanged() { + store.clear() + checkRange(startMonth, endMonth) + // Read the firstDayOfWeek and outDateStyle properties to ensure recomposition + // even though they are unused in the CalendarInfo. Alternatively, we could use + // mutableStateMapOf() as the backing store for DataStore() to ensure recomposition + // but not sure how compose handles recomposition of a lazy list that reads from + // such map when an entry unrelated to the visible indices changes. + calendarInfo = CalendarInfo( + indexCount = getMonthIndicesCount(startMonth, endMonth), + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + } + + /** + * Instantly brings the [month] to the top of the viewport. + * + * @param month the month to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * + * @see [animateScrollToMonth] + */ + public suspend fun scrollToMonth(month: YearMonth) { + listState.scrollToItem(getScrollIndex(month) ?: return) + } + + /** + * Animate (smooth scroll) to the given [month]. + * + * @param month the month to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * + * @see [scrollToMonth] + */ + public suspend fun animateScrollToMonth(month: YearMonth) { + listState.animateScrollToItem(getScrollIndex(month) ?: return) + } + + /** + * Instantly brings the [date] to the top of the viewport. + * + * @param date the date to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * @param position the position of the date in the month. + * + * @see [animateScrollToDate] + */ + public suspend fun scrollToDate( + date: LocalDate, + position: DayPosition = DayPosition.MonthDate, + ): Unit = scrollToDay(CalendarDay(date, position)) + + /** + * Animate (smooth scroll) to the given [date]. + * + * @param date the date to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * @param position the position of the date in the month. + * + * @see [scrollToDate] + */ + public suspend fun animateScrollToDate( + date: LocalDate, + position: DayPosition = DayPosition.MonthDate, + ): Unit = animateScrollToDay(CalendarDay(date, position)) + + /** + * Instantly brings the [day] to the top of the viewport. + * + * @param day the day to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * + * @see [animateScrollToDay] + */ + public suspend fun scrollToDay(day: CalendarDay): Unit = + scrollToDay(day, animate = false) + + /** + * Animate (smooth scroll) to the given [day]. + * + * @param day the day to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * + * @see [scrollToDay] + */ + public suspend fun animateScrollToDay(day: CalendarDay): Unit = + scrollToDay(day, animate = true) + + private suspend fun scrollToDay(day: CalendarDay, animate: Boolean) { + val monthIndex = getScrollIndex(day.positionYearMonth) ?: return + val weeksOfMonth = store[monthIndex].weekDays + val dayIndex = when (layoutInfo.orientation) { + Orientation.Vertical -> weeksOfMonth.indexOfFirstOrNull { it.contains(day) } + Orientation.Horizontal -> firstDayOfWeek.daysUntil(day.date.dayOfWeek) + } ?: return + val dayInfo = placementInfo.awaitFistDayOffsetAndSize(layoutInfo.orientation) ?: return + val scrollOffset = dayInfo.offset + dayInfo.size * dayIndex + if (animate) { + listState.animateScrollToItem(monthIndex, scrollOffset) + } else { + listState.scrollToItem(monthIndex, scrollOffset) + } + } + + private fun getScrollIndex(month: YearMonth): Int? { + if (month !in startMonth..endMonth) { + log("CalendarState", "Attempting to scroll out of range: $month") + return null + } + return getMonthIndex(startMonth, month) + } + + /** + * Whether this [ScrollableState] is currently scrolling by gesture, fling or programmatically. + */ + override val isScrollInProgress: Boolean + get() = listState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = listState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ): Unit = listState.scroll(scrollPriority, block) + + public companion object { + internal val Saver: Saver = listSaver( + save = { + listOf( + it.startMonth.toIso8601String(), + it.endMonth.toIso8601String(), + it.firstVisibleMonth.yearMonth.toIso8601String(), + it.firstDayOfWeek, + it.outDateStyle, + it.listState.firstVisibleItemIndex, + it.listState.firstVisibleItemScrollOffset, + ) + }, + restore = { + CalendarState( + startMonth = (it[0] as String).fromIso8601YearMonth(), + endMonth = (it[1] as String).fromIso8601YearMonth(), + firstVisibleMonth = (it[2] as String).fromIso8601YearMonth(), + firstDayOfWeek = it[3] as DayOfWeek, + outDateStyle = it[4] as OutDateStyle, + visibleItemState = VisibleItemState( + firstVisibleItemIndex = it[5] as Int, + firstVisibleItemScrollOffset = it[6] as Int, + ), + ) + }, + ) + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ContentHeightMode.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ContentHeightMode.kt new file mode 100644 index 00000000..627cc940 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ContentHeightMode.kt @@ -0,0 +1,28 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier + +/** + * Determines how the height of the day content is calculated. + */ +public enum class ContentHeightMode { + /** + * The day container will wrap its height. This allows you to + * use [Modifier.aspectRatio] if you want square day content + * or [Modifier.height] if you want a specific height value + * for the day content. + */ + Wrap, + + /** + * The days in each month will spread to fill the parent's height after + * any available header and footer content height has been accounted for. + * This allows you to use [Modifier.fillMaxHeight] for the day content + * height. With this option, your Calendar composable should also + * be created with [Modifier.fillMaxHeight] or [Modifier.height]. + */ + Fill, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ItemPlacementInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ItemPlacementInfo.kt new file mode 100644 index 00000000..82e91963 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/ItemPlacementInfo.kt @@ -0,0 +1,58 @@ +package com.zomato.sushi.compose.components.calender + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.round +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive + +@Immutable +internal data class ItemCoordinates( + val itemRootCoordinates: LayoutCoordinates, + val firstDayCoordinates: LayoutCoordinates, +) + +@Stable +internal class ItemPlacementInfo { + private var itemCoordinates: ItemCoordinates? = null + + fun onItemPlaced(itemCoordinates: ItemCoordinates) { + this.itemCoordinates = itemCoordinates + } + + suspend fun awaitFistDayOffsetAndSize(orientation: Orientation): OffsetSize? { + var itemCoordinates = this.itemCoordinates + while (currentCoroutineContext().isActive && itemCoordinates == null) { + withFrameNanos {} + itemCoordinates = this.itemCoordinates + } + if (itemCoordinates == null) { + return null + } + val (itemRootCoordinates, firstDayCoordinates) = itemCoordinates + val daySize = firstDayCoordinates.size + val dayOffset = itemRootCoordinates.localPositionOf(firstDayCoordinates).round() + return when (orientation) { + Orientation.Vertical -> OffsetSize( + offset = dayOffset.y, + size = daySize.height, + ) + + Orientation.Horizontal -> { + OffsetSize( + offset = dayOffset.x, + size = daySize.width, + ) + } + } + } + + @Immutable + internal data class OffsetSize( + val offset: Int, + val size: Int, + ) +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarDay.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarDay.kt new file mode 100644 index 00000000..7c649575 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarDay.kt @@ -0,0 +1,13 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable +import kotlinx.datetime.LocalDate + +/** + * Represents a day on the calendar. + * + * @param date the date for this day. + * @param position the [DayPosition] for this day. + */ +@Immutable +public data class CalendarDay(val date: LocalDate, val position: DayPosition) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarMonth.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarMonth.kt new file mode 100644 index 00000000..bc340b74 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarMonth.kt @@ -0,0 +1,44 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable +import kotlinx.datetime.YearMonth + +/** + * Represents a month on the calendar. + * + * @param yearMonth the calendar month value. + * @param weekDays the weeks in this month. + */ +@Immutable +@ConsistentCopyVisibility +public data class CalendarMonth internal constructor( + val yearMonth: YearMonth, + val weekDays: List>, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CalendarMonth + + if (yearMonth != other.yearMonth) return false + if (weekDays.first().first() != other.weekDays.first().first()) return false + if (weekDays.last().last() != other.weekDays.last().last()) return false + + return true + } + + override fun hashCode(): Int { + var result = yearMonth.hashCode() + result = 31 * result + weekDays.first().first().hashCode() + result = 31 * result + weekDays.last().last().hashCode() + return result + } + + override fun toString(): String { + return "CalendarMonth { " + + "first = ${weekDays.first().first()}, " + + "last = ${weekDays.last().last()} " + + "} " + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarYear.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarYear.kt new file mode 100644 index 00000000..0a385a0a --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/CalendarYear.kt @@ -0,0 +1,43 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable + +/** + * Represents a year on the calendar. + * + * @param year the calendar year value. + * @param months the months in this year. + */ +@Immutable +public data class CalendarYear( + val year: Year, + val months: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as CalendarYear + + if (year != other.year) return false + if (months.first() != other.months.first()) return false + if (months.last() != other.months.last()) return false + + return true + } + + override fun hashCode(): Int { + var result = year.hashCode() + result = 31 * result + months.first().hashCode() + result = 31 * result + months.last().hashCode() + return result + } + + override fun toString(): String { + return "CalendarYear { " + + "year = $year, " + + "firstMonth = ${months.first()}, " + + "lastMonth = ${months.last()} " + + "} " + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/DayPosition.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/DayPosition.kt new file mode 100644 index 00000000..8ea4b78c --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/DayPosition.kt @@ -0,0 +1,26 @@ +package com.zomato.sushi.compose.components.calender.core + +/** + * Describes the position of a [CalendarDay] in the month. + */ +public enum class DayPosition { + /** + * The day is positioned at the start of the month to + * ensure proper alignment of the first day of the week. + * The day belongs to the previous month on the calendar. + */ + InDate, + + /** + * The day belongs to the current month on the calendar. + */ + MonthDate, + + /** + * The day is positioned at the end of the month to + * to fill the remaining days after the days in the month. + * The day belongs to the next month on the calendar. + * @see [OutDateStyle] + */ + OutDate, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/ExperimentalCalendarApi.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/ExperimentalCalendarApi.kt new file mode 100644 index 00000000..1b83a9d0 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/ExperimentalCalendarApi.kt @@ -0,0 +1,9 @@ +package com.zomato.sushi.compose.components.calender.core + +@RequiresOptIn( + message = "This calendar API is experimental and is " + + "likely to change or to be removed in the future.", + level = RequiresOptIn.Level.ERROR, +) +@Retention(AnnotationRetention.BINARY) +public annotation class ExperimentalCalendarApi diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.kt new file mode 100644 index 00000000..0528c251 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.kt @@ -0,0 +1,176 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DateTimeArithmeticException +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.todayIn +import kotlinx.datetime.until +import kotlinx.datetime.yearMonth +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Returns the days of week values such that the desired + * [firstDayOfWeek] property is at the start position. + * + * @see [firstDayOfWeekFromLocale] + */ +public fun daysOfWeek(firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale()): List { + val pivot = 7 - firstDayOfWeek.ordinal + val daysOfWeek = DayOfWeek.entries + // Order `daysOfWeek` array so that firstDayOfWeek is at the start position. + return daysOfWeek.takeLast(pivot) + daysOfWeek.dropLast(pivot) +} + +/** + * Returns the first day of the week from the provided locale. + */ +public expect fun firstDayOfWeekFromLocale(locale: Locale = Locale.current): DayOfWeek + +/** + * Obtains the current [LocalDate] from the specified [clock] and [timeZone]. + * + * Using this method allows the use of an alternate clock or timezone for testing. + */ +@OptIn(ExperimentalTime::class) +public fun LocalDate.Companion.now( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), +): LocalDate = clock.todayIn(timeZone) + +/** + * Obtains the current [YearMonth] from the specified [clock] and [timeZone]. + * + * Using this method allows the use of an alternate clock or timezone for testing. + */ +@OptIn(ExperimentalTime::class) +public fun YearMonth.Companion.now( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), +): YearMonth = clock.todayIn(timeZone).yearMonth + +/** + * Returns a [LocalDate] that results from adding the [value] number of + * days to this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.plusDays(value: Int): LocalDate = plus(value, DateTimeUnit.DAY) + +/** + * Returns a [LocalDate] that results from subtracting the [value] number of + * days from this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.minusDays(value: Int): LocalDate = minus(value, DateTimeUnit.DAY) + +/** + * Returns a [LocalDate] that results from adding the [value] number of + * months to this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.plusMonths(value: Int): LocalDate = plus(value, DateTimeUnit.MONTH) + +/** + * Returns a [LocalDate] that results from subtracting the [value] number of + * months from this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.minusMonths(value: Int): LocalDate = minus(value, DateTimeUnit.MONTH) + +/** + * Returns a [LocalDate] that results from adding the [value] number of + * years to this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.plusYears(value: Int): LocalDate = plus(value, DateTimeUnit.YEAR) + +/** + * Returns a [LocalDate] that results from subtracting the [value] number of + * years from this date. + * + * If the [value] is positive, the returned date is later than this date. + * If the [value] is negative, the returned date is earlier than this date. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries of [LocalDate]. + */ +public fun LocalDate.minusYears(value: Int): LocalDate = minus(value, DateTimeUnit.YEAR) + +/** + * Returns a [YearMonth] that results from adding the [value] number of months + * to this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries + * of [YearMonth] which is essentially the [LocalDate] boundaries. + */ +public fun YearMonth.plusMonths(value: Int): YearMonth = plus(value, DateTimeUnit.MONTH) + +/** + * Returns a [YearMonth] that results from subtracting the [value] number of months + * from this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries + * of [YearMonth] which is essentially the [LocalDate] boundaries. + */ +public fun YearMonth.minusMonths(value: Int): YearMonth = minus(value, DateTimeUnit.MONTH) + +/** + * Returns a [YearMonth] that results from adding the [value] number of years + * to this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries + * of [YearMonth] which is essentially the [LocalDate] boundaries. + */ +public fun YearMonth.plusYears(value: Int): YearMonth = plus(value, DateTimeUnit.YEAR) + +/** + * Returns a [YearMonth] that results from subtracting the [value] number of years + * from this year-month. + * + * If the [value] is positive, the returned year-month is later than this year-month. + * If the [value] is negative, the returned year-month is earlier than this year-month. + * + * @throws DateTimeArithmeticException if the result exceeds the boundaries + * of [YearMonth] which is essentially the [LocalDate] boundaries. + */ +public fun YearMonth.minusYears(value: Int): YearMonth = minus(value, DateTimeUnit.YEAR) + +internal fun LocalDate.plusWeeks(value: Int): LocalDate = plus(value, DateTimeUnit.WEEK) + +internal fun LocalDate.minusWeeks(value: Int): LocalDate = minus(value, DateTimeUnit.WEEK) + +internal fun LocalDate.weeksUntil(other: LocalDate): Int = until(other, DateTimeUnit.WEEK).toInt() diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/OutDateStyle.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/OutDateStyle.kt new file mode 100644 index 00000000..49daaef9 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/OutDateStyle.kt @@ -0,0 +1,22 @@ +package com.zomato.sushi.compose.components.calender.core + +/** + * Determines how [DayPosition.OutDate] are + * generated for each month on the calendar. + */ +public enum class OutDateStyle { + /** + * The calendar will generate outDates until it reaches + * the end of the month row. This means that if a month + * has 5 rows, it will display 5 rows and if a month + * has 6 rows, it will display 6 rows. + */ + EndOfRow, + + /** + * The calendar will generate outDates until it + * reaches the end of a 6 x 7 grid on each month. + * This means that all months will have 6 rows. + */ + EndOfGrid, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Week.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Week.kt new file mode 100644 index 00000000..e6f433db --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Week.kt @@ -0,0 +1,37 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable + +/** + * Represents a week on the week-based calendar. + * + * @param days the days in this week. + */ +@Immutable +@ConsistentCopyVisibility +public data class Week internal constructor(val days: List) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Week + + if (days.first() != other.days.first()) return false + if (days.last() != other.days.last()) return false + + return true + } + + override fun hashCode(): Int { + var result = days.first().hashCode() + result = 31 * result + days.last().hashCode() + return result + } + + override fun toString(): String { + return "Week { " + + "first = ${days.first()}, " + + "last = ${days.last()} " + + "} " + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDay.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDay.kt new file mode 100644 index 00000000..d2db98d3 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDay.kt @@ -0,0 +1,13 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable +import kotlinx.datetime.LocalDate + +/** + * Represents a day on the week calendar. + * + * @param date the date for this day. + * @param position the [WeekDayPosition] for this day. + */ +@Immutable +public data class WeekDay(val date: LocalDate, val position: WeekDayPosition) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDayPosition.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDayPosition.kt new file mode 100644 index 00000000..49b20c05 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/WeekDayPosition.kt @@ -0,0 +1,25 @@ +package com.zomato.sushi.compose.components.calender.core + +/** + * Describes the position of a [WeekDay] on the calendar. + */ +public enum class WeekDayPosition { + /** + * The day is positioned at the start of the calendar to + * ensure proper alignment of the first day of the week. + * The day is before the provided start date. + */ + InDate, + + /** + * The day is in the range of the provided start and end dates. + */ + RangeDate, + + /** + * The day is positioned at the end of the calendar to to fill the + * remaining days in the last week after the provided end date. + * The day is after the provided end date. + */ + OutDate, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Year.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Year.kt new file mode 100644 index 00000000..a22c210d --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/Year.kt @@ -0,0 +1,177 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.runtime.Immutable +import com.zomato.sushi.compose.components.calender.core.format.fromIso8601Year +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import com.zomato.sushi.compose.components.calender.core.serializers.YearIso8601Serializer +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.onDay +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Immutable +@Serializable(with = YearIso8601Serializer::class) +public data class Year(val value: Int) : Comparable { + internal val year = value + + init { + try { + onMonth(Month.JANUARY) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Year value $value is out of range", e) + } + } + + /** + * Same as java.time.Year.compareTo() + */ + override fun compareTo(other: Year): Int { + return value - other.value + } + + /** + * Converts this year to the ISO 8601 string representation. + */ + override fun toString(): String = toIso8601String() + + public companion object { + /** + * Obtains the current [Year] from the specified [clock] and [timeZone]. + * + * Using this method allows the use of an alternate clock or timezone for testing. + */ + @OptIn(ExperimentalTime::class) + public fun now( + clock: Clock = Clock.System, + timeZone: TimeZone = TimeZone.currentSystemDefault(), + ): Year = Year(LocalDate.now(clock, timeZone).year) + + /** + * Checks if the year is a leap year, according to the ISO proleptic calendar system rules. + * + * This method applies the current rules for leap years across the whole time-line. + * In general, a year is a leap year if it is divisible by four without remainder. + * However, years divisible by 100, are not leap years, with the exception of years + * divisible by 400 which are. + * + * For example, 1904 was a leap year it is divisible by 4. 1900 was not a leap year + * as it is divisible by 100, however 2000 was a leap year as it is divisible by 400. + * + * The calculation is proleptic - applying the same rules into the far future and far past. + * This is historically inaccurate, but is correct for the ISO-8601 standard. + */ + public fun isLeap(year: Int): Boolean { + val prolepticYear: Long = year.toLong() + return prolepticYear and 3 == 0L && (prolepticYear % 100 != 0L || prolepticYear % 400 == 0L) + } + + /** + * Obtains an instance of [Year] from a text string such as `2020`. + * + * The string format must be `yyyy`, ideally obtained from calling [Year.toString]. + * + * @throws IllegalArgumentException if the text cannot be parsed or the boundaries of [Year] are exceeded. + * + * @see Year.toString + */ + public fun parseIso8601(string: String): Year { + return try { + string.fromIso8601Year() + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid Year value $string", e) + } + } + } +} + +/** + * Checks if the year is a leap year, according to the ISO proleptic calendar system rules. + * + * This method applies the current rules for leap years across the whole time-line. + * In general, a year is a leap year if it is divisible by four without remainder. + * However, years divisible by 100, are not leap years, with the exception of years + * divisible by 400 which are. + * + * For example, 1904 was a leap year it is divisible by 4. 1900 was not a leap year + * as it is divisible by 100, however 2000 was a leap year as it is divisible by 400. + * + * The calculation is proleptic - applying the same rules into the far future and far past. + * This is historically inaccurate, but is correct for the ISO-8601 standard. + */ +public fun Year.isLeap(): Boolean = Year.isLeap(year) + +/** + * Returns the number of days in this year. + * + * The result is 366 if this is a leap year and 365 otherwise. + */ +public fun Year.length(): Int = if (isLeap()) 366 else 365 + +/** + * Returns the [LocalDate] at the specified [dayOfYear] in this year. + * + * The day-of-year value 366 is only valid in a leap year + * + * @throws IllegalArgumentException if [dayOfYear] value is invalid in this year. + */ +public fun Year.onDay(dayOfYear: Int): LocalDate { + require( + dayOfYear >= 1 && + (dayOfYear <= 365 || (isLeap() && dayOfYear <= 366)), + ) { + "Invalid dayOfYear value '$dayOfYear' for year '$year" + } + for (month in Month.entries) { + val yearMonth = onMonth(month) + if (yearMonth.lastDay.dayOfYear >= dayOfYear) { + return yearMonth.onDay((dayOfYear - yearMonth.firstDay.dayOfYear) + 1) + } + } + throw IllegalArgumentException("Invalid dayOfYear value '$dayOfYear' for year '$year") +} + +/** + * Returns the [LocalDate] at the specified [monthNumber] and [day] in this year. + * + * @throws IllegalArgumentException if either [monthNumber] is invalid or the [day] value + * is invalid in the resolved calendar [Month]. + */ +public fun Year.onMonthDay(monthNumber: Int, day: Int): LocalDate = LocalDate(year, monthNumber, day) + +/** + * Returns the [LocalDate] at the specified [month] and [day] in this year. + * + * @throws IllegalArgumentException if the [day] value is invalid in the resolved calendar [Month]. + */ +public fun Year.onMonthDay(month: Month, day: Int): LocalDate = LocalDate(year, month, day) + +/** + * Returns the [YearMonth] at the specified [month] in this year. + */ +public fun Year.onMonth(month: Month): YearMonth = YearMonth(year, month) + +/** + * Returns the [YearMonth] at the specified [monthNumber] in this year. + * + * @throws IllegalArgumentException if either [monthNumber] is invalid. + */ +public fun Year.onMonth(monthNumber: Int): YearMonth = YearMonth(year, monthNumber) + +/** + * Returns the number of whole years between two year values. + */ +public fun Year.yearsUntil(other: Year): Int = other.year - year + +/** + * Returns a [Year] that results from adding the [value] number of years to this year. + */ +public fun Year.plusYears(value: Int): Year = Year(year + value) + +/** + * Returns a [Year] that results from subtracting the [value] number of years to this year. + */ +public fun Year.minusYears(value: Int): Year = Year(year - value) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/format/Format.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/format/Format.kt new file mode 100644 index 00000000..e1d7ffaa --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/format/Format.kt @@ -0,0 +1,33 @@ +package com.zomato.sushi.compose.components.calender.core.format + +import com.zomato.sushi.compose.components.calender.core.Year +import com.zomato.sushi.compose.components.calender.core.onMonth +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth + +private val ISO_YEAR_MONTH by lazy { + YearMonth.Formats.ISO +} + +private val ISO_YEAR by lazy { + LocalDate.Format { year() } +} + +private val ISO_LOCAL_DATE by lazy { + LocalDate.Formats.ISO +} + +internal fun LocalDate.toIso8601String() = ISO_LOCAL_DATE.format(this) + +internal fun YearMonth.toIso8601String() = ISO_YEAR_MONTH.format(this) + +internal fun Year.toIso8601String() = ISO_YEAR.format(onMonth(1).firstDay) + +internal fun String.fromIso8601LocalDate(): LocalDate = + LocalDate.parse(this, ISO_LOCAL_DATE) + +internal fun String.fromIso8601YearMonth(): YearMonth = + YearMonth.parse(this, ISO_YEAR_MONTH) + +internal fun String.fromIso8601Year(): Year = + Year(LocalDate.parse("$this-01-01", ISO_LOCAL_DATE).year) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/serializers/YearSerializers.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/serializers/YearSerializers.kt new file mode 100644 index 00000000..97f6953f --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/core/serializers/YearSerializers.kt @@ -0,0 +1,71 @@ +package com.zomato.sushi.compose.components.calender.core.serializers + +import com.zomato.sushi.compose.components.calender.core.Year +import com.zomato.sushi.compose.components.calender.core.format.fromIso8601Year +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.MissingFieldException +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure + +/** + * A serializer for [Year] that uses the ISO 8601 representation. + * + * JSON example: `"2020"` + * + * @see Year.toString + */ +public object YearIso8601Serializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.zomato.sushi.compose.components.calender.core.Year", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Year = + decoder.decodeString().fromIso8601Year() + + override fun serialize(encoder: Encoder, value: Year) { + encoder.encodeString(value.toIso8601String()) + } +} + +/** + * A serializer for [Year] that represents a value as its components. + * + * JSON example: `{"year":2020}` + */ +public object YearComponentSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.zomato.sushi.compose.components.calender.core.Year") { + element("year") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): Year = + decoder.decodeStructure(descriptor) { + var year: Int? = null + loop@ while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> year = decodeIntElement(descriptor, 0) + CompositeDecoder.DECODE_DONE -> break@loop // https://youtrack.jetbrains.com/issue/KT-42262 + else -> throw SerializationException("Unexpected index: $index") + } + } + if (year == null) throw MissingFieldException(missingField = "year", serialName = descriptor.serialName) + Year(year) + } + + override fun serialize(encoder: Encoder, value: Year) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.value) + } + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/DataStore.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/DataStore.kt new file mode 100644 index 00000000..cdd3f5b1 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/DataStore.kt @@ -0,0 +1,21 @@ +package com.zomato.sushi.compose.components.calender.data + +/** + * Basically [MutableMap.getOrPut] but allows us read the map + * in multiple places without calling `getOrPut` everywhere. + */ +internal class DataStore( + private val store: MutableMap = HashMap(), + private val create: (offset: Int) -> V, +) : MutableMap by store { + override fun get(key: Int): V { + val value = store[key] + return if (value == null) { + val data = create(key) + put(key, data) + data + } else { + value + } + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Extensions.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Extensions.kt new file mode 100644 index 00000000..252182fa --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Extensions.kt @@ -0,0 +1,31 @@ +package com.zomato.sushi.compose.components.calender.data + +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.DayPosition +import com.zomato.sushi.compose.components.calender.core.minusMonths +import com.zomato.sushi.compose.components.calender.core.plusMonths +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.YearMonth +import kotlinx.datetime.yearMonth + +// E.g DayOfWeek.SATURDAY.daysUntil(DayOfWeek.TUESDAY) = 3 +internal fun DayOfWeek.daysUntil(other: DayOfWeek) = (7 + (other.ordinal - ordinal)) % 7 + +// E.g DayOfWeek.SATURDAY.plusDays(3) = DayOfWeek.TUESDAY +internal fun DayOfWeek.plusDays(days: Int): DayOfWeek { + val amount = (days % 7) + return DayOfWeek.entries[(ordinal + (amount + 7)) % 7] +} + +// Find the actual month on the calendar where this date is shown. +internal val CalendarDay.positionYearMonth: YearMonth + get() = when (position) { + DayPosition.InDate -> date.yearMonth.plusMonths(1) + DayPosition.MonthDate -> date.yearMonth + DayPosition.OutDate -> date.yearMonth.minusMonths(1) + } + +internal inline fun Iterable.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { + val result = indexOfFirst(predicate) + return if (result == -1) null else result +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/MonthData.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/MonthData.kt new file mode 100644 index 00000000..ffc6f18e --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/MonthData.kt @@ -0,0 +1,92 @@ +package com.zomato.sushi.compose.components.calender.data + +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.DayPosition +import com.zomato.sushi.compose.components.calender.core.OutDateStyle +import com.zomato.sushi.compose.components.calender.core.minusDays +import com.zomato.sushi.compose.components.calender.core.minusMonths +import com.zomato.sushi.compose.components.calender.core.plusDays +import com.zomato.sushi.compose.components.calender.core.plusMonths +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.YearMonth +import kotlinx.datetime.monthsUntil +import kotlinx.datetime.yearMonth + +internal data class MonthData( + private val month: YearMonth, + private val inDays: Int, + private val outDays: Int, +) { + private val totalDays = inDays + month.numberOfDays + outDays + + private val firstDay = month.firstDay.minusDays(inDays) + + private val rows = (0 until totalDays).chunked(7) + + private val previousMonth = month.minusMonths(1) + + private val nextMonth = month.plusMonths(1) + + val calendarMonth = CalendarMonth(month, rows.map { week -> week.map { dayOffset -> getDay(dayOffset) } }) + + private fun getDay(dayOffset: Int): CalendarDay { + val date = firstDay.plusDays(dayOffset) + val position = when (date.yearMonth) { + month -> DayPosition.MonthDate + previousMonth -> DayPosition.InDate + nextMonth -> DayPosition.OutDate + else -> throw IllegalArgumentException("Invalid date: $date in month: $month") + } + return CalendarDay(date, position) + } +} + +internal fun getCalendarMonthData( + startMonth: YearMonth, + offset: Int, + firstDayOfWeek: DayOfWeek, + outDateStyle: OutDateStyle, +): MonthData { + val month = startMonth.plusMonths(offset) + val firstDay = month.firstDay + val inDays = firstDayOfWeek.daysUntil(firstDay.dayOfWeek) + val outDays = (inDays + month.numberOfDays).let { inAndMonthDays -> + val endOfRowDays = if (inAndMonthDays % 7 != 0) 7 - (inAndMonthDays % 7) else 0 + val endOfGridDays = if (outDateStyle == OutDateStyle.EndOfRow) { + 0 + } else { + val weeksInMonth = (inAndMonthDays + endOfRowDays) / 7 + (6 - weeksInMonth) * 7 + } + return@let endOfRowDays + endOfGridDays + } + return MonthData(month, inDays, outDays) +} + +internal fun getHeatMapCalendarMonthData( + startMonth: YearMonth, + offset: Int, + firstDayOfWeek: DayOfWeek, +): MonthData { + val month = startMonth.plusMonths(offset) + val firstDay = month.firstDay + val inDays = if (offset == 0) { + firstDayOfWeek.daysUntil(firstDay.dayOfWeek) + } else { + -firstDay.dayOfWeek.daysUntil(firstDayOfWeek) + } + val outDays = (inDays + month.numberOfDays).let { inAndMonthDays -> + if (inAndMonthDays % 7 != 0) 7 - (inAndMonthDays % 7) else 0 + } + return MonthData(month, inDays, outDays) +} + +internal fun getMonthIndex(startMonth: YearMonth, targetMonth: YearMonth): Int { + return startMonth.monthsUntil(targetMonth) +} + +internal fun getMonthIndicesCount(startMonth: YearMonth, endMonth: YearMonth): Int { + // Add one to include the start month itself! + return getMonthIndex(startMonth, endMonth) + 1 +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.kt new file mode 100644 index 00000000..545f6188 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.kt @@ -0,0 +1,9 @@ +package com.zomato.sushi.compose.components.calender.data + +internal fun > checkRange(start: T, end: T) { + check(end >= start) { + "start: $start is greater than end: $end" + } +} + +internal expect fun log(tag: String, message: String) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/VisibleItemState.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/VisibleItemState.kt new file mode 100644 index 00000000..5a94c912 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/VisibleItemState.kt @@ -0,0 +1,9 @@ +package com.zomato.sushi.compose.components.calender.data + +import androidx.compose.runtime.Immutable + +@Immutable +internal class VisibleItemState( + val firstVisibleItemIndex: Int = 0, + val firstVisibleItemScrollOffset: Int = 0, +) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/WeekData.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/WeekData.kt new file mode 100644 index 00000000..5521333b --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/WeekData.kt @@ -0,0 +1,65 @@ +package com.zomato.sushi.compose.components.calender.data + +import com.zomato.sushi.compose.components.calender.core.Week +import com.zomato.sushi.compose.components.calender.core.WeekDay +import com.zomato.sushi.compose.components.calender.core.WeekDayPosition +import com.zomato.sushi.compose.components.calender.core.minusDays +import com.zomato.sushi.compose.components.calender.core.plusDays +import com.zomato.sushi.compose.components.calender.core.plusWeeks +import com.zomato.sushi.compose.components.calender.core.weeksUntil +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate + +internal data class WeekDateRange( + val startDateAdjusted: LocalDate, + val endDateAdjusted: LocalDate, +) + +internal fun getWeekCalendarAdjustedRange( + startDate: LocalDate, + endDate: LocalDate, + firstDayOfWeek: DayOfWeek, +): WeekDateRange { + val inDays = firstDayOfWeek.daysUntil(startDate.dayOfWeek) + val startDateAdjusted = startDate.minusDays(inDays) + val weeksBetween = startDateAdjusted.weeksUntil(endDate) + val endDateAdjusted = startDateAdjusted.plusWeeks(weeksBetween).plusDays(6) + return WeekDateRange(startDateAdjusted = startDateAdjusted, endDateAdjusted = endDateAdjusted) +} + +internal fun getWeekCalendarData( + startDateAdjusted: LocalDate, + offset: Int, + desiredStartDate: LocalDate, + desiredEndDate: LocalDate, +): WeekData { + val firstDayInWeek = startDateAdjusted.plusWeeks(offset) + return WeekData(firstDayInWeek, desiredStartDate, desiredEndDate) +} + +internal data class WeekData( + private val firstDayInWeek: LocalDate, + private val desiredStartDate: LocalDate, + private val desiredEndDate: LocalDate, +) { + val week: Week = Week((0 until 7).map { dayOffset -> getDay(dayOffset) }) + + private fun getDay(dayOffset: Int): WeekDay { + val date = firstDayInWeek.plusDays(dayOffset) + val position = when { + date < desiredStartDate -> WeekDayPosition.InDate + date > desiredEndDate -> WeekDayPosition.OutDate + else -> WeekDayPosition.RangeDate + } + return WeekDay(date, position) + } +} + +internal fun getWeekIndex(startDateAdjusted: LocalDate, date: LocalDate): Int { + return startDateAdjusted.weeksUntil(date) +} + +internal fun getWeekIndicesCount(startDateAdjusted: LocalDate, endDateAdjusted: LocalDate): Int { + // Add one to include the start week itself! + return getWeekIndex(startDateAdjusted, endDateAdjusted) + 1 +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/YearData.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/YearData.kt new file mode 100644 index 00000000..cf042078 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/data/YearData.kt @@ -0,0 +1,37 @@ +package com.zomato.sushi.compose.components.calender.data + +import com.zomato.sushi.compose.components.calender.core.CalendarYear +import com.zomato.sushi.compose.components.calender.core.OutDateStyle +import com.zomato.sushi.compose.components.calender.core.Year +import com.zomato.sushi.compose.components.calender.core.onMonth +import com.zomato.sushi.compose.components.calender.core.plusYears +import com.zomato.sushi.compose.components.calender.core.yearsUntil +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month + +internal fun getCalendarYearData( + startYear: Year, + offset: Int, + firstDayOfWeek: DayOfWeek, + outDateStyle: OutDateStyle, +): CalendarYear { + val year = startYear.plusYears(offset) + val months = List(Month.entries.size) { index -> + getCalendarMonthData( + startMonth = year.onMonth(Month.JANUARY), + offset = index, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ).calendarMonth + } + return CalendarYear(year, months) +} + +internal fun getYearIndex(startYear: Year, targetYear: Year): Int { + return startYear.yearsUntil(targetYear) +} + +internal fun getYearIndicesCount(startYear: Year, endYear: Year): Int { + // Add one to include the start year itself! + return getYearIndex(startYear, endYear) + 1 +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendar.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendar.kt new file mode 100644 index 00000000..a46794c0 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendar.kt @@ -0,0 +1,92 @@ +package com.zomato.sushi.compose.components.calender.heatmapcalendar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.daysOfWeek +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import kotlinx.datetime.DayOfWeek + +@Composable +internal fun HeatMapCalendarImpl( + modifier: Modifier, + state: HeatMapCalendarState, + userScrollEnabled: Boolean, + weekHeaderPosition: HeatMapWeekHeaderPosition, + contentPadding: PaddingValues, + dayContent: @Composable ColumnScope.(day: CalendarDay, week: HeatMapWeek) -> Unit, + weekHeader: (@Composable ColumnScope.(DayOfWeek) -> Unit)? = null, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom, + ) { + if (weekHeaderPosition == HeatMapWeekHeaderPosition.Start && weekHeader != null) { + WeekHeaderColumn( + horizontalAlignment = Alignment.End, + firstDayOfWeek = state.firstDayOfWeek, + weekHeader = weekHeader, + ) + } + LazyRow( + modifier = Modifier.weight(1f), + state = state.listState, + userScrollEnabled = userScrollEnabled, + contentPadding = contentPadding, + ) { + items( + count = state.calendarInfo.indexCount, + key = { offset -> state.store[offset].yearMonth.toIso8601String() }, + ) { offset -> + val calendarMonth = state.store[offset] + Column(modifier = Modifier.width(IntrinsicSize.Max)) { + monthHeader?.invoke(this, calendarMonth) + Row { + for (week in calendarMonth.weekDays) { + Column { + for (day in week) { + dayContent(day, HeatMapWeek(week)) + } + } + } + } + } + } + } + if (weekHeaderPosition == HeatMapWeekHeaderPosition.End && weekHeader != null) { + WeekHeaderColumn( + horizontalAlignment = Alignment.Start, + firstDayOfWeek = state.firstDayOfWeek, + weekHeader = weekHeader, + ) + } + } +} + +@Composable +private fun WeekHeaderColumn( + horizontalAlignment: Alignment.Horizontal, + firstDayOfWeek: DayOfWeek, + weekHeader: @Composable (ColumnScope.(DayOfWeek) -> Unit), +) { + Column( + modifier = Modifier.width(IntrinsicSize.Max), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = horizontalAlignment, + ) { + for (dayOfWeek in daysOfWeek(firstDayOfWeek)) { + weekHeader(dayOfWeek) + } + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendarState.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendarState.kt new file mode 100644 index 00000000..7d839594 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapCalendarState.kt @@ -0,0 +1,270 @@ +package com.zomato.sushi.compose.components.calender.heatmapcalendar + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.zomato.sushi.compose.components.calender.CalendarInfo +import com.zomato.sushi.compose.components.calender.CalendarLayoutInfo +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.firstDayOfWeekFromLocale +import com.zomato.sushi.compose.components.calender.core.format.fromIso8601YearMonth +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import com.zomato.sushi.compose.components.calender.core.now +import com.zomato.sushi.compose.components.calender.data.DataStore +import com.zomato.sushi.compose.components.calender.data.VisibleItemState +import com.zomato.sushi.compose.components.calender.data.checkRange +import com.zomato.sushi.compose.components.calender.data.getHeatMapCalendarMonthData +import com.zomato.sushi.compose.components.calender.data.getMonthIndex +import com.zomato.sushi.compose.components.calender.data.getMonthIndicesCount +import com.zomato.sushi.compose.components.calender.data.log +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.YearMonth +import kotlin.time.ExperimentalTime + +/** + * Creates a [HeatMapCalendarState] that is remembered across compositions. + * + * @param startMonth the initial value for [HeatMapCalendarState.startMonth] + * @param endMonth the initial value for [HeatMapCalendarState.endMonth] + * @param firstDayOfWeek the initial value for [HeatMapCalendarState.firstDayOfWeek] + * @param firstVisibleMonth the initial value for [HeatMapCalendarState.firstVisibleMonth] + */ +@OptIn(ExperimentalTime::class) +@Composable +public fun rememberHeatMapCalendarState( + startMonth: YearMonth = YearMonth.now(), + endMonth: YearMonth = startMonth, + firstVisibleMonth: YearMonth = startMonth, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), +): HeatMapCalendarState { + return rememberSaveable( + inputs = arrayOf( + startMonth, + endMonth, + firstVisibleMonth, + firstDayOfWeek, + ), + saver = HeatMapCalendarState.Saver, + ) { + HeatMapCalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstDayOfWeek = firstDayOfWeek, + firstVisibleMonth = firstVisibleMonth, + visibleItemState = null, + ) + } +} + +/** + * A state object that can be hoisted to control and observe calendar properties. + * + * This should be created via [rememberHeatMapCalendarState]. + * + * @param startMonth the first month on the calendar. + * @param endMonth the last month on the calendar. + * @param firstDayOfWeek the first day of week on the calendar. + * @param firstVisibleMonth the initial value for [HeatMapCalendarState.firstVisibleMonth] + */ +@Stable +public class HeatMapCalendarState internal constructor( + startMonth: YearMonth, + endMonth: YearMonth, + firstVisibleMonth: YearMonth, + firstDayOfWeek: DayOfWeek, + visibleItemState: VisibleItemState?, +) : ScrollableState { + /** Backing state for [startMonth] */ + private var _startMonth by mutableStateOf(startMonth) + + /** The first month on the calendar. */ + public var startMonth: YearMonth + get() = _startMonth + set(value) { + if (value != startMonth) { + _startMonth = value + monthDataChanged() + } + } + + /** Backing state for [endMonth] */ + private var _endMonth by mutableStateOf(endMonth) + + /** The last month on the calendar. */ + public var endMonth: YearMonth + get() = _endMonth + set(value) { + if (value != endMonth) { + _endMonth = value + monthDataChanged() + } + } + + /** Backing state for [firstDayOfWeek] */ + private var _firstDayOfWeek by mutableStateOf(firstDayOfWeek) + + /** The first day of week on the calendar. */ + public var firstDayOfWeek: DayOfWeek + get() = _firstDayOfWeek + set(value) { + if (value != firstDayOfWeek) { + _firstDayOfWeek = value + monthDataChanged() + } + } + + /** + * The first month that is visible. + * + * @see [lastVisibleMonth] + */ + public val firstVisibleMonth: CalendarMonth by derivedStateOf { + store[listState.firstVisibleItemIndex] + } + + /** + * The last month that is visible. + * + * @see [firstVisibleMonth] + */ + public val lastVisibleMonth: CalendarMonth by derivedStateOf { + store[listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0] + } + + /** + * The object of [CalendarLayoutInfo] calculated during the last layout pass. For example, + * you can use it to calculate what items are currently visible. + * + * Note that this property is observable and is updated after every scroll or remeasure. + * If you use it in the composable function it will be recomposed on every change causing + * potential performance issues including infinity recomposition loop. + * Therefore, avoid using it in the composition. + * + * If you need to use it in the composition then consider wrapping the calculation into a + * derived state in order to only have recompositions when the derived value changes. + * See Example6Page in the sample app for usage. + * + * If you want to run some side effects like sending an analytics event or updating a state + * based on this value consider using "snapshotFlow". + */ + public val layoutInfo: CalendarLayoutInfo + get() = CalendarLayoutInfo(listState.layoutInfo) { index -> store[index] } + + /** + * [InteractionSource] that will be used to dispatch drag events when this + * calendar is being dragged. If you want to know whether the fling (or animated scroll) is in + * progress, use [isScrollInProgress]. + */ + public val interactionSource: InteractionSource + get() = listState.interactionSource + + internal val listState = LazyListState( + firstVisibleItemIndex = visibleItemState?.firstVisibleItemIndex + ?: getScrollIndex(firstVisibleMonth) ?: 0, + firstVisibleItemScrollOffset = visibleItemState?.firstVisibleItemScrollOffset ?: 0, + ) + + internal var calendarInfo by mutableStateOf(CalendarInfo(indexCount = 0)) + + internal val store = DataStore { offset -> + getHeatMapCalendarMonthData( + startMonth = this.startMonth, + offset = offset, + firstDayOfWeek = this.firstDayOfWeek, + ).calendarMonth + } + + init { + monthDataChanged() // Update indexCount initially. + } + + private fun monthDataChanged() { + store.clear() + checkRange(startMonth, endMonth) + calendarInfo = CalendarInfo( + indexCount = getMonthIndicesCount(startMonth, endMonth), + firstDayOfWeek = firstDayOfWeek, + ) + } + + /** + * Instantly brings the [month] to the top of the viewport. + * + * @param month the month to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + * + * @see [animateScrollToMonth] + */ + public suspend fun scrollToMonth(month: YearMonth) { + listState.scrollToItem(getScrollIndex(month) ?: return) + } + + /** + * Animate (smooth scroll) to the given [month]. + * + * @param month the month to which to scroll. Must be within the + * range of [startMonth] and [endMonth]. + */ + public suspend fun animateScrollToMonth(month: YearMonth) { + listState.animateScrollToItem(getScrollIndex(month) ?: return) + } + + private fun getScrollIndex(month: YearMonth): Int? { + if (month !in startMonth..endMonth) { + log("HeatMapCalendarState", "Attempting to scroll out of range: $month") + return null + } + return getMonthIndex(startMonth, month) + } + + /** + * Whether this [ScrollableState] is currently scrolling by gesture, fling or programmatically. + */ + override val isScrollInProgress: Boolean + get() = listState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = listState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ): Unit = listState.scroll(scrollPriority, block) + + public companion object { + internal val Saver: Saver = listSaver( + save = { + listOf( + it.startMonth.toIso8601String(), + it.endMonth.toIso8601String(), + it.firstVisibleMonth.yearMonth.toIso8601String(), + it.firstDayOfWeek, + it.listState.firstVisibleItemIndex, + it.listState.firstVisibleItemScrollOffset, + ) + }, + restore = { + HeatMapCalendarState( + startMonth = (it[0] as String).fromIso8601YearMonth(), + endMonth = (it[1] as String).fromIso8601YearMonth(), + firstVisibleMonth = (it[2] as String).fromIso8601YearMonth(), + firstDayOfWeek = it[3] as DayOfWeek, + visibleItemState = VisibleItemState( + firstVisibleItemIndex = it[4] as Int, + firstVisibleItemScrollOffset = it[5] as Int, + ), + ) + }, + ) + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeek.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeek.kt new file mode 100644 index 00000000..7d22d0d4 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeek.kt @@ -0,0 +1,18 @@ +package com.zomato.sushi.compose.components.calender.heatmapcalendar + +import androidx.compose.runtime.Immutable +import com.zomato.sushi.compose.components.calender.HeatMapCalendar +import com.zomato.sushi.compose.components.calender.core.CalendarDay + +/** + * Represents a week on the heatmap calendar. + * + * This model exists only as a wrapper class with the [Immutable] annotation for compose. + * The alternative would be to use the `kotlinx.ImmutableList` type for the `days` value + * which is used ONLY in the dayContent parameter of the [HeatMapCalendar] but then we + * would force that dependency on the library consumers. + * + * @param days the days in this week. + */ +@Immutable +public data class HeatMapWeek(val days: List) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeekHeaderPosition.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeekHeaderPosition.kt new file mode 100644 index 00000000..6a013533 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/heatmapcalendar/HeatMapWeekHeaderPosition.kt @@ -0,0 +1,19 @@ +package com.zomato.sushi.compose.components.calender.heatmapcalendar + +import com.zomato.sushi.compose.components.calender.HeatMapCalendar + +/** + * Determines the position of the week header + * composable (Mon, Tue, Wed...) in the [HeatMapCalendar] + */ +public enum class HeatMapWeekHeaderPosition { + /** + * The header is positioned at the start of the calendar. + */ + Start, + + /** + * The header is positioned at the end of the calendar. + */ + End, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendar.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendar.kt new file mode 100644 index 00000000..21924b0b --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendar.kt @@ -0,0 +1,109 @@ +package com.zomato.sushi.compose.components.calender.weekcalendar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import com.zomato.sushi.compose.components.calender.CalendarDefaults.flingBehavior +import com.zomato.sushi.compose.components.calender.ItemCoordinates +import com.zomato.sushi.compose.components.calender.ItemCoordinatesStore +import com.zomato.sushi.compose.components.calender.or +import com.zomato.sushi.compose.components.calender.core.Week +import com.zomato.sushi.compose.components.calender.core.WeekDay +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String + +@Composable +internal fun WeekCalendarImpl( + modifier: Modifier, + state: WeekCalendarState, + calendarScrollPaged: Boolean, + userScrollEnabled: Boolean, + reverseLayout: Boolean, + contentPadding: PaddingValues, + dayContent: @Composable BoxScope.(WeekDay) -> Unit, + weekHeader: (@Composable ColumnScope.(Week) -> Unit)? = null, + weekFooter: (@Composable ColumnScope.(Week) -> Unit)? = null, + weekContainer: (@Composable LazyItemScope.(Week, container: @Composable () -> Unit) -> Unit)? = null, + onItemPlaced: (itemCoordinates: ItemCoordinates) -> Unit, +) { + LazyRow( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + items( + count = state.weekIndexCount, + key = { offset -> state.store[offset].days.first().date.toIso8601String() }, + ) { offset -> + val week = state.store[offset] + val currentOnItemPlaced by rememberUpdatedState(onItemPlaced) + val itemCoordinatesStore = remember(week.days.first().date) { + ItemCoordinatesStore(currentOnItemPlaced) + } + val hasWeekContainer = weekContainer != null + Box(Modifier.onPlaced(itemCoordinatesStore::onItemRootPlaced)) { + weekContainer.or(defaultWeekContainer)(week) { + Column( + modifier = Modifier + .then( + if (calendarScrollPaged) { + if (hasWeekContainer) { + Modifier.fillMaxWidth() + } else { + Modifier.fillParentMaxWidth() + } + } else { + Modifier.width(IntrinsicSize.Max) + }, + ), + ) { + weekHeader?.invoke(this, week) + Row { + for ((column, day) in week.days.withIndex()) { + Box( + modifier = Modifier + .then(if (calendarScrollPaged) Modifier.weight(1f) else Modifier) + .clipToBounds() + .onFirstDayPlaced(column, itemCoordinatesStore::onFirstDayPlaced), + ) { + dayContent(day) + } + } + } + weekFooter?.invoke(this, week) + } + } + } + } + } +} + +private inline fun Modifier.onFirstDayPlaced( + column: Int, + noinline onFirstDayPlaced: (coordinates: LayoutCoordinates) -> Unit, +) = if (column == 0) { + onPlaced(onFirstDayPlaced) +} else { + this +} + +private val defaultWeekContainer: (@Composable LazyItemScope.(Week, container: @Composable () -> Unit) -> Unit) = + { _, container -> container() } diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarLayoutInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarLayoutInfo.kt new file mode 100644 index 00000000..f1a875e0 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarLayoutInfo.kt @@ -0,0 +1,37 @@ +package com.zomato.sushi.compose.components.calender.weekcalendar + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.zomato.sushi.compose.components.calender.core.Week + +/** + * Contains useful information about the currently displayed layout state of the calendar. + * For example you can get the list of currently displayed months. + * + * Use [WeekCalendarState.layoutInfo] to retrieve this. + * + * @see LazyListLayoutInfo + */ +public class WeekCalendarLayoutInfo( + info: LazyListLayoutInfo, + private val getIndexData: (Int) -> Week, +) : LazyListLayoutInfo by info { + /** + * The list of [WeekCalendarItemInfo] representing all the currently visible weeks. + */ + public val visibleWeeksInfo: List + get() = visibleItemsInfo.map { info -> + WeekCalendarItemInfo(info, getIndexData(info.index)) + } +} + +/** + * Contains useful information about an individual week on the calendar. + * + * @param week The week in the list. + + * @see WeekCalendarLayoutInfo + * @see LazyListItemInfo + */ +public class WeekCalendarItemInfo(info: LazyListItemInfo, public val week: Week) : + LazyListItemInfo by info diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarState.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarState.kt new file mode 100644 index 00000000..b93aba31 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/weekcalendar/WeekCalendarState.kt @@ -0,0 +1,372 @@ +package com.zomato.sushi.compose.components.calender.weekcalendar + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.zomato.sushi.compose.components.calender.ItemPlacementInfo +import com.zomato.sushi.compose.components.calender.core.Week +import com.zomato.sushi.compose.components.calender.core.WeekDay +import com.zomato.sushi.compose.components.calender.core.WeekDayPosition +import com.zomato.sushi.compose.components.calender.core.firstDayOfWeekFromLocale +import com.zomato.sushi.compose.components.calender.core.format.fromIso8601LocalDate +import com.zomato.sushi.compose.components.calender.core.format.toIso8601String +import com.zomato.sushi.compose.components.calender.core.now +import com.zomato.sushi.compose.components.calender.data.DataStore +import com.zomato.sushi.compose.components.calender.data.VisibleItemState +import com.zomato.sushi.compose.components.calender.data.checkRange +import com.zomato.sushi.compose.components.calender.data.daysUntil +import com.zomato.sushi.compose.components.calender.data.getWeekCalendarAdjustedRange +import com.zomato.sushi.compose.components.calender.data.getWeekCalendarData +import com.zomato.sushi.compose.components.calender.data.getWeekIndex +import com.zomato.sushi.compose.components.calender.data.getWeekIndicesCount +import com.zomato.sushi.compose.components.calender.data.log +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlin.time.ExperimentalTime + +/** + * Creates a [WeekCalendarState] that is remembered across compositions. + * + * @param startDate the initial value for [WeekCalendarState.startDate] + * @param endDate the initial value for [WeekCalendarState.endDate] + * @param firstDayOfWeek the initial value for [WeekCalendarState.firstDayOfWeek] + * @param firstVisibleWeekDate the date which will have its week visible initially. + */ +@OptIn(ExperimentalTime::class) +@Composable +public fun rememberWeekCalendarState( + startDate: LocalDate = YearMonth.now().firstDay, + endDate: LocalDate = YearMonth.now().lastDay, + firstVisibleWeekDate: LocalDate = LocalDate.now(), + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), +): WeekCalendarState { + return rememberSaveable( + inputs = arrayOf( + startDate, + endDate, + firstVisibleWeekDate, + firstDayOfWeek, + ), + saver = WeekCalendarState.Saver, + ) { + WeekCalendarState( + startDate = startDate, + endDate = endDate, + firstVisibleWeekDate = firstVisibleWeekDate, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = null, + ) + } +} + +/** + * A state object that can be hoisted to control and observe calendar properties. + * + * This should be created via [rememberWeekCalendarState]. + * + * @param startDate the desired first date on the calendar. The actual first date will be the + * first day in the week to which this date belongs, depending on the provided [firstDayOfWeek]. + * Such days will have their [WeekDayPosition] set to [WeekDayPosition.InDate]. + * @param endDate the desired last date on the calendar. The actual last date will be the last + * day in the week to which this date belongs. Such days will have their [WeekDayPosition] set + * to [WeekDayPosition.OutDate]. + * @param firstDayOfWeek the first day of week on the calendar. + * @param firstVisibleWeekDate the date which will have its week visible initially. + */ +@Stable +public class WeekCalendarState internal constructor( + startDate: LocalDate, + endDate: LocalDate, + firstVisibleWeekDate: LocalDate, + firstDayOfWeek: DayOfWeek, + visibleItemState: VisibleItemState?, +) : ScrollableState { + /** + * The adjusted first date on the calendar to ensure proper alignment + * of the provided [firstDayOfWeek]. + */ + private var startDateAdjusted by mutableStateOf(startDate) + + /** + * The adjusted last date on the calendar to fill the remaining days in the + * last week after the provided end date. + */ + private var endDateAdjusted by mutableStateOf(endDate) + + /** Backing state for [startDate] */ + private var _startDate by mutableStateOf(startDate) + + /** + * The desired first date on the calendar. The actual first date will be the first day + * in the week to which this date belongs, depending on the provided [firstDayOfWeek]. + * Such days will have their [WeekDayPosition] set to [WeekDayPosition.InDate] + */ + public var startDate: LocalDate + get() = _startDate + set(value) { + if (value != _startDate) { + _startDate = value + adjustDateRange() + } + } + + /** Backing state for [endDate] */ + private var _endDate by mutableStateOf(endDate) + + /** + * The desired last date on the calendar. The actual last date will be the last day + * in the week to which this date belongs. Such days will have their [WeekDayPosition] + * set to [WeekDayPosition.OutDate] + */ + public var endDate: LocalDate + get() = _endDate + set(value) { + if (value != _endDate) { + _endDate = value + adjustDateRange() + } + } + + /** Backing state for [firstDayOfWeek] */ + private var _firstDayOfWeek by mutableStateOf(firstDayOfWeek) + + /** The first day of week on the calendar. */ + public var firstDayOfWeek: DayOfWeek + get() = _firstDayOfWeek + set(value) { + if (value != _firstDayOfWeek) { + _firstDayOfWeek = value + adjustDateRange() + } + } + + /** + * The first week that is visible. + */ + public val firstVisibleWeek: Week by derivedStateOf { + store[listState.firstVisibleItemIndex] + } + + /** + * The last week that is visible. + */ + public val lastVisibleWeek: Week by derivedStateOf { + store[listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0] + } + + /** + * The object of [WeekCalendarLayoutInfo] calculated during the last layout pass. For example, + * you can use it to calculate what items are currently visible. + * + * Note that this property is observable and is updated after every scroll or remeasure. + * If you use it in the composable function it will be recomposed on every change causing + * potential performance issues including infinity recomposition loop. + * Therefore, avoid using it in the composition. + * + * If you need to use it in the composition then consider wrapping the calculation into a + * derived state in order to only have recompositions when the derived value changes. + * See Example5Page in the sample app for usage. + * + * If you want to run some side effects like sending an analytics event or updating a state + * based on this value consider using "snapshotFlow". + */ + public val layoutInfo: WeekCalendarLayoutInfo + get() = WeekCalendarLayoutInfo(listState.layoutInfo) { index -> store[index] } + + internal val placementInfo = ItemPlacementInfo() + + internal val store = DataStore { offset -> + getWeekCalendarData( + startDateAdjusted = this.startDateAdjusted, + offset = offset, + desiredStartDate = this.startDate, + desiredEndDate = this.endDate, + ).week + } + + internal var weekIndexCount by mutableIntStateOf(0) + + internal val listState = run { + // Update date range and weekIndexCount initially. + // Since getScrollIndex requires the adjusted start date, it is necessary to do this + // before finding the first visible index. + adjustDateRange() + val item = visibleItemState ?: run { + VisibleItemState(firstVisibleItemIndex = getScrollIndex(firstVisibleWeekDate) ?: 0) + } + LazyListState( + firstVisibleItemIndex = item.firstVisibleItemIndex, + firstVisibleItemScrollOffset = item.firstVisibleItemScrollOffset, + ) + } + + private fun adjustDateRange() { + checkRange(startDate, endDate) + val data = getWeekCalendarAdjustedRange(startDate, endDate, firstDayOfWeek) + startDateAdjusted = data.startDateAdjusted + endDateAdjusted = data.endDateAdjusted + store.clear() + weekIndexCount = getWeekIndicesCount(startDateAdjusted, endDateAdjusted) + } + + /** + * Instantly brings the week containing the given [date] to the top of the viewport. + * + * @param date the week to which to scroll. + * + * @see [animateScrollToWeek] + */ + public suspend fun scrollToWeek(date: LocalDate) { + listState.scrollToItem(getScrollIndex(date) ?: return) + } + + /** + * Animate (smooth scroll) to the week containing the given [date]. + * + * @param date the week to which to scroll. + * + * @see [scrollToWeek] + */ + public suspend fun animateScrollToWeek(date: LocalDate) { + listState.animateScrollToItem(getScrollIndex(date) ?: return) + } + + /** + * Instantly brings the week containing the given [day] to the top of the viewport. + * + * @param day the week to which to scroll. + * + * @see [animateScrollToWeek]] + */ + public suspend fun scrollToWeek(day: WeekDay): Unit = scrollToWeek(day.date) + + /** + * Animate (smooth scroll) to the week containing the given [day]. + * + * @param day the week to which to scroll. + * + * @see [scrollToWeek] + */ + public suspend fun animateScrollToWeek(day: WeekDay): Unit = animateScrollToWeek(day.date) + + /** + * Instantly brings the [date] to the top of the viewport. + * + * @param date the date to which to scroll. + * + * @see [animateScrollToDate] + */ + public suspend fun scrollToDate(date: LocalDate): Unit = scrollToDate(date, animate = false) + + /** + * Animate (smooth scroll) to the given [date]. + * + * @param date the date to which to scroll. + * + * @see [scrollToDate] + */ + public suspend fun animateScrollToDate(date: LocalDate): Unit = scrollToDate(date, animate = true) + + /** + * Instantly brings the [day] to the top of the viewport. + * + * @param day the day to which to scroll. + * + * @see [animateScrollToDay] + */ + public suspend fun scrollToDay(day: WeekDay): Unit = scrollToDate(day.date) + + /** + * Animate (smooth scroll) to the given [day]. + * + * @param day the day to which to scroll. + * + * @see [scrollToDay] + */ + public suspend fun animateScrollToDay(day: WeekDay): Unit = animateScrollToDate(day.date) + + private suspend fun scrollToDate(date: LocalDate, animate: Boolean) { + val weekIndex = getScrollIndex(date) ?: return + val dayIndex = when (layoutInfo.orientation) { + Orientation.Vertical -> 0 + Orientation.Horizontal -> firstDayOfWeek.daysUntil(date.dayOfWeek) + } + val dayInfo = placementInfo.awaitFistDayOffsetAndSize(layoutInfo.orientation) ?: return + val scrollOffset = dayInfo.offset + dayInfo.size * dayIndex + if (animate) { + listState.animateScrollToItem(weekIndex, scrollOffset) + } else { + listState.scrollToItem(weekIndex, scrollOffset) + } + } + + /** + * [InteractionSource] that will be used to dispatch drag events when this + * calendar is being dragged. If you want to know whether the fling (or animated scroll) is in + * progress, use [isScrollInProgress]. + */ + public val interactionSource: InteractionSource + get() = listState.interactionSource + + /** + * Whether this [ScrollableState] is currently scrolling by gesture, fling or programmatically. + */ + override val isScrollInProgress: Boolean + get() = listState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = listState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ): Unit = listState.scroll(scrollPriority, block) + + private fun getScrollIndex(date: LocalDate): Int? { + if (date !in startDateAdjusted..endDateAdjusted) { + log("WeekCalendarState", "Attempting to scroll out of range: $date") + return null + } + return getWeekIndex(startDateAdjusted, date) + } + + public companion object { + internal val Saver: Saver = listSaver( + save = { + listOf( + it.startDate.toIso8601String(), + it.endDate.toIso8601String(), + it.firstVisibleWeek.days.first().date.toIso8601String(), + it.firstDayOfWeek, + it.listState.firstVisibleItemIndex, + it.listState.firstVisibleItemScrollOffset, + ) + }, + restore = { + WeekCalendarState( + startDate = (it[0] as String).fromIso8601LocalDate(), + endDate = (it[1] as String).fromIso8601LocalDate(), + firstVisibleWeekDate = (it[2] as String).fromIso8601LocalDate(), + firstDayOfWeek = it[3] as DayOfWeek, + visibleItemState = VisibleItemState( + firstVisibleItemIndex = it[4] as Int, + firstVisibleItemScrollOffset = it[5] as Int, + ), + ) + }, + ) + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarLayoutInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarLayoutInfo.kt new file mode 100644 index 00000000..8f16a997 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarLayoutInfo.kt @@ -0,0 +1,37 @@ +package com.zomato.sushi.compose.components.calender.yearcalendar + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.zomato.sushi.compose.components.calender.core.CalendarYear + +/** + * Contains useful information about the currently displayed layout state of the calendar. + * For example you can get the list of currently displayed years. + * + * Use [YearCalendarState.layoutInfo] to retrieve this. + * + * @see LazyListLayoutInfo + */ +public class YearCalendarLayoutInfo( + info: LazyListLayoutInfo, + private val getIndexData: (Int) -> CalendarYear, +) : LazyListLayoutInfo by info { + /** + * The list of [YearCalendarItemInfo] representing all the currently visible years. + */ + public val visibleYearsInfo: List + get() = visibleItemsInfo.map { info -> + YearCalendarItemInfo(info, getIndexData(info.index)) + } +} + +/** + * Contains useful information about an individual year on the calendar. + * + * @param year The year in the list. + + * @see YearCalendarLayoutInfo + * @see LazyListItemInfo + */ +public class YearCalendarItemInfo(info: LazyListItemInfo, public val year: CalendarYear) : + LazyListItemInfo by info diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarMonths.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarMonths.kt new file mode 100644 index 00000000..707bae38 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarMonths.kt @@ -0,0 +1,304 @@ +package com.zomato.sushi.compose.components.calender.yearcalendar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.Dp +import com.zomato.sushi.compose.components.calender.or +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.CalendarYear +import kotlin.math.min + +@Suppress("FunctionName") +internal fun LazyListScope.YearCalendarMonths( + yearCount: Int, + yearData: (offset: Int) -> CalendarYear, + monthColumns: Int, + monthVerticalSpacing: Dp, + monthHorizontalSpacing: Dp, + yearBodyContentPadding: PaddingValues, + contentHeightMode: YearContentHeightMode, + isMonthVisible: ((month: CalendarMonth) -> Boolean)?, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit)?, + monthFooter: (@Composable ColumnScope.(CalendarMonth) -> Unit)?, + monthContainer: (@Composable BoxScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit)?, + yearHeader: (@Composable ColumnScope.(CalendarYear) -> Unit)?, + yearBody: (@Composable ColumnScope.(CalendarYear, content: @Composable () -> Unit) -> Unit)?, + yearFooter: (@Composable ColumnScope.(CalendarYear) -> Unit)?, + yearContainer: (@Composable LazyItemScope.(CalendarYear, container: @Composable () -> Unit) -> Unit)?, + onItemPlaced: (itemCoordinates: YearItemCoordinates) -> Unit, +) { + items( + count = yearCount, + key = { offset -> yearData(offset).year.value }, + ) { yearOffset -> + val year = yearData(yearOffset) + val fillHeight = when (contentHeightMode) { + YearContentHeightMode.Wrap -> false + YearContentHeightMode.Fill, + YearContentHeightMode.Stretch, + -> true + } + val months = isMonthVisible.apply(year.months) + val hasYearContainer = yearContainer != null + val currentOnItemPlaced by rememberUpdatedState(onItemPlaced) + val itemCoordinatesStore = remember(year.year) { + YearItemCoordinatesStore(months.first(), currentOnItemPlaced) + } + Box(Modifier.onPlaced(itemCoordinatesStore::onItemRootPlaced)) { + yearContainer.or(defaultYearContainer)(year) { + Column( + modifier = Modifier + .then( + if (hasYearContainer) { + Modifier.fillMaxWidth() + } else { + Modifier.fillParentMaxWidth() + }, + ) + .then( + if (fillHeight) { + if (hasYearContainer) { + Modifier.fillMaxHeight() + } else { + Modifier.fillParentMaxHeight() + } + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + yearHeader?.invoke(this, year) + yearBody.or(defaultYearBody)(year) { + CalendarGrid( + modifier = Modifier + .fillMaxWidth() + .then(if (fillHeight) Modifier.weight(1f) else Modifier.wrapContentHeight()) + .padding(yearBodyContentPadding), + monthColumns = monthColumns, + monthCount = months.count(), + fillHeight = fillHeight, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + onFirstMonthPlaced = itemCoordinatesStore::onFirstMonthPlaced, + ) { monthOffset -> + val month = months[monthOffset] + val hasMonthContainer = monthContainer != null + monthContainer.or(defaultMonthContainer)(month) { + Column( + modifier = Modifier + .then(if (hasMonthContainer) Modifier.fillMaxWidth() else Modifier) + .then( + if (fillHeight) { + if (hasMonthContainer) Modifier.fillMaxHeight() else Modifier + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + monthHeader?.invoke(this, month) + monthBody.or(defaultMonthBody)(month) { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (fillHeight) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for ((row, week) in month.weekDays.withIndex()) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentHeightMode == YearContentHeightMode.Stretch) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for ((column, day) in week.withIndex()) { + Box( + modifier = Modifier + .weight(1f) + .clipToBounds() + .onFirstDayPlaced( + monthIndex = monthOffset, + dateRow = row, + dateColumn = column, + onFirstDayPlaced = itemCoordinatesStore::onFirstDayPlaced, + ), + ) { + dayContent(day) + } + } + } + } + } + } + monthFooter?.invoke(this, month) + } + } + } + } + yearFooter?.invoke(this, year) + } + } + } + } +} + +@Composable +private inline fun CalendarGrid( + monthColumns: Int, + fillHeight: Boolean, + monthVerticalSpacing: Dp, + monthHorizontalSpacing: Dp, + monthCount: Int, + modifier: Modifier = Modifier, + noinline onFirstMonthPlaced: (coordinates: LayoutCoordinates) -> Unit, + crossinline content: @Composable BoxScope.(Int) -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(monthVerticalSpacing), + ) { + val rows = (monthCount / monthColumns) + min(monthCount % monthColumns, 1) + for (rowId in 0 until rows) { + val firstIndex = rowId * monthColumns + Row( + modifier = Modifier.then( + if (fillHeight) Modifier.weight(1f) else Modifier, + ), + horizontalArrangement = Arrangement.spacedBy(monthHorizontalSpacing), + ) { + for (columnId in 0 until monthColumns) { + val index = firstIndex + columnId + Box( + modifier = Modifier + .weight(1f) + .onFirstMonthPlaced(index, onFirstMonthPlaced), + ) { + if (index < monthCount) { + content(index) + } + } + } + } + } + } +} + +@Stable +internal class YearItemCoordinatesStore( + private val firstMonth: CalendarMonth, + private val onItemPlaced: (itemCoordinates: YearItemCoordinates) -> Unit, +) { + private var itemRootCoordinates: LayoutCoordinates? = null + private var firstDayCoordinates: LayoutCoordinates? = null + private var firstMonthCoordinates: LayoutCoordinates? = null + + fun onItemRootPlaced(coordinates: LayoutCoordinates) { + itemRootCoordinates = coordinates + check() + } + + fun onFirstMonthPlaced(coordinates: LayoutCoordinates) { + firstMonthCoordinates = coordinates + check() + } + + fun onFirstDayPlaced(coordinates: LayoutCoordinates) { + firstDayCoordinates = coordinates + check() + } + + private fun check() { + val itemRootCoordinates = itemRootCoordinates ?: return + val firstMonthCoordinates = firstMonthCoordinates ?: return + val firstDayCoordinates = firstDayCoordinates ?: return + val itemCoordinates = YearItemCoordinates( + firstMonth = firstMonth, + itemRootCoordinates = itemRootCoordinates, + firstMonthCoordinates = firstMonthCoordinates, + firstDayCoordinates = firstDayCoordinates, + ) + onItemPlaced(itemCoordinates) + } +} + +private inline fun Modifier.onFirstDayPlaced( + monthIndex: Int, + dateRow: Int, + dateColumn: Int, + noinline onFirstDayPlaced: (coordinates: LayoutCoordinates) -> Unit, +) = if (monthIndex == 0 && dateRow == 0 && dateColumn == 0) { + onPlaced(onFirstDayPlaced) +} else { + this +} + +private inline fun Modifier.onFirstMonthPlaced( + monthIndex: Int, + noinline onFirstMonthPlaced: (coordinates: LayoutCoordinates) -> Unit, +) = if (monthIndex == 0) { + onPlaced(onFirstMonthPlaced) +} else { + this +} + +internal inline fun ((month: CalendarMonth) -> Boolean)?.apply(months: List) = if (this != null) { + months.filter(this).also { + check(it.isNotEmpty()) { + "Cannot remove all the months in a year, " + + "use the startYear and endYear parameters to remove full years." + } + } +} else { + months +} + +internal fun rowColumn(monthIndex: Int, monthColumns: Int): Pair { + val row = monthIndex / monthColumns + val column = monthIndex % monthColumns + // val index = row * monthColumns + column + return row to column +} + +private val defaultYearContainer: (@Composable LazyItemScope.(CalendarYear, container: @Composable () -> Unit) -> Unit) = + { _, container -> container() } + +private val defaultYearBody: (@Composable ColumnScope.(CalendarYear, content: @Composable () -> Unit) -> Unit) = + { _, content -> content() } + +private val defaultMonthContainer: (@Composable BoxScope.(CalendarMonth, container: @Composable () -> Unit) -> Unit) = + { _, container -> container() } + +private val defaultMonthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit) = + { _, content -> content() } diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarState.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarState.kt new file mode 100644 index 00000000..a749bdb6 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearCalendarState.kt @@ -0,0 +1,481 @@ +package com.zomato.sushi.compose.components.calender.yearcalendar + +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.zomato.sushi.compose.components.calender.CalendarInfo +import com.zomato.sushi.compose.components.calender.CalendarLayoutInfo +import com.zomato.sushi.compose.components.calender.core.CalendarDay +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import com.zomato.sushi.compose.components.calender.core.CalendarYear +import com.zomato.sushi.compose.components.calender.core.DayPosition +import com.zomato.sushi.compose.components.calender.core.OutDateStyle +import com.zomato.sushi.compose.components.calender.core.Year +import com.zomato.sushi.compose.components.calender.core.firstDayOfWeekFromLocale +import com.zomato.sushi.compose.components.calender.data.DataStore +import com.zomato.sushi.compose.components.calender.data.VisibleItemState +import com.zomato.sushi.compose.components.calender.data.checkRange +import com.zomato.sushi.compose.components.calender.data.daysUntil +import com.zomato.sushi.compose.components.calender.data.getCalendarYearData +import com.zomato.sushi.compose.components.calender.data.getYearIndex +import com.zomato.sushi.compose.components.calender.data.getYearIndicesCount +import com.zomato.sushi.compose.components.calender.data.indexOfFirstOrNull +import com.zomato.sushi.compose.components.calender.data.log +import com.zomato.sushi.compose.components.calender.data.positionYearMonth +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlin.time.ExperimentalTime + +/** + * Creates a [YearCalendarState] that is remembered across compositions. + * + * @param startYear the initial value for [YearCalendarState.startYear] + * @param endYear the initial value for [YearCalendarState.endYear] + * @param firstDayOfWeek the initial value for [YearCalendarState.firstDayOfWeek] + * @param firstVisibleYear the initial value for [YearCalendarState.firstVisibleYear] + * @param outDateStyle the initial value for [YearCalendarState.outDateStyle] + */ +@OptIn(ExperimentalTime::class) +@Composable +public fun rememberYearCalendarState( + startYear: Year = Year.now(), + endYear: Year = startYear, + firstVisibleYear: Year = startYear, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + outDateStyle: OutDateStyle = OutDateStyle.EndOfRow, +): YearCalendarState { + return rememberSaveable( + inputs = arrayOf( + startYear, + endYear, + firstVisibleYear, + firstDayOfWeek, + outDateStyle, + ), + saver = YearCalendarState.Saver, + ) { + YearCalendarState( + startYear = startYear, + endYear = endYear, + firstDayOfWeek = firstDayOfWeek, + firstVisibleYear = firstVisibleYear, + outDateStyle = outDateStyle, + visibleItemState = null, + ) + } +} + +/** + * A state object that can be hoisted to control and observe calendar properties. + * + * This should be created via [rememberYearCalendarState]. + * + * @param startYear the first month on the calendar. + * @param endYear the last month on the calendar. + * @param firstDayOfWeek the first day of week on the calendar. + * @param firstVisibleYear the initial value for [YearCalendarState.firstVisibleYear] + * @param outDateStyle the preferred style for out date generation. + */ +@Stable +public class YearCalendarState internal constructor( + startYear: Year, + endYear: Year, + firstDayOfWeek: DayOfWeek, + firstVisibleYear: Year, + outDateStyle: OutDateStyle, + visibleItemState: VisibleItemState?, +) : ScrollableState { + /** Backing state for [startYear] */ + private var _startYear by mutableStateOf(startYear) + + /** The first year on the calendar. */ + public var startYear: Year + get() = _startYear + set(value) { + if (value != startYear) { + _startYear = value + yearDataChanged() + } + } + + /** Backing state for [endYear] */ + private var _endYear by mutableStateOf(endYear) + + /** The last year on the calendar. */ + public var endYear: Year + get() = _endYear + set(value) { + if (value != endYear) { + _endYear = value + yearDataChanged() + } + } + + /** Backing state for [firstDayOfWeek] */ + private var _firstDayOfWeek by mutableStateOf(firstDayOfWeek) + + /** The first day of week on the calendar. */ + public var firstDayOfWeek: DayOfWeek + get() = _firstDayOfWeek + set(value) { + if (value != firstDayOfWeek) { + _firstDayOfWeek = value + yearDataChanged() + } + } + + /** Backing state for [outDateStyle] */ + private var _outDateStyle by mutableStateOf(outDateStyle) + + /** The preferred style for out date generation. */ + public var outDateStyle: OutDateStyle + get() = _outDateStyle + set(value) { + if (value != outDateStyle) { + _outDateStyle = value + yearDataChanged() + } + } + + /** + * The first year that is visible. + * + * @see [lastVisibleYear] + */ + public val firstVisibleYear: CalendarYear by derivedStateOf { + store[listState.firstVisibleItemIndex] + } + + /** + * The last year that is visible. + * + * @see [firstVisibleYear] + */ + public val lastVisibleYear: CalendarYear by derivedStateOf { + store[listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0] + } + + /** + * The object of [CalendarLayoutInfo] calculated during the last layout pass. For example, + * you can use it to calculate what items are currently visible. + * + * Note that this property is observable and is updated after every scroll or remeasure. + * If you use it in the composable function it will be recomposed on every change causing + * potential performance issues including infinity recomposition loop. + * Therefore, avoid using it in the composition. + * + * If you need to use it in the composition then consider wrapping the calculation into a + * derived state in order to only have recompositions when the derived value changes. + * See Example6Page in the sample app for usage. + * + * If you want to run some side effects like sending an analytics event or updating a state + * based on this value consider using "snapshotFlow". + * + * see [LazyListLayoutInfo] + */ + public val layoutInfo: YearCalendarLayoutInfo + get() = YearCalendarLayoutInfo(listState.layoutInfo) { index -> store[index] } + + /** + * [InteractionSource] that will be used to dispatch drag events when this + * calendar is being dragged. If you want to know whether the fling (or animated scroll) is in + * progress, use [isScrollInProgress]. + */ + public val interactionSource: InteractionSource + get() = listState.interactionSource + + internal val listState = LazyListState( + firstVisibleItemIndex = visibleItemState?.firstVisibleItemIndex + ?: getScrollIndex(firstVisibleYear) ?: 0, + firstVisibleItemScrollOffset = visibleItemState?.firstVisibleItemScrollOffset ?: 0, + ) + + internal val placementInfo = YearItemPlacementInfo() + + internal var calendarInfo by mutableStateOf(CalendarInfo(indexCount = 0)) + + internal val store = DataStore { offset -> + getCalendarYearData( + startYear = this.startYear, + offset = offset, + firstDayOfWeek = this.firstDayOfWeek, + outDateStyle = this.outDateStyle, + ) + } + + init { + yearDataChanged() // Update indexCount initially. + } + + private fun yearDataChanged() { + store.clear() + checkRange(startYear, endYear) + // Read the firstDayOfWeek and outDateStyle properties to ensure recomposition + // even though they are unused in the CalendarInfo. Alternatively, we could use + // mutableStateMapOf() as the backing store for DataStore() to ensure recomposition + // but not sure how compose handles recomposition of a lazy list that reads from + // such map when an entry unrelated to the visible indices changes. + calendarInfo = CalendarInfo( + indexCount = getYearIndicesCount(startYear, endYear), + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + } + + /** + * Instantly brings the [year] to the top of the viewport. + * + * @param year the year to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [animateScrollToYear] + */ + public suspend fun scrollToYear(year: Year) { + listState.scrollToItem(getScrollIndex(year) ?: return) + } + + /** + * Animate (smooth scroll) to the given [year]. + * + * @param year the year to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [scrollToYear] + */ + public suspend fun animateScrollToYear(year: Year) { + listState.animateScrollToItem(getScrollIndex(year) ?: return) + } + + /** + * Instantly brings the [month] to the top of the viewport. + * + * @param month the month to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [animateScrollToMonth] + */ + public suspend fun scrollToMonth(month: YearMonth): Unit = + scrollToMonth(month, animate = false) + + /** + * Animate (smooth scroll) to the given [month]. + * + * @param month the month to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [scrollToMonth] + */ + public suspend fun animateScrollToMonth(month: YearMonth): Unit = + scrollToMonth(month, animate = true) + + /** + * Instantly brings the [date] to the top of the viewport. + * + * @param date the date to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * @param position the position of the date in the month. + * + * @see [animateScrollToDate] + */ + public suspend fun scrollToDate( + date: LocalDate, + position: DayPosition = DayPosition.MonthDate, + ): Unit = scrollToDay(CalendarDay(date, position)) + + /** + * Animate (smooth scroll) to the given [date]. + * + * @param date the date to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * @param position the position of the date in the month. + * + * @see [scrollToDate] + */ + public suspend fun animateScrollToDate( + date: LocalDate, + position: DayPosition = DayPosition.MonthDate, + ): Unit = animateScrollToDay(CalendarDay(date, position)) + + /** + * Instantly brings the [day] to the top of the viewport. + * + * @param day the day to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [animateScrollToDay] + */ + public suspend fun scrollToDay(day: CalendarDay): Unit = + scrollToDay(day, animate = false) + + /** + * Animate (smooth scroll) to the given [day]. + * + * @param day the day to which to scroll. Must be within the + * range of [startYear] and [endYear]. + * + * @see [scrollToDay] + */ + public suspend fun animateScrollToDay(day: CalendarDay): Unit = + scrollToDay(day, animate = true) + + private suspend fun scrollToDay(day: CalendarDay, animate: Boolean) { + val yearMonth = day.positionYearMonth + val yearIndex = getScrollIndex(Year(yearMonth.year)) ?: return + val year = store[yearIndex] + val visibleMonths = placementInfo.isMonthVisible.apply(year.months) + val monthIndex = visibleMonths.indexOfFirstOrNull { it.yearMonth == yearMonth } ?: return + val weeksOfMonth = visibleMonths[monthIndex].weekDays + val dayIndex = when (layoutInfo.orientation) { + Orientation.Vertical -> weeksOfMonth.indexOfFirstOrNull { it.contains(day) } + Orientation.Horizontal -> firstDayOfWeek.daysUntil(day.date.dayOfWeek) + } ?: return + val monthDayInfo = placementInfo.awaitFistMonthDayOffsetAndSize(layoutInfo.orientation) ?: return + val monthGridOffset = monthDayInfo.monthGridOffset( + monthIndex = monthIndex, + columnCount = placementInfo.monthColumns, + visibleMonths = visibleMonths, + ) + val scrollOffset = monthGridOffset + + monthDayInfo.monthOffsetInContainer + + monthDayInfo.dayOffsetInMonth + + (monthDayInfo.daySize * dayIndex) + if (animate) { + listState.animateScrollToItem(yearIndex, scrollOffset) + } else { + listState.scrollToItem(yearIndex, scrollOffset) + } + } + + private suspend fun scrollToMonth(yearMonth: YearMonth, animate: Boolean) { + val yearIndex = getScrollIndex(Year(yearMonth.year)) ?: return + val year = store[yearIndex] + val visibleMonths = placementInfo.isMonthVisible.apply(year.months) + val monthIndex = visibleMonths.indexOfFirstOrNull { + it.yearMonth == yearMonth + } ?: return + val monthDayInfo = placementInfo.awaitFistMonthDayOffsetAndSize(layoutInfo.orientation) ?: return + val monthGridOffset = monthDayInfo.monthGridOffset( + monthIndex = monthIndex, + columnCount = placementInfo.monthColumns, + visibleMonths = visibleMonths, + ) + val scrollOffset = monthGridOffset + monthDayInfo.monthOffsetInContainer + if (animate) { + listState.animateScrollToItem(yearIndex, scrollOffset) + } else { + listState.scrollToItem(yearIndex, scrollOffset) + } + } + + private fun YearItemPlacementInfo.OffsetSize.monthGridOffset( + monthIndex: Int, + columnCount: Int, + visibleMonths: List, + ): Int { + val (monthRow, monthColumn) = rowColumn( + monthIndex = monthIndex, + monthColumns = columnCount, + ) + val orientation = layoutInfo.orientation + val isUniformOrientationGrid = when (orientation) { + Orientation.Vertical -> when (placementInfo.contentHeightMode) { + // Variable month height, we need to do some more work. + YearContentHeightMode.Wrap -> false + // Equal month height, we can multiply reliably. + YearContentHeightMode.Fill, + YearContentHeightMode.Stretch, + -> true + } + + // Equal month width, we can multiply reliably. + Orientation.Horizontal -> true + } + val spacingMultiplier = when (orientation) { + Orientation.Vertical -> monthRow + Orientation.Horizontal -> monthColumn + } + return if (isUniformOrientationGrid) { + spacingMultiplier * (monthSize + monthSpacing) + } else { + val offset = visibleMonths + .take(monthIndex + 1) // remove non relevant rows + .chunked(columnCount) + .dropLast(1) // remove the month row + .sumOf { row -> + row.maxOf { + // dayBodyCount * daySize is fine because this is only relevant + // in vertical wrap mode so the extra space will be the month + // decorations (header, week day name, footer) as the month + // container is not stretched in this case. + val monthSizeWithoutDays = monthSize - (dayBodyCount * daySize) + monthSizeWithoutDays + (it.weekDays.count() * daySize) + } + } + offset + (spacingMultiplier * monthSpacing) + } + } + + private fun getScrollIndex(year: Year): Int? { + if (year !in startYear..endYear) { + log("YearCalendarState", "Attempting to scroll out of range: $year") + return null + } + return getYearIndex(startYear, year) + } + + /** + * Whether this [ScrollableState] is currently scrolling by gesture, fling or programmatically. + */ + override val isScrollInProgress: Boolean + get() = listState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = listState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit, + ): Unit = listState.scroll(scrollPriority, block) + + public companion object { + internal val Saver: Saver = listSaver( + save = { + listOf( + it.startYear.value, + it.endYear.value, + it.firstVisibleYear.year.value, + it.firstDayOfWeek.ordinal, + it.outDateStyle.ordinal, + it.listState.firstVisibleItemIndex, + it.listState.firstVisibleItemScrollOffset, + ) + }, + restore = { + YearCalendarState( + startYear = Year(it[0]), + endYear = Year(it[1]), + firstVisibleYear = Year(it[2]), + firstDayOfWeek = DayOfWeek.entries[it[3]], + outDateStyle = OutDateStyle.entries[it[4]], + visibleItemState = VisibleItemState( + firstVisibleItemIndex = it[5], + firstVisibleItemScrollOffset = it[6], + ), + ) + }, + ) + } +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearContentHeightMode.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearContentHeightMode.kt new file mode 100644 index 00000000..0449ccfa --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearContentHeightMode.kt @@ -0,0 +1,37 @@ +package com.zomato.sushi.compose.components.calender.yearcalendar + +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.ui.Modifier + +/** + * Determines how the height of the month content is calculated. + */ +public enum class YearContentHeightMode { + /** + * The calendar months and days will wrap content height. This allows + * you to use [Modifier.aspectRatio] if you want square day content + * or [Modifier.height] if you want a specific height value + * for the day content. + */ + Wrap, + + /** + * The calendar months will be distributed uniformly to fill the + * parent's height. However, the days within the calendar months will + * wrap content height. This allows you to spread the calendar months + * evenly across the screen while using [Modifier.aspectRatio] if you + * want square day content or [Modifier.height] if you want a specific + * height value for the day content. + */ + Fill, + + /** + * The calendar months and days will uniformly stretch to fill the + * parent's height. This allows you to use [Modifier.fillMaxHeight] for + * the day content height. With this option, your Calendar composable should + * also be created with [Modifier.fillMaxHeight] or [Modifier.height]. + */ + Stretch, +} diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearItemPlacementInfo.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearItemPlacementInfo.kt new file mode 100644 index 00000000..5b70b3e2 --- /dev/null +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/components/calender/yearcalendar/YearItemPlacementInfo.kt @@ -0,0 +1,81 @@ +package com.zomato.sushi.compose.components.calender.yearcalendar + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.round +import com.zomato.sushi.compose.components.calender.core.CalendarMonth +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive + +@Immutable +internal data class YearItemCoordinates( + val firstMonth: CalendarMonth, + val itemRootCoordinates: LayoutCoordinates, + val firstMonthCoordinates: LayoutCoordinates, + val firstDayCoordinates: LayoutCoordinates, +) + +@Stable +internal class YearItemPlacementInfo { + private var itemCoordinates: YearItemCoordinates? = null + + internal var isMonthVisible: ((month: CalendarMonth) -> Boolean)? = null + internal var monthVerticalSpacingPx = 0 + internal var monthHorizontalSpacingPx = 0 + internal var monthColumns = 0 + internal var contentHeightMode = YearContentHeightMode.Wrap + + fun onItemPlaced(itemCoordinates: YearItemCoordinates) { + this.itemCoordinates = itemCoordinates + } + + suspend fun awaitFistMonthDayOffsetAndSize(orientation: Orientation): OffsetSize? { + var itemCoordinates = this.itemCoordinates + while (currentCoroutineContext().isActive && itemCoordinates == null) { + withFrameNanos {} + itemCoordinates = this.itemCoordinates + } + if (itemCoordinates == null) { + return null + } + val (firstMonth, itemRootCoordinates, firstMonthCoordinates, firstDayCoordinates) = itemCoordinates + val daySize = firstDayCoordinates.size + val monthOffset = itemRootCoordinates.localPositionOf(firstMonthCoordinates).round() + val dayOffsetInMonth = firstMonthCoordinates.localPositionOf(firstDayCoordinates).round() + val monthSize = firstMonthCoordinates.size + return when (orientation) { + Orientation.Vertical -> OffsetSize( + monthOffsetInContainer = monthOffset.y, + monthSize = monthSize.height, + monthSpacing = monthVerticalSpacingPx, + dayOffsetInMonth = dayOffsetInMonth.y, + daySize = daySize.height, + dayBodyCount = firstMonth.weekDays.size, + ) + + Orientation.Horizontal -> { + OffsetSize( + monthOffsetInContainer = monthOffset.x, + monthSize = monthSize.width, + monthSpacing = monthHorizontalSpacingPx, + dayOffsetInMonth = dayOffsetInMonth.x, + daySize = daySize.width, + dayBodyCount = firstMonth.weekDays.first().size, + ) + } + } + } + + @Immutable + internal data class OffsetSize( + val monthSize: Int, + val monthOffsetInContainer: Int, + val monthSpacing: Int, + val dayOffsetInMonth: Int, + val daySize: Int, + val dayBodyCount: Int, + ) +} diff --git a/sushi/sushi-compose/src/desktopMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.desktop.kt b/sushi/sushi-compose/src/desktopMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.desktop.kt new file mode 100644 index 00000000..7b3637a8 --- /dev/null +++ b/sushi/sushi-compose/src/desktopMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.desktop.kt @@ -0,0 +1,11 @@ +package com.zomato.sushi.compose.components.calender.data + +import java.util.logging.Level +import java.util.logging.Logger + +internal actual fun log(tag: String, message: String) = + logger.warning("$tag : $message") + +private val logger = Logger.getLogger("Calendar").apply { + level = Level.WARNING +} diff --git a/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.ios.kt b/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.ios.kt new file mode 100644 index 00000000..3a09300e --- /dev/null +++ b/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/core/Extensions.ios.kt @@ -0,0 +1,21 @@ +package com.zomato.sushi.compose.components.calender.core + +import androidx.compose.ui.text.intl.Locale +import kotlinx.datetime.DayOfWeek +import platform.Foundation.NSCalendar +import platform.Foundation.NSLocale +import kotlin.collections.get + +/** + * Returns the first day of the week from the provided locale. + */ +public actual fun firstDayOfWeekFromLocale(locale: Locale): DayOfWeek { + val firstWeekday = NSCalendar.currentCalendar.let { + it.setLocale(NSLocale(locale.toLanguageTag())) + // https://developer.apple.com/documentation/foundation/calendar/2293656-firstweekday + // Value is one-based, starting from sunday + it.firstWeekday.toInt() + } + // Get the index value from a sunday-based array. + return daysOfWeek(firstDayOfWeek = DayOfWeek.SUNDAY)[firstWeekday - 1] +} diff --git a/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.ios.kt b/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.ios.kt new file mode 100644 index 00000000..4377b669 --- /dev/null +++ b/sushi/sushi-compose/src/iosMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.ios.kt @@ -0,0 +1,6 @@ +package com.zomato.sushi.compose.components.calender.data + +import platform.Foundation.NSLog + +internal actual fun log(tag: String, message: String) = + NSLog("$tag : $message") \ No newline at end of file diff --git a/sushi/sushi-compose/src/jsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.js.kt b/sushi/sushi-compose/src/jsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.js.kt new file mode 100644 index 00000000..c310eea8 --- /dev/null +++ b/sushi/sushi-compose/src/jsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.js.kt @@ -0,0 +1,4 @@ +package com.zomato.sushi.compose.components.calender.data + +internal actual fun log(tag: String, message: String) = + console.log("$tag : $message") \ No newline at end of file diff --git a/sushi/sushi-compose/src/wasmJsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.wasmJs.kt b/sushi/sushi-compose/src/wasmJsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.wasmJs.kt new file mode 100644 index 00000000..30fcd983 --- /dev/null +++ b/sushi/sushi-compose/src/wasmJsMain/kotlin/com/zomato/sushi/compose/components/calender/data/Utils.wasmJs.kt @@ -0,0 +1,7 @@ +package com.zomato.sushi.compose.components.calender.data + +internal actual fun log(tag: String, message: String) = + consoleLog("$tag : $message") + +@JsFun("(output) => console.log(output)") +private external fun consoleLog(vararg output: String?) \ No newline at end of file From 93f53e3f85af2565b04a835a5f7a7477eb9dd6a4 Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Tue, 17 Mar 2026 12:19:02 +0530 Subject: [PATCH 2/6] background modifiers for SushiGradientColorSpec --- .../atoms/color/SushiGradientColorSpec.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt index f7476ae6..2ff848ec 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt @@ -312,6 +312,50 @@ fun List.asSushiGradientColorSpec(): SushiGradientColorSpec { return SushiGradientColorSpec(this.toPersistentList()) } + +/** + * Modifier that applies [SushiGradientColorSpec] as background. + * + * @param gradient the gradient to apply + * @param defaultTileMode The default tile mode to use if not specified in the gradient type + * @param defaultGradientType The default gradient type to use if not specified in the SushiGradientColorData + * @return A modifier that applies the gradient as background + */ +@Composable +fun Modifier.background( + gradient: SushiGradientColorSpec, + defaultTileMode: TileMode = TileMode.Clamp, + defaultGradientType: SushiGradientColorSpec.GradientType = SushiGradientColorSpec.GradientType.Linear(defaultLinearDirection) +): Modifier { + return this.background(gradient.toBrush()) +} + +/** + * Modifier that applies a list of [SushiGradientColorSpec] as layered backgrounds. + * + * Gradients are applied from first (bottom-most layer) to last (top-most layer), matching + * the intuitive "painter's order" — the last element in the list visually appears on top. + * + * **Note**: the list size must be stable across recompositions. Changing the number of + * gradients will reset the composition slots held by each [toBrush] call. + * + * @param gradients the ordered list of gradients to layer; returns unchanged modifier if empty + * @param defaultTileMode The default tile mode to use if not specified in the gradient type + * @param defaultGradientType The default gradient type to use if not specified in the gradient + * @return A modifier that applies all gradients as stacked backgrounds + */ +@Composable +fun Modifier.background( + gradients: List, + defaultTileMode: TileMode = TileMode.Clamp, + defaultGradientType: SushiGradientColorSpec.GradientType = SushiGradientColorSpec.GradientType.Linear(defaultLinearDirection) +): Modifier { + if (gradients.isEmpty()) return this + return gradients.fold(this) { acc, gradient -> + acc.background(gradient.toBrush(defaultTileMode, defaultGradientType)) + } +} + @SushiPreview @Composable private fun SushiGradientPreview() { From 49a6d76799fdfc1d4665ae56c4790c3a7455b6fc Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Tue, 17 Mar 2026 23:32:51 +0530 Subject: [PATCH 3/6] enhance gradient background modifier --- .../compose/atoms/color/SushiGradientColorSpec.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt index 2ff848ec..dcdc2d8e 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt @@ -1,5 +1,6 @@ package com.zomato.sushi.compose.atoms.color +import androidx.annotation.FloatRange import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,6 +13,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.unit.dp import com.zomato.sushi.compose.atoms.color.SushiGradientColorSpec.GradientType @@ -317,6 +320,8 @@ fun List.asSushiGradientColorSpec(): SushiGradientColorSpec { * Modifier that applies [SushiGradientColorSpec] as background. * * @param gradient the gradient to apply + * @param shape the shape to apply + * @param alpha the alpha value to apply * @param defaultTileMode The default tile mode to use if not specified in the gradient type * @param defaultGradientType The default gradient type to use if not specified in the SushiGradientColorData * @return A modifier that applies the gradient as background @@ -324,10 +329,18 @@ fun List.asSushiGradientColorSpec(): SushiGradientColorSpec { @Composable fun Modifier.background( gradient: SushiGradientColorSpec, + shape: Shape = RectangleShape, + @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, defaultTileMode: TileMode = TileMode.Clamp, defaultGradientType: SushiGradientColorSpec.GradientType = SushiGradientColorSpec.GradientType.Linear(defaultLinearDirection) ): Modifier { - return this.background(gradient.toBrush()) + return this.background( + gradient.toBrush( + defaultTileMode = defaultTileMode, + defaultGradientType = defaultGradientType + ), + shape = shape, alpha = alpha + ) } /** From 6fa2ce33c0cbd53360bd31a7f2b0f565b159e583 Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Tue, 17 Mar 2026 23:33:10 +0530 Subject: [PATCH 4/6] add: foreground gradient modifier --- .../atoms/color/SushiGradientColorSpec.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt index dcdc2d8e..ee601942 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/color/SushiGradientColorSpec.kt @@ -31,6 +31,7 @@ import com.zomato.sushi.compose.foundation.ThemedProps import com.zomato.sushi.compose.foundation.ThemedPropsProvider import com.zomato.sushi.compose.foundation.getThemedProps import com.zomato.sushi.compose.internal.SushiPreview +import com.zomato.sushi.compose.modifiers.foreground.foreground import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf @@ -315,6 +316,33 @@ fun List.asSushiGradientColorSpec(): SushiGradientColorSpec { return SushiGradientColorSpec(this.toPersistentList()) } +/** + * Modifier that applies [SushiGradientColorSpec] as foreground. + * + * @param gradient the gradient to apply + * @param shape the shape to apply + * @param alpha the alpha value to apply + * @param defaultTileMode The default tile mode to use if not specified in the gradient type + * @param defaultGradientType The default gradient type to use if not specified in the SushiGradientColorData + * @return A modifier that applies the gradient as background + */ +@Composable +fun Modifier.foreground( + gradient: SushiGradientColorSpec, + shape: Shape = RectangleShape, + @FloatRange(from = 0.0, to = 1.0) alpha: Float = 1.0f, + defaultTileMode: TileMode = TileMode.Clamp, + defaultGradientType: SushiGradientColorSpec.GradientType = SushiGradientColorSpec.GradientType.Linear(defaultLinearDirection) +): Modifier { + return this.foreground( + gradient.toBrush( + defaultTileMode = defaultTileMode, + defaultGradientType = defaultGradientType + ), + shape = shape, alpha = alpha + ) +} + /** * Modifier that applies [SushiGradientColorSpec] as background. From 2b2cfadf64f27c5d901b4b901cb81407a2b72cff Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Wed, 25 Mar 2026 13:24:49 +0530 Subject: [PATCH 5/6] add: rotation in SushiImage --- .../kotlin/com/zomato/sushi/compose/atoms/image/SushiImage.kt | 2 ++ .../com/zomato/sushi/compose/atoms/image/SushiImageProps.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImage.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImage.kt index 8dc2f0e5..62c98cbf 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImage.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImage.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.ColorFilter @@ -98,6 +99,7 @@ private fun SushiImageImpl( contentDescription, modifier .ifNonNull(onClick) { this.clickable(onClick = it) } + .ifNonNull(props.rotation) { this.rotate(it) } .ifNonNull(border) { this.border(it) } .ifNonNull(props.shape) { this.clip(it) } .ifNonNull(height) { this.height(it) } diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImageProps.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImageProps.kt index 7c6e2d5b..90605703 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImageProps.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/image/SushiImageProps.kt @@ -29,6 +29,7 @@ import com.zomato.sushi.compose.atoms.color.ColorSpec * @property scaleFactor Additional scaling factor applied to the image size * @property alignment How the image should be aligned within its bounds * @property colorFilter Optional filter to apply color transformations to the image + * @property rotation Rotation angle in degrees for the image (from the center) in clockwise rotation (Negative degrees also supported). * * @author gupta.anirudh@zomato.com */ @@ -47,4 +48,5 @@ data class SushiImageProps( val scaleFactor: Float? = null, val alignment: Alignment? = null, val colorFilter: ColorFilter? = null, + val rotation: Float? = null ) \ No newline at end of file From b66a1e1136c2691c76966ec18515618ebb29112b Mon Sep 17 00:00:00 2001 From: Anirudh Gupta Date: Tue, 31 Mar 2026 11:03:43 +0530 Subject: [PATCH 6/6] smooth indicators --- .../atoms/indicators/SushiIndicator.kt | 1 + .../atoms/indicators/type/ShiftIndicator.kt | 79 ++++++++++++++++++- .../indicators/type/SushiIndicatorType.kt | 4 +- 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/SushiIndicator.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/SushiIndicator.kt index 8b9ff6e3..3304a18b 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/SushiIndicator.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/SushiIndicator.kt @@ -125,6 +125,7 @@ fun SushiIndicator( dotSpacing = dotSpacing, onDotClicked = onDotClicked, modifier = Modifier, + selectedDotsGraphic = type.selectedDotGraphic, dotsGraphic = type.dotsGraphic, shiftSizeFactor = type.shiftSizeFactor, currentFillProgressProvider = type.currentFillProgressProvider, diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/ShiftIndicator.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/ShiftIndicator.kt index 6e620607..c19efe81 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/ShiftIndicator.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/ShiftIndicator.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import com.zomato.sushi.compose.atoms.indicators.Dot import com.zomato.sushi.compose.atoms.indicators.model.DotGraphic +import kotlin.math.abs import kotlin.math.absoluteValue /** @@ -37,6 +39,7 @@ internal fun ShiftIndicator( dotSpacing: Dp, onDotClicked: ((Int) -> Unit)?, modifier: Modifier = Modifier, + selectedDotsGraphic: DotGraphic? = null, dotsGraphic: DotGraphic = DotGraphic(), shiftSizeFactor: Float = 3f, currentFillProgressProvider: (() -> Float)? = null, @@ -62,8 +65,29 @@ internal fun ShiftIndicator( computeDotWidth(dotIndex, offsetProvider(), dotsGraphic, shiftSizeFactor) } } + val finalDotGraphic = when { +// selectedDotsGraphic != null && selectedDotsGraphic != dotsGraphic -> { +// remember { +// derivedStateOf { +// interpolateDotGraphic( +// selected = selectedDotsGraphic, +// unselected = dotsGraphic, +// currentDotIndex = currentDotIndex, +// globalOffset = offsetProvider() +// ) +// } +// }.value +// } + currentDotIndex == dotIndex -> { + selectedDotsGraphic ?: dotsGraphic + } + else -> { + dotsGraphic + } + } Box { - Dot(dotsGraphic, + Dot( + finalDotGraphic, Modifier .clip(dotsGraphic.shape) .drawWithContent { @@ -104,6 +128,9 @@ internal fun ShiftIndicator( } } } + .graphicsLayer { + alpha = computeAlpha(dotIndex, offsetProvider(), minAlpha = 0.8f) + } .width(dotWidth) .clickable { onDotClicked?.invoke(dotIndex) @@ -124,4 +151,54 @@ private fun computeDotWidth( val diffFactor = 1f - (currentDotIndex - globalOffset).absoluteValue.coerceAtMost(1f) val widthToAdd = ((shiftSizeFactor - 1f).coerceAtLeast(0f) * dotsGraphic.size * diffFactor) return dotsGraphic.size + widthToAdd +} + +private fun computeAlpha( + currentDotIndex: Int, + globalOffset: Float, + minAlpha: Float +): Float { + return minAlpha + (1f - minAlpha) * (1f - abs(globalOffset - currentDotIndex).coerceIn(0f, 1f)) +} + +private fun interpolateDotGraphic( + selected: DotGraphic, + unselected: DotGraphic, + currentDotIndex: Int, + globalOffset: Float +): DotGraphic { + val distance = abs(globalOffset - currentDotIndex) + val t = 1f - distance.coerceIn(0f, 1f) + + return DotGraphic( + size = lerpDp(unselected.size, selected.size, t), + color = lerpColor(unselected.color, selected.color, t), + shape = if (t > 0.5f) selected.shape else unselected.shape, + borderWidth = lerpDpOrNull(unselected.borderWidth, selected.borderWidth, t), + borderColor = lerpColor(unselected.borderColor, selected.borderColor, t) + ) +} + +private fun lerpDp(start: Dp, end: Dp, t: Float): Dp { + return (start.value + (end.value - start.value) * t).dp +} + +private fun lerpDpOrNull(start: Dp?, end: Dp?, t: Float): Dp? { + if (start == null && end == null) return null + val s = start ?: 0.dp + val e = end ?: 0.dp + return lerpDp(s, e, t) +} + +private fun lerpColor(start: Color, end: Color, t: Float): Color { + return Color( + red = lerp(start.red, end.red, t), + green = lerp(start.green, end.green, t), + blue = lerp(start.blue, end.blue, t), + alpha = lerp(start.alpha, end.alpha, t), + ) +} + +private fun lerp(start: Float, end: Float, t: Float): Float { + return start + (end - start) * t } \ No newline at end of file diff --git a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/SushiIndicatorType.kt b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/SushiIndicatorType.kt index 9fd28083..27fc1332 100644 --- a/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/SushiIndicatorType.kt +++ b/sushi/sushi-compose/src/commonMain/kotlin/com/zomato/sushi/compose/atoms/indicators/type/SushiIndicatorType.kt @@ -34,6 +34,7 @@ sealed interface SushiIndicatorType { * form as it becomes inactive. * * @property dotsGraphic The appearance configuration for the dots + * @property selectedDotGraphic The appearance configuration for the selected dot ([dotsGraphic] used if this is null) * @property shiftSizeFactor The maximum width multiplication factor for the active dot * @property currentFillProgressProvider A function that provides the current fill * progress, which will be used to animate the progress inside the active dot @@ -41,9 +42,10 @@ sealed interface SushiIndicatorType { */ data class Shift( val dotsGraphic: DotGraphic = DotGraphic(), + val selectedDotGraphic: DotGraphic? = null, val shiftSizeFactor: Float = 3f, val currentFillProgressProvider: (() -> Float)? = null, - val fillProgressColor: Color = Color.Unspecified + val fillProgressColor: Color = Color.Unspecified, ) : SushiIndicatorType /**