diff --git a/.editorconfig b/.editorconfig index d9e3ecad..7efbeee7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,6 +13,9 @@ ktlint = disabled ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma_on_call_site = true ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_chain-method-continuation = disabled +ktlint_standard_class-signature = disabled # string-template-indent requires multiline-expression-wrapping to be enabled ktlint_standard_string-template-indent = disabled ktlint_standard_parameter-list-wrapping = disabled diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fd8ba9f2..a02fb9ec 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,7 +52,7 @@ jobs: with: gradle-home-cache-cleanup: true - name: Unit tests - run: ./gradlew testDebugUnitTest + run: ./gradlew test instrumentation-tests: name: Instrumentation tests diff --git a/README.md b/README.md index 4fb89cce..e415d391 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ A highly customizable calendar library for Android and Compose Multiplatform, ba ## Features -- [x] Single, multiple or range selection - Total flexibility to implement the date selection +- [x] Week, month, or year modes - Show a week-based calendar, or the typical month calendar, or a year-based calendar. +- [x] Single, multiple, or range selection - Total flexibility to implement the date selection whichever way you like. -- [x] Week or month mode - Show a week-based calendar, or the typical month calendar. - [x] Disable desired dates - Prevent selection of some dates by disabling them. - [x] Boundary dates - Limit the calendar date range. - [x] Custom date view/composable - Make your day cells look however you want, with any @@ -28,8 +28,8 @@ A highly customizable calendar library for Android and Compose Multiplatform, ba - [x] Horizontal or vertical scrolling calendar. - [x] HeatMap calendar - Suitable for showing how data changes over time, like GitHub's contribution chart. -- [x] Month/Week headers and footers - Add headers/footers of any kind on each month/week. -- [x] Easily scroll to any date/week/month on the calendar via swipe actions or programmatically. +- [x] Year/Month/Week headers and footers - Add headers/footers of any kind on each year/month/week. +- [x] Easily scroll to any date/week/month/year on the calendar via swipe actions or programmatically. - [x] Use all RecyclerView/LazyRow/LazyColumn customizations since the calendar extends from RecyclerView for the view system and uses LazyRow/LazyColumn for compose. - [x] Design your calendar [however you want.](https://github.com/kizitonwose/Calendar/issues/1) The @@ -124,12 +124,12 @@ For the compose calendar library, ensure that you are using the library version | Compose UI | Android Calendar Library | Multiplatform Calendar Library | |:----------:|:------------------------:|:------------------------------:| -| 1.2.x | 2.0.x | - | -| 1.3.x | 2.1.x - 2.2.x | - | -| 1.4.x | 2.3.x | - | -| 1.5.x | 2.4.x | - | -| 1.6.x | 2.5.x | - | -| 1.7.x | 2.6.x | 2.6.x | +| 1.2.x | 2.0.x | - | +| 1.3.x | 2.1.x - 2.2.x | - | +| 1.4.x | 2.3.x | - | +| 1.5.x | 2.4.x | - | +| 1.6.x | 2.5.x | 2.5.x | +| 1.7.x | 2.6.x | 2.6.x | ## Usage diff --git a/build.gradle.kts b/build.gradle.kts index dd8a9e78..a7a1fbd3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,7 @@ plugins { alias(libs.plugins.mavenPublish) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.jetbrainsCompose) apply false + alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.versionCheck) alias(libs.plugins.bcv) } @@ -22,15 +23,15 @@ plugins { allprojects { apply(plugin = rootProject.libs.plugins.kotlinter.get().pluginId) - plugins.withType().configureEach { + plugins.withType { extensions.configure { if ("sample" !in project.name) { explicitApi() } } } - - tasks.withType().configureEach { + tasks.withType { + useJUnitPlatform() // https://docs.gradle.org/8.8/userguide/performance.html#execute_tests_in_parallel maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) } diff --git a/compose-multiplatform/library/api/android/library.api b/compose-multiplatform/library/api/android/library.api index cef6c873..8ad819cc 100644 --- a/compose-multiplatform/library/api/android/library.api +++ b/compose-multiplatform/library/api/android/library.api @@ -12,7 +12,9 @@ public final class com/kizitonwose/calendar/compose/CalendarItemInfo : androidx/ public final class com/kizitonwose/calendar/compose/CalendarKt { public static final fun HeatMapCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapWeekHeaderPosition;ZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun HorizontalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun HorizontalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun VerticalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun VerticalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun WeekCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V } @@ -179,6 +181,84 @@ public final class com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarSta public static final fun rememberWeekCalendarState (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Ljava/time/DayOfWeek;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState; } +public final class com/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt { + public static final field INSTANCE Lcom/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function5; + public static field lambda-2 Lkotlin/jvm/functions/Function5; + public static field lambda-3 Lkotlin/jvm/functions/Function5; + public static field lambda-4 Lkotlin/jvm/functions/Function5; + public fun ()V + public final fun getLambda-1$library_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-2$library_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-3$library_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-4$library_release ()Lkotlin/jvm/functions/Function5; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarItemInfo : androidx/compose/foundation/lazy/LazyListItemInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListItemInfo;Lcom/kizitonwose/calendar/core/CalendarYear;)V + public fun getContentType ()Ljava/lang/Object; + public fun getIndex ()I + public fun getKey ()Ljava/lang/Object; + public fun getOffset ()I + public fun getSize ()I + public final fun getYear ()Lcom/kizitonwose/calendar/core/CalendarYear; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo : androidx/compose/foundation/lazy/LazyListLayoutInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListLayoutInfo;Lkotlin/jvm/functions/Function1;)V + public fun getAfterContentPadding ()I + public fun getBeforeContentPadding ()I + public fun getMainAxisItemSpacing ()I + public fun getOrientation ()Landroidx/compose/foundation/gestures/Orientation; + public fun getReverseLayout ()Z + public fun getTotalItemsCount ()I + public fun getViewportEndOffset ()I + public fun getViewportSize-YbymL2g ()J + public fun getViewportStartOffset ()I + public fun getVisibleItemsInfo ()Ljava/util/List; + public final fun getVisibleYearsInfo ()Ljava/util/List; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState : androidx/compose/foundation/gestures/ScrollableState { + public static final field $stable I + public static final field Companion Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion; + public final fun animateScrollToYear (Lcom/kizitonwose/calendar/core/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun dispatchRawDelta (F)F + public final fun getEndYear ()Lcom/kizitonwose/calendar/core/Year; + public final fun getFirstDayOfWeek ()Ljava/time/DayOfWeek; + public final fun getFirstVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; + public final fun getLastVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getLayoutInfo ()Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo; + public final fun getOutDateStyle ()Lcom/kizitonwose/calendar/core/OutDateStyle; + public final fun getStartYear ()Lcom/kizitonwose/calendar/core/Year; + public fun isScrollInProgress ()Z + public fun scroll (Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun scrollToYear (Lcom/kizitonwose/calendar/core/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setEndYear (Lcom/kizitonwose/calendar/core/Year;)V + public final fun setFirstDayOfWeek (Ljava/time/DayOfWeek;)V + public final fun setOutDateStyle (Lcom/kizitonwose/calendar/core/OutDateStyle;)V + public final fun setStartYear (Lcom/kizitonwose/calendar/core/Year;)V +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion { +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarStateKt { + public static final fun rememberYearCalendarState (Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;Ljava/time/DayOfWeek;Lcom/kizitonwose/calendar/core/OutDateStyle;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode : java/lang/Enum { + public static final field Fill Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Stretch Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Wrap Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun values ()[Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; +} + public final class com/kizitonwose/calendar/core/CalendarDay { public static final field $stable I public fun (Lkotlinx/datetime/LocalDate;Lcom/kizitonwose/calendar/core/DayPosition;)V @@ -206,8 +286,24 @@ public final class com/kizitonwose/calendar/core/CalendarMonth { public fun toString ()Ljava/lang/String; } +public final class com/kizitonwose/calendar/core/CalendarYear { + public static final field $stable I + public fun (Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;)V + public final fun component1 ()Lcom/kizitonwose/calendar/core/Year; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;)Lcom/kizitonwose/calendar/core/CalendarYear; + public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/CalendarYear;Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/CalendarYear; + public fun equals (Ljava/lang/Object;)Z + public final fun getMonths ()Ljava/util/List; + public final fun getYear ()Lcom/kizitonwose/calendar/core/Year; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/kizitonwose/calendar/core/ConvertersKt { + public static final fun toJavaYear (Lcom/kizitonwose/calendar/core/Year;)Ljava/time/Year; public static final fun toJavaYearMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Ljava/time/YearMonth; + public static final fun toKotlinYear (Ljava/time/Year;)Lcom/kizitonwose/calendar/core/Year; public static final fun toKotlinYearMonth (Ljava/time/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; } @@ -220,12 +316,21 @@ public final class com/kizitonwose/calendar/core/DayPosition : java/lang/Enum { public static fun values ()[Lcom/kizitonwose/calendar/core/DayPosition; } +public abstract interface annotation class com/kizitonwose/calendar/core/ExperimentalCalendarApi : java/lang/annotation/Annotation { +} + public final class com/kizitonwose/calendar/core/ExtensionsKt { public static final fun daysOfWeek (Ljava/time/DayOfWeek;)Ljava/util/List; public static synthetic fun daysOfWeek$default (Ljava/time/DayOfWeek;ILjava/lang/Object;)Ljava/util/List; public static final fun getYearMonth (Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusDays (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun minusMonths (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun minusYears (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; public static final fun now (Lkotlinx/datetime/LocalDate$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/LocalDate; public static synthetic fun now$default (Lkotlinx/datetime/LocalDate$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lkotlinx/datetime/LocalDate; + public static final fun plusDays (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun plusMonths (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun plusYears (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; } public final class com/kizitonwose/calendar/core/Extensions_jvmKt { @@ -275,7 +380,43 @@ public final class com/kizitonwose/calendar/core/WeekDayPosition : java/lang/Enu public static fun values ()[Lcom/kizitonwose/calendar/core/WeekDayPosition; } -public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializable, java/lang/Comparable { +public final class com/kizitonwose/calendar/core/Year : java/lang/Comparable { + public static final field $stable I + public static final field Companion Lcom/kizitonwose/calendar/core/Year$Companion; + public fun (I)V + public fun compareTo (Lcom/kizitonwose/calendar/core/Year;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public final fun component1 ()I + public final fun copy (I)Lcom/kizitonwose/calendar/core/Year; + public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/Year;IILjava/lang/Object;)Lcom/kizitonwose/calendar/core/Year; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/kizitonwose/calendar/core/Year$Companion { + public final fun isLeap (I)Z + public final fun now (Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lcom/kizitonwose/calendar/core/Year; + public static synthetic fun now$default (Lcom/kizitonwose/calendar/core/Year$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/Year; + public final fun parseIso8601 (Ljava/lang/String;)Lcom/kizitonwose/calendar/core/Year; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/kizitonwose/calendar/core/YearKt { + public static final fun atDay (Lcom/kizitonwose/calendar/core/Year;I)Lkotlinx/datetime/LocalDate; + public static final fun atMonth (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun atMonth (Lcom/kizitonwose/calendar/core/Year;Ljava/time/Month;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun atMonthDay (Lcom/kizitonwose/calendar/core/Year;II)Lkotlinx/datetime/LocalDate; + public static final fun atMonthDay (Lcom/kizitonwose/calendar/core/Year;Ljava/time/Month;I)Lkotlinx/datetime/LocalDate; + public static final fun isLeap (Lcom/kizitonwose/calendar/core/Year;)Z + public static final fun length (Lcom/kizitonwose/calendar/core/Year;)I + public static final fun minusYears (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/Year; + public static final fun plusYears (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/Year; + public static final fun yearsUntil (Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;)I +} + +public final class com/kizitonwose/calendar/core/YearMonth : java/lang/Comparable { public static final field $stable I public static final field Companion Lcom/kizitonwose/calendar/core/YearMonth$Companion; public fun (II)V @@ -288,6 +429,7 @@ public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializabl public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/YearMonth;ILjava/time/Month;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/YearMonth; public fun equals (Ljava/lang/Object;)Z public final fun getMonth ()Ljava/time/Month; + public final fun getMonthNumber ()I public final fun getYear ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -296,48 +438,61 @@ public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializabl public final class com/kizitonwose/calendar/core/YearMonth$Companion { public final fun now (Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lcom/kizitonwose/calendar/core/YearMonth; public static synthetic fun now$default (Lcom/kizitonwose/calendar/core/YearMonth$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/YearMonth; + public final fun parseIso8601 (Ljava/lang/String;)Lcom/kizitonwose/calendar/core/YearMonth; + public final fun serializer ()Lkotlinx/serialization/KSerializer; } public final class com/kizitonwose/calendar/core/YearMonthKt { public static final fun atDay (Lcom/kizitonwose/calendar/core/YearMonth;I)Lkotlinx/datetime/LocalDate; public static final fun atEndOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Lkotlinx/datetime/LocalDate; public static final fun atStartOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Lkotlinx/datetime/LocalDate; - public static final fun getNext (Lcom/kizitonwose/calendar/core/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; - public static final fun getPrevious (Lcom/kizitonwose/calendar/core/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; public static final fun lengthOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)I public static final fun minus (Lcom/kizitonwose/calendar/core/YearMonth;ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusMonths (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusYears (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; public static final fun monthsUntil (Lcom/kizitonwose/calendar/core/YearMonth;Lcom/kizitonwose/calendar/core/YearMonth;)I public static final fun plus (Lcom/kizitonwose/calendar/core/YearMonth;ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun plusMonths (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun plusYears (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; } -public final class com/kizitonwose/calendar/data/WeekData { +public final class com/kizitonwose/calendar/core/serializers/YearComponentSerializer : kotlinx/serialization/KSerializer { public static final field $stable I - public final fun copy (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekData; - public static synthetic fun copy$default (Lcom/kizitonwose/calendar/data/WeekData;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;ILjava/lang/Object;)Lcom/kizitonwose/calendar/data/WeekData; - public fun equals (Ljava/lang/Object;)Z - public final fun getWeek ()Lcom/kizitonwose/calendar/core/Week; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearComponentSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/Year; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/Year;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } -public final class com/kizitonwose/calendar/data/WeekDataKt { - public static final fun getWeekCalendarAdjustedRange (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Ljava/time/DayOfWeek;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public static final fun getWeekCalendarData (Lkotlinx/datetime/LocalDate;ILkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekData; - public static final fun getWeekIndex (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I - public static final fun getWeekIndicesCount (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I +public final class com/kizitonwose/calendar/core/serializers/YearIso8601Serializer : kotlinx/serialization/KSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearIso8601Serializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/Year; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/Year;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } -public final class com/kizitonwose/calendar/data/WeekDateRange { +public final class com/kizitonwose/calendar/core/serializers/YearMonthComponentSerializer : kotlinx/serialization/KSerializer { public static final field $stable I - public fun (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)V - public final fun component1 ()Lkotlinx/datetime/LocalDate; - public final fun component2 ()Lkotlinx/datetime/LocalDate; - public final fun copy (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public static synthetic fun copy$default (Lcom/kizitonwose/calendar/data/WeekDateRange;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;ILjava/lang/Object;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public fun equals (Ljava/lang/Object;)Z - public final fun getEndDateAdjusted ()Lkotlinx/datetime/LocalDate; - public final fun getStartDateAdjusted ()Lkotlinx/datetime/LocalDate; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearMonthComponentSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/YearMonth; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/YearMonth;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V +} + +public final class com/kizitonwose/calendar/core/serializers/YearMonthIso8601Serializer : kotlinx/serialization/KSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearMonthIso8601Serializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/YearMonth; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/YearMonth;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } diff --git a/compose-multiplatform/library/api/desktop/library.api b/compose-multiplatform/library/api/desktop/library.api index 362a1914..873fe1df 100644 --- a/compose-multiplatform/library/api/desktop/library.api +++ b/compose-multiplatform/library/api/desktop/library.api @@ -12,7 +12,9 @@ public final class com/kizitonwose/calendar/compose/CalendarItemInfo : androidx/ public final class com/kizitonwose/calendar/compose/CalendarKt { public static final fun HeatMapCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapWeekHeaderPosition;ZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun HorizontalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun HorizontalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun VerticalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun VerticalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun WeekCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V } @@ -179,6 +181,84 @@ public final class com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarSta public static final fun rememberWeekCalendarState (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Ljava/time/DayOfWeek;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState; } +public final class com/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt { + public static final field INSTANCE Lcom/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function5; + public static field lambda-2 Lkotlin/jvm/functions/Function5; + public static field lambda-3 Lkotlin/jvm/functions/Function5; + public static field lambda-4 Lkotlin/jvm/functions/Function5; + public fun ()V + public final fun getLambda-1$library ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-2$library ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-3$library ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-4$library ()Lkotlin/jvm/functions/Function5; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarItemInfo : androidx/compose/foundation/lazy/LazyListItemInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListItemInfo;Lcom/kizitonwose/calendar/core/CalendarYear;)V + public fun getContentType ()Ljava/lang/Object; + public fun getIndex ()I + public fun getKey ()Ljava/lang/Object; + public fun getOffset ()I + public fun getSize ()I + public final fun getYear ()Lcom/kizitonwose/calendar/core/CalendarYear; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo : androidx/compose/foundation/lazy/LazyListLayoutInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListLayoutInfo;Lkotlin/jvm/functions/Function1;)V + public fun getAfterContentPadding ()I + public fun getBeforeContentPadding ()I + public fun getMainAxisItemSpacing ()I + public fun getOrientation ()Landroidx/compose/foundation/gestures/Orientation; + public fun getReverseLayout ()Z + public fun getTotalItemsCount ()I + public fun getViewportEndOffset ()I + public fun getViewportSize-YbymL2g ()J + public fun getViewportStartOffset ()I + public fun getVisibleItemsInfo ()Ljava/util/List; + public final fun getVisibleYearsInfo ()Ljava/util/List; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState : androidx/compose/foundation/gestures/ScrollableState { + public static final field $stable I + public static final field Companion Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion; + public final fun animateScrollToYear (Lcom/kizitonwose/calendar/core/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun dispatchRawDelta (F)F + public final fun getEndYear ()Lcom/kizitonwose/calendar/core/Year; + public final fun getFirstDayOfWeek ()Ljava/time/DayOfWeek; + public final fun getFirstVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; + public final fun getLastVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getLayoutInfo ()Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo; + public final fun getOutDateStyle ()Lcom/kizitonwose/calendar/core/OutDateStyle; + public final fun getStartYear ()Lcom/kizitonwose/calendar/core/Year; + public fun isScrollInProgress ()Z + public fun scroll (Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun scrollToYear (Lcom/kizitonwose/calendar/core/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setEndYear (Lcom/kizitonwose/calendar/core/Year;)V + public final fun setFirstDayOfWeek (Ljava/time/DayOfWeek;)V + public final fun setOutDateStyle (Lcom/kizitonwose/calendar/core/OutDateStyle;)V + public final fun setStartYear (Lcom/kizitonwose/calendar/core/Year;)V +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion { +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarStateKt { + public static final fun rememberYearCalendarState (Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;Ljava/time/DayOfWeek;Lcom/kizitonwose/calendar/core/OutDateStyle;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode : java/lang/Enum { + public static final field Fill Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Stretch Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Wrap Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun values ()[Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; +} + public final class com/kizitonwose/calendar/core/CalendarDay { public static final field $stable I public fun (Lkotlinx/datetime/LocalDate;Lcom/kizitonwose/calendar/core/DayPosition;)V @@ -206,8 +286,24 @@ public final class com/kizitonwose/calendar/core/CalendarMonth { public fun toString ()Ljava/lang/String; } +public final class com/kizitonwose/calendar/core/CalendarYear { + public static final field $stable I + public fun (Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;)V + public final fun component1 ()Lcom/kizitonwose/calendar/core/Year; + public final fun component2 ()Ljava/util/List; + public final fun copy (Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;)Lcom/kizitonwose/calendar/core/CalendarYear; + public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/CalendarYear;Lcom/kizitonwose/calendar/core/Year;Ljava/util/List;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/CalendarYear; + public fun equals (Ljava/lang/Object;)Z + public final fun getMonths ()Ljava/util/List; + public final fun getYear ()Lcom/kizitonwose/calendar/core/Year; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/kizitonwose/calendar/core/ConvertersKt { + public static final fun toJavaYear (Lcom/kizitonwose/calendar/core/Year;)Ljava/time/Year; public static final fun toJavaYearMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Ljava/time/YearMonth; + public static final fun toKotlinYear (Ljava/time/Year;)Lcom/kizitonwose/calendar/core/Year; public static final fun toKotlinYearMonth (Ljava/time/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; } @@ -220,12 +316,21 @@ public final class com/kizitonwose/calendar/core/DayPosition : java/lang/Enum { public static fun values ()[Lcom/kizitonwose/calendar/core/DayPosition; } +public abstract interface annotation class com/kizitonwose/calendar/core/ExperimentalCalendarApi : java/lang/annotation/Annotation { +} + public final class com/kizitonwose/calendar/core/ExtensionsKt { public static final fun daysOfWeek (Ljava/time/DayOfWeek;)Ljava/util/List; public static synthetic fun daysOfWeek$default (Ljava/time/DayOfWeek;ILjava/lang/Object;)Ljava/util/List; public static final fun getYearMonth (Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusDays (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun minusMonths (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun minusYears (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; public static final fun now (Lkotlinx/datetime/LocalDate$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lkotlinx/datetime/LocalDate; public static synthetic fun now$default (Lkotlinx/datetime/LocalDate$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lkotlinx/datetime/LocalDate; + public static final fun plusDays (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun plusMonths (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; + public static final fun plusYears (Lkotlinx/datetime/LocalDate;I)Lkotlinx/datetime/LocalDate; } public final class com/kizitonwose/calendar/core/Extensions_jvmKt { @@ -275,7 +380,43 @@ public final class com/kizitonwose/calendar/core/WeekDayPosition : java/lang/Enu public static fun values ()[Lcom/kizitonwose/calendar/core/WeekDayPosition; } -public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializable, java/lang/Comparable { +public final class com/kizitonwose/calendar/core/Year : java/lang/Comparable { + public static final field $stable I + public static final field Companion Lcom/kizitonwose/calendar/core/Year$Companion; + public fun (I)V + public fun compareTo (Lcom/kizitonwose/calendar/core/Year;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public final fun component1 ()I + public final fun copy (I)Lcom/kizitonwose/calendar/core/Year; + public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/Year;IILjava/lang/Object;)Lcom/kizitonwose/calendar/core/Year; + public fun equals (Ljava/lang/Object;)Z + public final fun getValue ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/kizitonwose/calendar/core/Year$Companion { + public final fun isLeap (I)Z + public final fun now (Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lcom/kizitonwose/calendar/core/Year; + public static synthetic fun now$default (Lcom/kizitonwose/calendar/core/Year$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/Year; + public final fun parseIso8601 (Ljava/lang/String;)Lcom/kizitonwose/calendar/core/Year; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public final class com/kizitonwose/calendar/core/YearKt { + public static final fun atDay (Lcom/kizitonwose/calendar/core/Year;I)Lkotlinx/datetime/LocalDate; + public static final fun atMonth (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun atMonth (Lcom/kizitonwose/calendar/core/Year;Ljava/time/Month;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun atMonthDay (Lcom/kizitonwose/calendar/core/Year;II)Lkotlinx/datetime/LocalDate; + public static final fun atMonthDay (Lcom/kizitonwose/calendar/core/Year;Ljava/time/Month;I)Lkotlinx/datetime/LocalDate; + public static final fun isLeap (Lcom/kizitonwose/calendar/core/Year;)Z + public static final fun length (Lcom/kizitonwose/calendar/core/Year;)I + public static final fun minusYears (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/Year; + public static final fun plusYears (Lcom/kizitonwose/calendar/core/Year;I)Lcom/kizitonwose/calendar/core/Year; + public static final fun yearsUntil (Lcom/kizitonwose/calendar/core/Year;Lcom/kizitonwose/calendar/core/Year;)I +} + +public final class com/kizitonwose/calendar/core/YearMonth : java/lang/Comparable { public static final field $stable I public static final field Companion Lcom/kizitonwose/calendar/core/YearMonth$Companion; public fun (II)V @@ -288,6 +429,7 @@ public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializabl public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/YearMonth;ILjava/time/Month;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/YearMonth; public fun equals (Ljava/lang/Object;)Z public final fun getMonth ()Ljava/time/Month; + public final fun getMonthNumber ()I public final fun getYear ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -296,48 +438,61 @@ public final class com/kizitonwose/calendar/core/YearMonth : java/io/Serializabl public final class com/kizitonwose/calendar/core/YearMonth$Companion { public final fun now (Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;)Lcom/kizitonwose/calendar/core/YearMonth; public static synthetic fun now$default (Lcom/kizitonwose/calendar/core/YearMonth$Companion;Lkotlinx/datetime/Clock;Lkotlinx/datetime/TimeZone;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/YearMonth; + public final fun parseIso8601 (Ljava/lang/String;)Lcom/kizitonwose/calendar/core/YearMonth; + public final fun serializer ()Lkotlinx/serialization/KSerializer; } public final class com/kizitonwose/calendar/core/YearMonthKt { public static final fun atDay (Lcom/kizitonwose/calendar/core/YearMonth;I)Lkotlinx/datetime/LocalDate; public static final fun atEndOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Lkotlinx/datetime/LocalDate; public static final fun atStartOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)Lkotlinx/datetime/LocalDate; - public static final fun getNext (Lcom/kizitonwose/calendar/core/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; - public static final fun getPrevious (Lcom/kizitonwose/calendar/core/YearMonth;)Lcom/kizitonwose/calendar/core/YearMonth; public static final fun lengthOfMonth (Lcom/kizitonwose/calendar/core/YearMonth;)I public static final fun minus (Lcom/kizitonwose/calendar/core/YearMonth;ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusMonths (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun minusYears (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; public static final fun monthsUntil (Lcom/kizitonwose/calendar/core/YearMonth;Lcom/kizitonwose/calendar/core/YearMonth;)I public static final fun plus (Lcom/kizitonwose/calendar/core/YearMonth;ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun plusMonths (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; + public static final fun plusYears (Lcom/kizitonwose/calendar/core/YearMonth;I)Lcom/kizitonwose/calendar/core/YearMonth; } -public final class com/kizitonwose/calendar/data/WeekData { +public final class com/kizitonwose/calendar/core/serializers/YearComponentSerializer : kotlinx/serialization/KSerializer { public static final field $stable I - public final fun copy (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekData; - public static synthetic fun copy$default (Lcom/kizitonwose/calendar/data/WeekData;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;ILjava/lang/Object;)Lcom/kizitonwose/calendar/data/WeekData; - public fun equals (Ljava/lang/Object;)Z - public final fun getWeek ()Lcom/kizitonwose/calendar/core/Week; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearComponentSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/Year; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/Year;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } -public final class com/kizitonwose/calendar/data/WeekDataKt { - public static final fun getWeekCalendarAdjustedRange (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;Ljava/time/DayOfWeek;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public static final fun getWeekCalendarData (Lkotlinx/datetime/LocalDate;ILkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekData; - public static final fun getWeekIndex (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I - public static final fun getWeekIndicesCount (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)I +public final class com/kizitonwose/calendar/core/serializers/YearIso8601Serializer : kotlinx/serialization/KSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearIso8601Serializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/Year; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/Year;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } -public final class com/kizitonwose/calendar/data/WeekDateRange { +public final class com/kizitonwose/calendar/core/serializers/YearMonthComponentSerializer : kotlinx/serialization/KSerializer { public static final field $stable I - public fun (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)V - public final fun component1 ()Lkotlinx/datetime/LocalDate; - public final fun component2 ()Lkotlinx/datetime/LocalDate; - public final fun copy (Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public static synthetic fun copy$default (Lcom/kizitonwose/calendar/data/WeekDateRange;Lkotlinx/datetime/LocalDate;Lkotlinx/datetime/LocalDate;ILjava/lang/Object;)Lcom/kizitonwose/calendar/data/WeekDateRange; - public fun equals (Ljava/lang/Object;)Z - public final fun getEndDateAdjusted ()Lkotlinx/datetime/LocalDate; - public final fun getStartDateAdjusted ()Lkotlinx/datetime/LocalDate; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearMonthComponentSerializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/YearMonth; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/YearMonth;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V +} + +public final class com/kizitonwose/calendar/core/serializers/YearMonthIso8601Serializer : kotlinx/serialization/KSerializer { + public static final field $stable I + public static final field INSTANCE Lcom/kizitonwose/calendar/core/serializers/YearMonthIso8601Serializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kizitonwose/calendar/core/YearMonth; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kizitonwose/calendar/core/YearMonth;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V } diff --git a/compose-multiplatform/library/build.gradle.kts b/compose-multiplatform/library/build.gradle.kts index a13f535f..3515d712 100644 --- a/compose-multiplatform/library/build.gradle.kts +++ b/compose-multiplatform/library/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.androidLibrary) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.mavenPublish) } @@ -58,18 +59,25 @@ kotlin { implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + compileOnly(libs.kotlinx.serialization.core) api(libs.kotlinx.datetime) } val nonJvmMain by creating { dependsOn(commonMain) nativeMain.dependsOn(this) wasmJsMain.dependsOn(this) - dependencies {} + dependencies { + api(libs.kotlinx.serialization.core) + } } desktopMain.dependsOn(jvmMain) desktopMain.dependencies { implementation(compose.desktop.currentOs) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.serialization.json) + } } @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/Calendar.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/Calendar.kt index 981d03e9..7da01948 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/Calendar.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/Calendar.kt @@ -7,7 +7,9 @@ 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.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.kizitonwose.calendar.compose.CalendarDefaults.flingBehavior import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarImpl @@ -18,14 +20,20 @@ import com.kizitonwose.calendar.compose.heatmapcalendar.rememberHeatMapCalendarS import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarImpl import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarMonths +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.ExperimentalCalendarApi import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.WeekDay import kotlinx.datetime.DayOfWeek /** - * A horizontally scrolling calendar. + * 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. @@ -39,7 +47,7 @@ import kotlinx.datetime.DayOfWeek * @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. + * 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 @@ -88,7 +96,7 @@ public fun HorizontalCalendar( ) /** - * A vertically scrolling calendar. + * 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. @@ -102,7 +110,7 @@ public fun HorizontalCalendar( * @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. + * 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 @@ -293,3 +301,295 @@ public fun HeatMapCalendar( 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 columns the number of months columns in each year on the calendar. + * @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. Alternatively, you can + * also provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows. + * @param monthHorizontalSpacing the horizontal spacing between month columns. + * @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. + */ +@ExperimentalCalendarApi +@Composable +public fun HorizontalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + columns: 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 = remember { { true } }, + 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, + columns = columns, + 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 columns the number of months columns in each year on the calendar. + * @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. Alternatively, you can + * also provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows. + * @param monthHorizontalSpacing the horizontal spacing between month columns. + * @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. + */ +@ExperimentalCalendarApi +@Composable +public fun VerticalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + columns: 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 = remember { { true } }, + 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, + columns = columns, + 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, + columns: 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)?, +) { + 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 = { offset -> state.store[offset] }, + columns = columns, + 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, + ) + } + } else { + LazyColumn( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + YearCalendarMonths( + yearCount = state.calendarInfo.indexCount, + yearData = { offset -> state.store[offset] }, + columns = columns, + 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, + ) + } + } +} diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarMonths.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarMonths.kt index 1b246f67..388a0d0d 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarMonths.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarMonths.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.format.toIso8601String @Suppress("FunctionName") internal fun LazyListScope.CalendarMonths( @@ -28,7 +29,7 @@ internal fun LazyListScope.CalendarMonths( ) { items( count = monthCount, - key = { offset -> monthData(offset).yearMonth }, + key = { offset -> monthData(offset).yearMonth.toIso8601String() }, ) { offset -> val month = monthData(offset) val fillHeight = when (contentHeightMode) { @@ -86,4 +87,4 @@ private val defaultMonthContainer: (@Composable LazyItemScope.(CalendarMonth, co private val defaultMonthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit) = { _, content -> content() } -private fun T?.or(default: T) = this ?: default +internal fun T?.or(default: T) = this ?: default diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarState.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarState.kt index 3ae6bd8a..b734d45f 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarState.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/CalendarState.kt @@ -18,10 +18,11 @@ import androidx.compose.runtime.setValue import com.kizitonwose.calendar.core.OutDateStyle import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.format.fromIso8601YearMonth +import com.kizitonwose.calendar.core.format.toIso8601String import com.kizitonwose.calendar.data.DataStore import com.kizitonwose.calendar.data.VisibleItemState -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getCalendarMonthData import com.kizitonwose.calendar.data.getMonthIndex import com.kizitonwose.calendar.data.getMonthIndicesCount @@ -202,12 +203,12 @@ public class CalendarState internal constructor( } init { - monthDataChanged() // Update monthIndexCount initially. + monthDataChanged() // Update indexCount initially. } private fun monthDataChanged() { store.clear() - checkDateRange(startMonth, endMonth) + 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 @@ -267,9 +268,9 @@ public class CalendarState internal constructor( internal val Saver: Saver = listSaver( save = { listOf( - it.startMonth, - it.endMonth, - it.firstVisibleMonth.yearMonth, + it.startMonth.toIso8601String(), + it.endMonth.toIso8601String(), + it.firstVisibleMonth.yearMonth.toIso8601String(), it.firstDayOfWeek, it.outDateStyle, it.listState.firstVisibleItemIndex, @@ -278,9 +279,9 @@ public class CalendarState internal constructor( }, restore = { CalendarState( - startMonth = it[0] as YearMonth, - endMonth = it[1] as YearMonth, - firstVisibleMonth = it[2] as YearMonth, + 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( diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendar.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendar.kt index 875aeb46..a8d95521 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendar.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendar.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.format.toIso8601String import kotlinx.datetime.DayOfWeek @Composable @@ -46,7 +47,7 @@ internal fun HeatMapCalendarImpl( ) { items( count = state.calendarInfo.indexCount, - key = { offset -> state.store[offset].yearMonth }, + key = { offset -> state.store[offset].yearMonth.toIso8601String() }, ) { offset -> val calendarMonth = state.store[offset] Column(modifier = Modifier.width(IntrinsicSize.Max)) { diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt index bbf0fc97..34c33426 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt @@ -19,10 +19,11 @@ import com.kizitonwose.calendar.compose.CalendarLayoutInfo import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.format.fromIso8601YearMonth +import com.kizitonwose.calendar.core.format.toIso8601String import com.kizitonwose.calendar.data.DataStore import com.kizitonwose.calendar.data.VisibleItemState -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getHeatMapCalendarMonthData import com.kizitonwose.calendar.data.getMonthIndex import com.kizitonwose.calendar.data.getMonthIndicesCount @@ -181,12 +182,12 @@ public class HeatMapCalendarState internal constructor( } init { - monthDataChanged() // Update monthIndexCount initially. + monthDataChanged() // Update indexCount initially. } private fun monthDataChanged() { store.clear() - checkDateRange(startMonth, endMonth) + checkRange(startMonth, endMonth) calendarInfo = CalendarInfo( indexCount = getMonthIndicesCount(startMonth, endMonth), firstDayOfWeek = firstDayOfWeek, @@ -240,9 +241,9 @@ public class HeatMapCalendarState internal constructor( internal val Saver: Saver = listSaver( save = { listOf( - it.startMonth, - it.endMonth, - it.firstVisibleMonth.yearMonth, + it.startMonth.toIso8601String(), + it.endMonth.toIso8601String(), + it.firstVisibleMonth.yearMonth.toIso8601String(), it.firstDayOfWeek, it.listState.firstVisibleItemIndex, it.listState.firstVisibleItemScrollOffset, @@ -250,9 +251,9 @@ public class HeatMapCalendarState internal constructor( }, restore = { HeatMapCalendarState( - startMonth = it[0] as YearMonth, - endMonth = it[1] as YearMonth, - firstVisibleMonth = it[2] as YearMonth, + 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, diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendar.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendar.kt index 63f57649..91a71113 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendar.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendar.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.draw.clipToBounds import com.kizitonwose.calendar.compose.CalendarDefaults.flingBehavior import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.WeekDay -import com.kizitonwose.calendar.core.toJvmSerializableLocalDate +import com.kizitonwose.calendar.core.format.toIso8601String @Composable internal fun WeekCalendarImpl( @@ -39,7 +39,7 @@ internal fun WeekCalendarImpl( ) { items( count = state.weekIndexCount, - key = { offset -> state.store[offset].days.first().date.toJvmSerializableLocalDate() }, + key = { offset -> state.store[offset].days.first().date.toIso8601String() }, ) { offset -> val week = state.store[offset] Column( diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt index 22016034..8b4d56dc 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt @@ -15,18 +15,18 @@ 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.kizitonwose.calendar.core.JvmSerializableLocalDate import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.WeekDayPosition import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.atEndOfMonth import com.kizitonwose.calendar.core.atStartOfMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.core.format.fromIso8601LocalDate +import com.kizitonwose.calendar.core.format.toIso8601String import com.kizitonwose.calendar.core.now -import com.kizitonwose.calendar.core.toJvmSerializableLocalDate -import com.kizitonwose.calendar.core.toLocalDate import com.kizitonwose.calendar.data.DataStore import com.kizitonwose.calendar.data.VisibleItemState +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getWeekCalendarAdjustedRange import com.kizitonwose.calendar.data.getWeekCalendarData import com.kizitonwose.calendar.data.getWeekIndex @@ -208,6 +208,7 @@ public class WeekCalendarState internal constructor( } private fun adjustDateRange() { + checkRange(startDate, endDate) val data = getWeekCalendarAdjustedRange(startDate, endDate, firstDayOfWeek) startDateAdjusted = data.startDateAdjusted endDateAdjusted = data.endDateAdjusted @@ -268,9 +269,9 @@ public class WeekCalendarState internal constructor( internal val Saver: Saver = listSaver( save = { listOf( - it.startDate.toJvmSerializableLocalDate(), - it.endDate.toJvmSerializableLocalDate(), - it.firstVisibleWeek.days.first().date.toJvmSerializableLocalDate(), + it.startDate.toIso8601String(), + it.endDate.toIso8601String(), + it.firstVisibleWeek.days.first().date.toIso8601String(), it.firstDayOfWeek, it.listState.firstVisibleItemIndex, it.listState.firstVisibleItemScrollOffset, @@ -278,9 +279,9 @@ public class WeekCalendarState internal constructor( }, restore = { WeekCalendarState( - startDate = (it[0] as JvmSerializableLocalDate).toLocalDate(), - endDate = (it[1] as JvmSerializableLocalDate).toLocalDate(), - firstVisibleWeekDate = (it[2] as JvmSerializableLocalDate).toLocalDate(), + 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, diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt new file mode 100644 index 00000000..d8eda40a --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt @@ -0,0 +1,37 @@ +package com.kizitonwose.calendar.compose.yearcalendar + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.kizitonwose.calendar.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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt new file mode 100644 index 00000000..f2366b0b --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt @@ -0,0 +1,194 @@ +package com.kizitonwose.calendar.compose.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.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.Dp +import com.kizitonwose.calendar.compose.or +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear + +@Suppress("FunctionName") +internal fun LazyListScope.YearCalendarMonths( + yearCount: Int, + yearData: (offset: Int) -> CalendarYear, + columns: 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)?, +) { + 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 hasYearContainer = yearContainer != null + 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() + }, + ), + ) { + val months = year.months.filter(isMonthVisible) + yearHeader?.invoke(this, year) + yearBody.or(defaultYearBody)(year) { + CalendarGrid( + modifier = Modifier + .fillMaxWidth() + .then(if (fillHeight) Modifier.weight(1f) else Modifier.wrapContentHeight()) + .padding(yearBodyContentPadding), + columns = columns, + itemCount = months.count(), + fillHeight = fillHeight, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + ) { monthOffset -> + val month = months[monthOffset] + val hasContainer = monthContainer != null + monthContainer.or(defaultMonthContainer)(month) { + Column( + modifier = Modifier + .then(if (hasContainer) Modifier.fillMaxWidth() else Modifier) + .then( + if (fillHeight) { + if (hasContainer) 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 (week in month.weekDays) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentHeightMode == YearContentHeightMode.Stretch) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for (day in week) { + Box( + modifier = Modifier + .weight(1f) + .clipToBounds(), + ) { + dayContent(day) + } + } + } + } + } + } + monthFooter?.invoke(this, month) + } + } + } + } + yearFooter?.invoke(this, year) + } + } + } +} + +@Composable +private fun CalendarGrid( + columns: Int, + fillHeight: Boolean, + monthVerticalSpacing: Dp, + monthHorizontalSpacing: Dp, + itemCount: Int, + modifier: Modifier = Modifier, + content: @Composable BoxScope.(Int) -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(monthVerticalSpacing), + ) { + var rows = (itemCount / columns) + if (itemCount.mod(columns) > 0) { + rows += 1 + } + + for (rowId in 0 until rows) { + val firstIndex = rowId * columns + + Row( + modifier = Modifier.then( + if (fillHeight) Modifier.weight(1f) else Modifier, + ), + horizontalArrangement = Arrangement.spacedBy(monthHorizontalSpacing), + ) { + for (columnId in 0 until columns) { + val index = firstIndex + columnId + Box( + modifier = Modifier + .weight(1f), + ) { + if (index < itemCount) { + content(index) + } + } + } + } + } + } +} + +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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt new file mode 100644 index 00000000..0dd3b554 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt @@ -0,0 +1,298 @@ +package com.kizitonwose.calendar.compose.yearcalendar + +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.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.kizitonwose.calendar.compose.CalendarInfo +import com.kizitonwose.calendar.compose.CalendarLayoutInfo +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.data.DataStore +import com.kizitonwose.calendar.data.VisibleItemState +import com.kizitonwose.calendar.data.checkRange +import com.kizitonwose.calendar.data.getCalendarYearData +import com.kizitonwose.calendar.data.getYearIndex +import com.kizitonwose.calendar.data.getYearIndicesCount +import kotlinx.datetime.DayOfWeek + +/** + * 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] + */ +@ExperimentalCalendarApi +@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 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]. + */ + public suspend fun animateScrollToYear(year: Year) { + listState.animateScrollToItem(getScrollIndex(year) ?: return) + } + + private fun getScrollIndex(year: Year): Int? { + if (year !in startYear..endYear) { + println("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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt new file mode 100644 index 00000000..227c72b3 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt @@ -0,0 +1,37 @@ +package com.kizitonwose.calendar.compose.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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/CalendarYear.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/CalendarYear.kt new file mode 100644 index 00000000..f8de0747 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/CalendarYear.kt @@ -0,0 +1,43 @@ +package com.kizitonwose.calendar.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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt new file mode 100644 index 00000000..df02b05a --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt @@ -0,0 +1,9 @@ +package com.kizitonwose.calendar.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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Extensions.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Extensions.kt index 71fcd83f..3159e327 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Extensions.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Extensions.kt @@ -2,11 +2,11 @@ package com.kizitonwose.calendar.core import androidx.compose.ui.text.intl.Locale import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeArithmeticException import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone -import kotlinx.datetime.isoDayNumber import kotlinx.datetime.minus import kotlinx.datetime.plus import kotlinx.datetime.todayIn @@ -46,24 +46,83 @@ public fun LocalDate.Companion.now( public val LocalDate.yearMonth: YearMonth get() = YearMonth(year, month) -internal fun YearMonth.plusMonths(value: Int): YearMonth = plus(value, DateTimeUnit.MONTH) +/** + * 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) -internal fun YearMonth.minusMonths(value: Int): YearMonth = minus(value, DateTimeUnit.MONTH) +/** + * 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) -internal fun LocalDate.plusDays(value: Int): LocalDate = plus(value, DateTimeUnit.DAY) +/** + * 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) -internal fun LocalDate.minusDays(value: Int): LocalDate = minus(value, DateTimeUnit.DAY) +/** + * 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) 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.plusMonths(value: Int): LocalDate = plus(value, DateTimeUnit.MONTH) - -internal fun LocalDate.minusMonths(value: Int): LocalDate = minus(value, DateTimeUnit.MONTH) - -internal fun LocalDate.weeksUntil(other: LocalDate): Int = - until(other, DateTimeUnit.WEEK) +internal fun LocalDate.weeksUntil(other: LocalDate): Int = until(other, DateTimeUnit.WEEK) // E.g DayOfWeek.SATURDAY.daysUntil(DayOfWeek.TUESDAY) = 3 -internal fun DayOfWeek.daysUntil(other: DayOfWeek) = (7 + (other.isoDayNumber - isoDayNumber)) % 7 +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] +} diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.kt deleted file mode 100644 index dba7ae32..00000000 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.kizitonwose.calendar.core - -public expect interface JvmSerializable diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializableLocalDate.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializableLocalDate.kt deleted file mode 100644 index b0693f30..00000000 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/JvmSerializableLocalDate.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.kizitonwose.calendar.core - -import kotlinx.datetime.LocalDate - -internal data class JvmSerializableLocalDate( - val year: Int, - val monthNumber: Int, - val dayOfMonth: Int, -) : JvmSerializable - -internal fun JvmSerializableLocalDate.toLocalDate() = LocalDate( - year = year, - monthNumber = monthNumber, - dayOfMonth = dayOfMonth, -) - -internal fun LocalDate.toJvmSerializableLocalDate() = JvmSerializableLocalDate( - year = year, - monthNumber = monthNumber, - dayOfMonth = dayOfMonth, -) diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Year.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Year.kt new file mode 100644 index 00000000..e0861d37 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/Year.kt @@ -0,0 +1,173 @@ +package com.kizitonwose.calendar.core + +import androidx.compose.runtime.Immutable +import com.kizitonwose.calendar.core.format.fromIso8601Year +import com.kizitonwose.calendar.core.format.toIso8601String +import com.kizitonwose.calendar.core.serializers.YearIso8601Serializer +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.serialization.Serializable + +@Immutable +@Serializable(with = YearIso8601Serializer::class) +public data class Year(val value: Int) : Comparable { + internal val year = value + + init { + try { + atMonth(Month.JANUARY).atStartOfMonth() + } 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. + */ + 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.atDay(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 = atMonth(month) + if (yearMonth.atEndOfMonth().dayOfYear >= dayOfYear) { + return yearMonth.atDay((dayOfYear - yearMonth.atStartOfMonth().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.atMonthDay(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.atMonthDay(month: Month, day: Int): LocalDate = LocalDate(year, month, day) + +/** + * Returns the [YearMonth] at the specified [month] in this year. + */ +public fun Year.atMonth(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.atMonth(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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/YearMonth.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/YearMonth.kt index a1ca4ba9..b5856cf5 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/YearMonth.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/YearMonth.kt @@ -1,23 +1,34 @@ package com.kizitonwose.calendar.core import androidx.compose.runtime.Immutable +import com.kizitonwose.calendar.core.format.fromIso8601YearMonth +import com.kizitonwose.calendar.core.format.toIso8601String +import com.kizitonwose.calendar.core.serializers.YearMonthIso8601Serializer import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeArithmeticException import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.Month import kotlinx.datetime.TimeZone -import kotlinx.datetime.daysUntil import kotlinx.datetime.minus import kotlinx.datetime.monthsUntil import kotlinx.datetime.number import kotlinx.datetime.plus +import kotlinx.serialization.Serializable @Immutable -public data class YearMonth(val year: Int, val month: Month) : Comparable, JvmSerializable { +@Serializable(with = YearMonthIso8601Serializer::class) +public data class YearMonth(val year: Int, val month: Month) : Comparable { public constructor(year: Int, monthNumber: Int) : this(year = year, month = Month(monthNumber)) + /** + * Returns the number-of-the-month (1..12) component of the year-month. + * + * Shortcut for `month.number`. + */ + public val monthNumber: Int get() = month.number + init { try { atStartOfMonth() @@ -47,7 +58,31 @@ public data class YearMonth(val year: Int, val month: Month) : Comparable if (Year.isLeap(year)) 29 else 28 + Month.APRIL, + Month.JUNE, + Month.SEPTEMBER, + Month.NOVEMBER, + -> 30 + + else -> 31 + } } /** @@ -120,23 +162,49 @@ public fun YearMonth.minus(value: Int, unit: DateTimeUnit.MonthBased): YearMonth atStartOfMonth().minus(value, unit).yearMonth /** - * Returns a [YearMonth] that results from adding the 1 month this year-month. + * 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. * - * @see YearMonth.plus + * @throws DateTimeArithmeticException if the result exceeds the boundaries + * of [YearMonth] which is essentially the [LocalDate] boundaries. */ -public val YearMonth.next: YearMonth - get() = this.plus(1, DateTimeUnit.MONTH) +public fun YearMonth.minusMonths(value: Int): YearMonth = minus(value, DateTimeUnit.MONTH) /** - * Returns a [YearMonth] that results from subtracting the 1 month this year-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. * - * @see YearMonth.minus + * 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 val YearMonth.previous: YearMonth - get() = this.minus(1, DateTimeUnit.MONTH) +public fun YearMonth.minusYears(value: Int): YearMonth = minus(value, DateTimeUnit.YEAR) diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/format/Format.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/format/Format.kt new file mode 100644 index 00000000..cc837660 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/format/Format.kt @@ -0,0 +1,40 @@ +package com.kizitonwose.calendar.core.format + +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.atMonth +import com.kizitonwose.calendar.core.atStartOfMonth +import com.kizitonwose.calendar.core.yearMonth +import kotlinx.datetime.LocalDate +import kotlinx.datetime.format.char + +private val ISO_YEAR_MONTH by lazy { + LocalDate.Format { + year() + char('-') + monthNumber() + } +} + +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(atStartOfMonth()) + +internal fun Year.toIso8601String() = ISO_YEAR.format(atMonth(1).atStartOfMonth()) + +internal fun String.fromIso8601LocalDate(): LocalDate = + LocalDate.parse(this, ISO_LOCAL_DATE) + +internal fun String.fromIso8601YearMonth(): YearMonth = + LocalDate.parse("$this-01", ISO_LOCAL_DATE).yearMonth + +internal fun String.fromIso8601Year(): Year = + Year(LocalDate.parse("$this-01-01", ISO_LOCAL_DATE).year) diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearMonthSerializers.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearMonthSerializers.kt new file mode 100644 index 00000000..64fffe95 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearMonthSerializers.kt @@ -0,0 +1,76 @@ +package com.kizitonwose.calendar.core.serializers + +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.format.fromIso8601YearMonth +import com.kizitonwose.calendar.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 [YearMonth] that uses the ISO 8601 representation. + * + * JSON example: `"2020-01"` + * + * @see YearMonth.toString + */ +public object YearMonthIso8601Serializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.kizitonwose.calendar.core.YearMonth", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): YearMonth = + decoder.decodeString().fromIso8601YearMonth() + + override fun serialize(encoder: Encoder, value: YearMonth) { + encoder.encodeString(value.toIso8601String()) + } +} + +/** + * A serializer for [YearMonth] that represents a value as its components. + * + * JSON example: `{"year":2020,"month":12}` + */ +public object YearMonthComponentSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("com.kizitonwose.calendar.core.YearMonth") { + element("year") + element("month") + } + + @OptIn(ExperimentalSerializationApi::class) + override fun deserialize(decoder: Decoder): YearMonth = + decoder.decodeStructure(descriptor) { + var year: Int? = null + var month: Short? = null + loop@ while (true) { + when (val index = decodeElementIndex(descriptor)) { + 0 -> year = decodeIntElement(descriptor, 0) + 1 -> month = decodeShortElement(descriptor, 1) + 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) + if (month == null) throw MissingFieldException(missingField = "month", serialName = descriptor.serialName) + YearMonth(year, month.toInt()) + } + + override fun serialize(encoder: Encoder, value: YearMonth) { + encoder.encodeStructure(descriptor) { + encodeIntElement(descriptor, 0, value.year) + encodeShortElement(descriptor, 1, value.monthNumber.toShort()) + } + } +} diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearSerializers.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearSerializers.kt new file mode 100644 index 00000000..557942b2 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/core/serializers/YearSerializers.kt @@ -0,0 +1,71 @@ +package com.kizitonwose.calendar.core.serializers + +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.format.fromIso8601Year +import com.kizitonwose.calendar.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.kizitonwose.calendar.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.kizitonwose.calendar.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/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/MonthData.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/MonthData.kt index c940b023..e0a524c8 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/MonthData.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/MonthData.kt @@ -16,7 +16,7 @@ import com.kizitonwose.calendar.core.plusMonths import com.kizitonwose.calendar.core.yearMonth import kotlinx.datetime.DayOfWeek -internal data class MonthData internal constructor( +internal data class MonthData( private val month: YearMonth, private val inDays: Int, private val outDays: Int, diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/Utils.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/Utils.kt index 0b36ece9..2c31f7dd 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/Utils.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/Utils.kt @@ -1,16 +1,7 @@ package com.kizitonwose.calendar.data -import com.kizitonwose.calendar.core.YearMonth -import kotlinx.datetime.LocalDate - -internal fun checkDateRange(startMonth: YearMonth, endMonth: YearMonth) { - check(endMonth >= startMonth) { - "startMonth: $startMonth is greater than endMonth: $endMonth" - } -} - -internal fun checkDateRange(startDate: LocalDate, endDate: LocalDate) { - check(endDate >= startDate) { - "startDate: $startDate is greater than endDate: $endDate" +internal fun > checkRange(start: T, end: T) { + check(end >= start) { + "start: $start is greater than end: $end" } } diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/WeekData.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/WeekData.kt index 6865c1d8..5d96a3c3 100644 --- a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/WeekData.kt +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/WeekData.kt @@ -11,12 +11,12 @@ import com.kizitonwose.calendar.core.weeksUntil import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate -public data class WeekDateRange( +internal data class WeekDateRange( val startDateAdjusted: LocalDate, val endDateAdjusted: LocalDate, ) -public fun getWeekCalendarAdjustedRange( +internal fun getWeekCalendarAdjustedRange( startDate: LocalDate, endDate: LocalDate, firstDayOfWeek: DayOfWeek, @@ -28,7 +28,7 @@ public fun getWeekCalendarAdjustedRange( return WeekDateRange(startDateAdjusted = startDateAdjusted, endDateAdjusted = endDateAdjusted) } -public fun getWeekCalendarData( +internal fun getWeekCalendarData( startDateAdjusted: LocalDate, offset: Int, desiredStartDate: LocalDate, @@ -38,7 +38,7 @@ public fun getWeekCalendarData( return WeekData(firstDayInWeek, desiredStartDate, desiredEndDate) } -public data class WeekData internal constructor( +internal data class WeekData( private val firstDayInWeek: LocalDate, private val desiredStartDate: LocalDate, private val desiredEndDate: LocalDate, @@ -56,11 +56,11 @@ public data class WeekData internal constructor( } } -public fun getWeekIndex(startDateAdjusted: LocalDate, date: LocalDate): Int { +internal fun getWeekIndex(startDateAdjusted: LocalDate, date: LocalDate): Int { return startDateAdjusted.weeksUntil(date) } -public fun getWeekIndicesCount(startDateAdjusted: LocalDate, endDateAdjusted: LocalDate): Int { +internal fun getWeekIndicesCount(startDateAdjusted: LocalDate, endDateAdjusted: LocalDate): Int { // Add one to include the start week itself! return getWeekIndex(startDateAdjusted, endDateAdjusted) + 1 } diff --git a/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/YearData.kt b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/YearData.kt new file mode 100644 index 00000000..a39d89b4 --- /dev/null +++ b/compose-multiplatform/library/src/commonMain/kotlin/com/kizitonwose/calendar/data/YearData.kt @@ -0,0 +1,37 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.atMonth +import com.kizitonwose.calendar.core.plusYears +import com.kizitonwose.calendar.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.atMonth(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/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/CalendarStateTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/CalendarStateTest.kt new file mode 100644 index 00000000..e580b16d --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/CalendarStateTest.kt @@ -0,0 +1,108 @@ +package com.kizitonwose.calendar.compose + +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays +import com.kizitonwose.calendar.core.plusMonths +import com.kizitonwose.calendar.data.VisibleItemState +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +class CalendarStateTest { + @Test + @JsName("test1") + fun `start month update is reflected in the state`() { + val now = YearMonth.now() + val updatedStartMonth = now.minusMonths(4) + val state = createState( + startMonth = now, + endMonth = now, + ) + + assertEquals(state.store[0].yearMonth, now) + + state.startMonth = updatedStartMonth + + assertEquals(state.store[0].yearMonth, updatedStartMonth) + } + + @Test + @JsName("test2") + fun `end month update is reflected in the state`() { + val now = YearMonth.now() + val updatedEndMonth = now.plusMonths(4) + val state = createState( + startMonth = now, + endMonth = now, + ) + + assertEquals(state.store[0].yearMonth, now) + + state.endMonth = updatedEndMonth + + assertEquals(state.store[4].yearMonth, updatedEndMonth) + } + + @Test + @JsName("test3") + fun `first day of the week update is reflected in the state`() { + val firstDayOfWeek = LocalDate.now().dayOfWeek + + val state = createState(firstDayOfWeek = firstDayOfWeek) + + state.store[0].weekDays.forEach { week -> + assertEquals(week.first().date.dayOfWeek, firstDayOfWeek) + } + + do { + val updatedFirstDayOfWeek = state.firstDayOfWeek.plusDays(1) + state.firstDayOfWeek = updatedFirstDayOfWeek + + state.store[0].weekDays.forEach { week -> + assertEquals(week.first().date.dayOfWeek, updatedFirstDayOfWeek) + } + } while (firstDayOfWeek != state.firstDayOfWeek) + } + + @Test + @JsName("test4") + fun `out date style update is reflected in the state`() { + val outDateStyle = OutDateStyle.EndOfRow + // Nov 2022 has 5 weeks when Sun is the first day. + val startMonth = YearMonth(2022, 11) + val state = createState( + startMonth = startMonth, + endMonth = startMonth, + outDateStyle = outDateStyle, + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + + assertEquals(state.store[0].weekDays.count(), 5) + + state.outDateStyle = OutDateStyle.EndOfGrid + + assertEquals(state.store[0].weekDays.count(), 6) + } + + private fun createState( + startMonth: YearMonth = YearMonth.now(), + endMonth: YearMonth = startMonth, + firstVisibleMonth: YearMonth = startMonth, + outDateStyle: OutDateStyle = OutDateStyle.EndOfRow, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + visibleItemState: VisibleItemState = VisibleItemState(), + ) = CalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstVisibleMonth = firstVisibleMonth, + outDateStyle = outDateStyle, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = visibleItemState, + ) +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt new file mode 100644 index 00000000..6bba9952 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt @@ -0,0 +1,86 @@ +package com.kizitonwose.calendar.compose + +import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarState +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays +import com.kizitonwose.calendar.core.plusMonths +import com.kizitonwose.calendar.data.VisibleItemState +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +class HeatMapCalendarStateTest { + @Test + @JsName("test1") + fun `start month update is reflected in the state`() { + val now = YearMonth.now() + val updatedStartMonth = now.minusMonths(4) + val state = createState( + startMonth = now, + endMonth = now, + ) + + assertEquals(state.store[0].yearMonth, now) + + state.startMonth = updatedStartMonth + + assertEquals(state.store[0].yearMonth, updatedStartMonth) + } + + @Test + @JsName("test2") + fun `end month update is reflected in the state`() { + val now = YearMonth.now() + val updatedEndMonth = now.plusMonths(4) + val state = createState( + startMonth = now, + endMonth = now, + ) + + assertEquals(state.store[0].yearMonth, now) + + state.endMonth = updatedEndMonth + + assertEquals(state.store[4].yearMonth, updatedEndMonth) + } + + @Test + @JsName("test3") + fun `first day of the week update is reflected in the state`() { + val firstDayOfWeek = LocalDate.now().dayOfWeek + + val state = createState(firstDayOfWeek = firstDayOfWeek) + + state.store[0].weekDays.forEach { week -> + assertEquals(week.first().date.dayOfWeek, firstDayOfWeek) + } + + do { + val updatedFirstDayOfWeek = state.firstDayOfWeek.plusDays(1) + state.firstDayOfWeek = updatedFirstDayOfWeek + + state.store[0].weekDays.forEach { week -> + assertEquals(week.first().date.dayOfWeek, updatedFirstDayOfWeek) + } + } while (firstDayOfWeek != state.firstDayOfWeek) + } + + private fun createState( + startMonth: YearMonth = YearMonth.now(), + endMonth: YearMonth = startMonth, + firstVisibleMonth: YearMonth = startMonth, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + visibleItemState: VisibleItemState = VisibleItemState(), + ) = HeatMapCalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstVisibleMonth = firstVisibleMonth, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = visibleItemState, + ) +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/StateSaverTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/StateSaverTest.kt new file mode 100644 index 00000000..e70ad3bd --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/StateSaverTest.kt @@ -0,0 +1,114 @@ +package com.kizitonwose.calendar.compose + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.listSaver +import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarState +import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.data.VisibleItemState +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The states use the [listSaver] type so these tests should catch when we move + * things around without paying attention to the indices or the actual items + * being saved. Such issues are typically not caught during development since + * state restoration (e.g via rotation) will likely not happen often. + */ +class StateSaverTest { + @Test + @JsName("test1") + fun `month calendar state can be restored`() { + val now = YearMonth.now() + val firstDayOfWeek = DayOfWeek.entries.random() + val outDateStyle = OutDateStyle.entries.random() + val state = CalendarState( + startMonth = now, + endMonth = now, + firstVisibleMonth = now, + outDateStyle = outDateStyle, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = VisibleItemState(), + ) + val restored = restore(state, CalendarState.Saver) + assertEquals(state.startMonth, restored.startMonth) + assertEquals(state.endMonth, restored.endMonth) + assertEquals(state.firstVisibleMonth, restored.firstVisibleMonth) + assertEquals(state.outDateStyle, restored.outDateStyle) + assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) + } + + @Test + @JsName("test2") + fun `week calendar state can be restored`() { + val now = LocalDate.now() + val firstDayOfWeek = DayOfWeek.entries.random() + val state = WeekCalendarState( + startDate = now, + endDate = now, + firstVisibleWeekDate = now, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = VisibleItemState(), + ) + val restored = restore(state, WeekCalendarState.Saver) + assertEquals(state.startDate, restored.startDate) + assertEquals(state.endDate, restored.endDate) + assertEquals(state.firstVisibleWeek, restored.firstVisibleWeek) + assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) + } + + @Test + @JsName("test3") + fun `heatmap calendar state can be restored`() { + val now = YearMonth.now() + val firstDayOfWeek = DayOfWeek.entries.random() + val state = HeatMapCalendarState( + startMonth = now, + endMonth = now, + firstVisibleMonth = now, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = VisibleItemState(), + ) + val restored = restore(state, HeatMapCalendarState.Saver) + assertEquals(state.startMonth, restored.startMonth) + assertEquals(state.endMonth, restored.endMonth) + assertEquals(state.firstVisibleMonth, restored.firstVisibleMonth) + assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) + } + + @Test + @JsName("test4") + fun `year calendar state can be restored`() { + val now = Year.now() + val firstDayOfWeek = DayOfWeek.entries.random() + val outDateStyle = OutDateStyle.entries.random() + val state = YearCalendarState( + startYear = now, + endYear = now, + firstVisibleYear = now, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + visibleItemState = VisibleItemState(), + ) + val restored = restore(state, YearCalendarState.Saver) + assertEquals(state.startYear, restored.startYear) + assertEquals(state.endYear, restored.endYear) + assertEquals(state.firstVisibleYear, restored.firstVisibleYear) + assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) + } + + private fun restore(value: State, saver: Saver): State { + with(saver) { + val saved = SaverScope { true }.save(value)!! + return restore(saved)!! + } + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt new file mode 100644 index 00000000..391b3b14 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt @@ -0,0 +1,81 @@ +package com.kizitonwose.calendar.compose + +import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.core.minusDays +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays +import com.kizitonwose.calendar.data.VisibleItemState +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WeekCalendarStateTest { + @Test + @JsName("test1") + fun `start date update is reflected in the state`() { + val now = LocalDate.now() + val updatedStartDate = now.minusDays(7) + val state = createState( + startDate = now, + endDate = now, + ) + + assertTrue(state.store[0].days.map { it.date }.contains(now)) + + state.startDate = updatedStartDate + + assertTrue(state.store[0].days.map { it.date }.contains(updatedStartDate)) + } + + @Test + @JsName("test2") + fun `end date update is reflected in the state`() { + val now = LocalDate.now() + val updatedEndDate = now.plusDays(7) + val state = createState( + startDate = now, + endDate = now, + ) + + assertTrue(state.store[0].days.map { it.date }.contains(now)) + + state.endDate = updatedEndDate + + assertTrue(state.store[1].days.map { it.date }.contains(updatedEndDate)) + } + + @Test + @JsName("test3") + fun `first day of the week update is reflected in the state`() { + val firstDayOfWeek = LocalDate.now().dayOfWeek + + val state = createState(firstDayOfWeek = firstDayOfWeek) + + assertEquals(state.store[0].days.first().date.dayOfWeek, firstDayOfWeek) + + do { + val updatedFirstDayOfWeek = state.firstDayOfWeek.plusDays(1) + state.firstDayOfWeek = updatedFirstDayOfWeek + + assertEquals(state.store[0].days.first().date.dayOfWeek, updatedFirstDayOfWeek) + } while (firstDayOfWeek != state.firstDayOfWeek) + } + + private fun createState( + startDate: LocalDate = LocalDate.now(), + endDate: LocalDate = startDate, + firstVisibleWeekDate: LocalDate = startDate, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + visibleItemState: VisibleItemState = VisibleItemState(), + ) = WeekCalendarState( + startDate = startDate, + endDate = endDate, + firstVisibleWeekDate = firstVisibleWeekDate, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = visibleItemState, + ) +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt new file mode 100644 index 00000000..dfa79ae3 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt @@ -0,0 +1,113 @@ +package com.kizitonwose.calendar.compose + +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.core.minusYears +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays +import com.kizitonwose.calendar.core.plusYears +import com.kizitonwose.calendar.data.VisibleItemState +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +class YearCalendarStateTest { + @Test + @JsName("test1") + fun `start year update is reflected in the state`() { + val now = Year.now() + val updatedStartYear = now.minusYears(8) + val state = createState( + startYear = now, + endYear = now, + ) + + assertEquals(state.store[0].year, now) + + state.startYear = updatedStartYear + + assertEquals(state.store[0].year, updatedStartYear) + } + + @Test + @JsName("test2") + fun `end year update is reflected in the state`() { + val now = Year.now() + val updatedEndMonth = now.plusYears(8) + val state = createState( + startYear = now, + endYear = now, + ) + + assertEquals(state.store[0].year, now) + + state.endYear = updatedEndMonth + + assertEquals(state.store[8].year, updatedEndMonth) + } + + @Test + @JsName("test3") + fun `first day of the week update is reflected in the state`() { + val firstDayOfWeek = LocalDate.now().dayOfWeek + + val state = createState(firstDayOfWeek = firstDayOfWeek) + + state.store[0].months + .flatMap { month -> month.weekDays } + .forEach { week -> + assertEquals(week.first().date.dayOfWeek, firstDayOfWeek) + } + do { + val updatedFirstDayOfWeek = state.firstDayOfWeek.plusDays(1) + state.firstDayOfWeek = updatedFirstDayOfWeek + + state.store[0].months + .flatMap { month -> month.weekDays } + .forEach { week -> + assertEquals(week.first().date.dayOfWeek, updatedFirstDayOfWeek) + } + } while (firstDayOfWeek != state.firstDayOfWeek) + } + + @Test + @JsName("test4") + fun `out date style update is reflected in the state`() { + val outDateStyle = OutDateStyle.EndOfRow + // Nov 2022 has 5 weeks when Sun is the first day. + val startYear = Year(2022) + val state = createState( + startYear = startYear, + endYear = startYear, + outDateStyle = outDateStyle, + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + + assertEquals(state.store[0].months[Month.NOVEMBER.ordinal].weekDays.count(), 5) + + state.outDateStyle = OutDateStyle.EndOfGrid + + assertEquals(state.store[0].months[Month.NOVEMBER.ordinal].weekDays.count(), 6) + } + + private fun createState( + startYear: Year = Year.now(), + endYear: Year = startYear, + firstVisibleYear: Year = startYear, + outDateStyle: OutDateStyle = OutDateStyle.EndOfRow, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + visibleItemState: VisibleItemState = VisibleItemState(), + ) = YearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = firstVisibleYear, + outDateStyle = outDateStyle, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = visibleItemState, + ) +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/ExtensionTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/ExtensionTest.kt new file mode 100644 index 00000000..d400b092 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/ExtensionTest.kt @@ -0,0 +1,100 @@ +package com.kizitonwose.calendar.core + +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlin.test.Test +import kotlin.test.assertEquals + +class ExtensionTest { + @Test + fun generalExtensions() { + assertEquals( + YearMonth(2024, Month.JULY), + YearMonth(2024, Month.JUNE).plusMonths(1), + ) + assertEquals( + YearMonth(2024, Month.MAY), + YearMonth(2024, Month.JUNE).minusMonths(1), + ) + assertEquals( + YearMonth(2025, Month.JUNE), + YearMonth(2024, Month.JUNE).plusYears(1), + ) + assertEquals( + YearMonth(2023, Month.MAY), + YearMonth(2024, Month.MAY).minusYears(1), + ) + assertEquals( + LocalDate(2024, Month.MAY, 2), + LocalDate(2024, Month.MAY, 1).plusDays(1), + ) + assertEquals( + LocalDate(2024, Month.APRIL, 30), + LocalDate(2024, Month.MAY, 1).minusDays(1), + ) + assertEquals( + LocalDate(2024, Month.JUNE, 2), + LocalDate(2024, Month.MAY, 2).plusMonths(1), + ) + assertEquals( + LocalDate(2024, Month.FEBRUARY, 29), + LocalDate(2024, Month.MARCH, 30).minusMonths(1), + ) + assertEquals( + LocalDate(2026, Month.JUNE, 2), + LocalDate(2025, Month.JUNE, 2).plusYears(1), + ) + assertEquals( + LocalDate(2023, Month.FEBRUARY, 28), + LocalDate(2024, Month.FEBRUARY, 29).minusYears(1), + ) + assertEquals( + LocalDate(2024, Month.MAY, 9), + LocalDate(2024, Month.MAY, 2).plusWeeks(1), + ) + assertEquals( + LocalDate(2024, Month.MARCH, 23), + LocalDate(2024, Month.MARCH, 30).minusWeeks(1), + ) + assertEquals( + 4, + LocalDate(2024, Month.MARCH, 1).weeksUntil(LocalDate(2024, Month.MARCH, 30)), + ) + assertEquals( + YearMonth(2024, Month.MARCH), + LocalDate(2024, Month.MARCH, 1).yearMonth, + ) + } + + @Test + fun daysUntil() { + assertEquals(5, DayOfWeek.FRIDAY.daysUntil(DayOfWeek.WEDNESDAY)) + assertEquals(2, DayOfWeek.TUESDAY.daysUntil(DayOfWeek.THURSDAY)) + assertEquals(0, DayOfWeek.SUNDAY.daysUntil(DayOfWeek.SUNDAY)) + assertEquals(3, DayOfWeek.SATURDAY.daysUntil(DayOfWeek.TUESDAY)) + assertEquals(5, DayOfWeek.WEDNESDAY.daysUntil(DayOfWeek.MONDAY)) + assertEquals(1, DayOfWeek.THURSDAY.daysUntil(DayOfWeek.FRIDAY)) + assertEquals(6, DayOfWeek.MONDAY.daysUntil(DayOfWeek.SUNDAY)) + assertEquals(6, DayOfWeek.SUNDAY.daysUntil(DayOfWeek.SATURDAY)) + } + + @Test + fun plusDays() { + assertEquals(DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY.plusDays(5)) + assertEquals(DayOfWeek.THURSDAY, DayOfWeek.TUESDAY.plusDays(2)) + assertEquals(DayOfWeek.SUNDAY, DayOfWeek.SUNDAY.plusDays(0)) + assertEquals(DayOfWeek.TUESDAY, DayOfWeek.SATURDAY.plusDays(3)) + assertEquals(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY.plusDays(5)) + assertEquals(DayOfWeek.FRIDAY, DayOfWeek.THURSDAY.plusDays(1)) + assertEquals(DayOfWeek.SUNDAY, DayOfWeek.MONDAY.plusDays(6)) + assertEquals(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY.plusDays(6)) + } + + @Test + fun daysOfWeek() { + DayOfWeek.entries.forEach { dayOfWeek -> + assertEquals(dayOfWeek, daysOfWeek(firstDayOfWeek = dayOfWeek).first()) + } + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthSerializationTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthSerializationTest.kt new file mode 100644 index 00000000..ad9c70f5 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthSerializationTest.kt @@ -0,0 +1,68 @@ +package com.kizitonwose.calendar.core + +import com.kizitonwose.calendar.core.serializers.YearMonthComponentSerializer +import com.kizitonwose.calendar.core.serializers.YearMonthIso8601Serializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class YearMonthSerializationTest { + private fun iso8601Serialization(serializer: KSerializer) { + for ((localDate, json) in listOf( + Pair(YearMonth(2020, 12), "\"2020-12\""), + Pair(YearMonth(-2020, 1), "\"-2020-01\""), + Pair(YearMonth(2019, 10), "\"2019-10\""), + )) { + assertEquals(json, Json.encodeToString(serializer, localDate)) + assertEquals(localDate, Json.decodeFromString(serializer, json)) + } + } + + private fun componentSerialization(serializer: KSerializer) { + for ((localDate, json) in listOf( + Pair(YearMonth(2020, 12), "{\"year\":2020,\"month\":12}"), + Pair(YearMonth(-2020, 1), "{\"year\":-2020,\"month\":1}"), + Pair(YearMonth(2019, 10), "{\"year\":2019,\"month\":10}"), + )) { + assertEquals(json, Json.encodeToString(serializer, localDate)) + assertEquals(localDate, Json.decodeFromString(serializer, json)) + } + // all components must be present + assertFailsWith { + Json.decodeFromString(serializer, "{}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":3}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"month\":3}") + } + // invalid values must fail to construct + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":1000000000000,\"month\":3}") + } + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":2020,\"month\":30}") + } + } + + @Test + fun testIso8601Serialization() { + iso8601Serialization(YearMonthIso8601Serializer) + } + + @Test + fun testComponentSerialization() { + componentSerialization(YearMonthComponentSerializer) + } + + @Test + fun testDefaultSerializers() { + // should be the same as the ISO 8601 + iso8601Serialization(Json.serializersModule.serializer()) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthTest.kt new file mode 100644 index 00000000..462c4013 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearMonthTest.kt @@ -0,0 +1,185 @@ +package com.kizitonwose.calendar.core + +import com.kizitonwose.calendar.utils.toTriple +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class YearMonthTest { + @Test + fun lengthOfMonth() { + val leapYearValues = Month.entries.map { Year(2024).atMonth(it) } + val nonLeapYearValues = Month.entries.map { Year(2023).atMonth(it) } + + for (value in leapYearValues) { + val expectedLength = when (value.month) { + Month.FEBRUARY -> 29 + Month.APRIL, + Month.JUNE, + Month.SEPTEMBER, + Month.NOVEMBER, + -> 30 + + else -> 31 + } + assertEquals(expectedLength, value.lengthOfMonth()) + } + + for (value in nonLeapYearValues) { + val expectedLength = when (value.month) { + Month.FEBRUARY -> 28 + Month.APRIL, + Month.JUNE, + Month.SEPTEMBER, + Month.NOVEMBER, + -> 30 + + else -> 31 + } + assertEquals(expectedLength, value.lengthOfMonth()) + } + } + + @Test + fun atStartOfMonth() { + for ((yearMonth, firstDay) in listOf( + YearMonth(2025, Month.JANUARY) to LocalDate(2025, Month.JANUARY, 1), + YearMonth(2020, Month.JUNE) to LocalDate(2020, Month.JUNE, 1), + )) { + assertEquals(yearMonth.atStartOfMonth(), firstDay) + } + } + + @Test + fun atEndOfMonth() { + for ((yearMonth, lastDay) in listOf( + YearMonth(2025, Month.JANUARY) to LocalDate(2025, Month.JANUARY, 31), + YearMonth(2024, Month.JUNE) to LocalDate(2024, Month.JUNE, 30), + YearMonth(2025, Month.FEBRUARY) to LocalDate(2025, Month.FEBRUARY, 28), + YearMonth(2024, Month.FEBRUARY) to LocalDate(2024, Month.FEBRUARY, 29), + )) { + assertEquals(yearMonth.atEndOfMonth(), lastDay) + } + } + + @Test + fun atDay() { + for (yearMonth in Month.entries.map { Year(2024).atMonth(it) }) { + for (day in 1..yearMonth.lengthOfMonth()) { + assertEquals(LocalDate(yearMonth.year, yearMonth.month, day), yearMonth.atDay(day)) + } + } + } + + @Test + fun monthsUntil() { + for ((start, end, result) in listOf( + YearMonth(2024, Month.JANUARY) to YearMonth(2024, Month.NOVEMBER) toTriple 10, + YearMonth(2024, Month.JANUARY) to YearMonth(2024, Month.DECEMBER) toTriple 11, + YearMonth(2026, Month.MARCH) to YearMonth(2028, Month.FEBRUARY) toTriple 23, + YearMonth(2047, Month.OCTOBER) to YearMonth(2051, Month.APRIL) toTriple 42, + YearMonth(2065, Month.JUNE) to YearMonth(2071, Month.JUNE) toTriple 72, + YearMonth(2020, Month.MAY) to YearMonth(2023, Month.JULY) toTriple 38, + YearMonth(2022, Month.AUGUST) to YearMonth(2022, Month.AUGUST) toTriple 0, + YearMonth(2022, Month.AUGUST) to YearMonth(2022, Month.SEPTEMBER) toTriple 1, + )) { + assertEquals(result, start.monthsUntil(end)) + assertEquals(-result, end.monthsUntil(start)) + } + } + + @Test + fun plus() { + val plusMonth = listOf( + YearMonth(2024, Month.JANUARY) to 10 toTriple YearMonth(2024, Month.NOVEMBER), + YearMonth(2020, Month.MAY) to 38 toTriple YearMonth(2023, Month.JULY), + YearMonth(2022, Month.AUGUST) to 0 toTriple YearMonth(2022, Month.AUGUST), + YearMonth(2022, Month.AUGUST) to 1 toTriple YearMonth(2022, Month.SEPTEMBER), + ) + val plusYear = listOf( + YearMonth(2024, Month.JANUARY) to 10 toTriple YearMonth(2034, Month.JANUARY), + YearMonth(2020, Month.MAY) to 38 toTriple YearMonth(2058, Month.MAY), + YearMonth(2022, Month.AUGUST) to 0 toTriple YearMonth(2022, Month.AUGUST), + YearMonth(2022, Month.SEPTEMBER) to 1 toTriple YearMonth(2023, Month.SEPTEMBER), + ) + + for ((start, value, result) in plusMonth) { + assertEquals(result, start.plus(value, DateTimeUnit.MONTH)) + assertEquals(start, result.plus(-value, DateTimeUnit.MONTH)) + } + + for ((start, value, result) in plusYear) { + assertEquals(result, start.plus(value, DateTimeUnit.YEAR)) + assertEquals(start, result.plus(-value, DateTimeUnit.YEAR)) + } + } + + @Test + fun minus() { + val minusMonth = listOf( + YearMonth(2024, Month.JANUARY) to 10 toTriple YearMonth(2023, Month.MARCH), + YearMonth(2020, Month.MAY) to 38 toTriple YearMonth(2017, Month.MARCH), + YearMonth(2022, Month.AUGUST) to 0 toTriple YearMonth(2022, Month.AUGUST), + YearMonth(2022, Month.AUGUST) to 1 toTriple YearMonth(2022, Month.JULY), + ) + val minusYear = listOf( + YearMonth(2024, Month.JANUARY) to 10 toTriple YearMonth(2014, Month.JANUARY), + YearMonth(2020, Month.MAY) to 38 toTriple YearMonth(1982, Month.MAY), + YearMonth(2022, Month.AUGUST) to 0 toTriple YearMonth(2022, Month.AUGUST), + YearMonth(2022, Month.SEPTEMBER) to 1 toTriple YearMonth(2021, Month.SEPTEMBER), + ) + + for ((start, value, result) in minusMonth) { + assertEquals(result, start.minus(value, DateTimeUnit.MONTH)) + assertEquals(start, result.minus(-value, DateTimeUnit.MONTH)) + } + + for ((start, value, result) in minusYear) { + assertEquals(result, start.minus(value, DateTimeUnit.YEAR)) + assertEquals(start, result.minus(-value, DateTimeUnit.YEAR)) + } + } + + @Test + fun monthNumber() { + for ((value, result) in listOf( + YearMonth(2025, Month.JANUARY) to 1, + YearMonth(1999, Month.JUNE) to 6, + )) { + assertEquals(result, value.monthNumber) + } + } + + @Test + fun toIso8601String() { + for ((value, result) in listOf( + YearMonth(2025, Month.JANUARY) to "2025-01", + YearMonth(-1999, Month.JUNE) to "-1999-06", + YearMonth(1, Month.AUGUST) to "0001-08", + YearMonth(0, Month.MARCH) to "0000-03", + )) { + assertEquals(result, value.toString()) + } + } + + @Test + fun parseIso8601() { + for ((value, result) in listOf( + "2025-01" to YearMonth(2025, Month.JANUARY), + "-1999-06" to YearMonth(-1999, Month.JUNE), + "0001-08" to YearMonth(1, Month.AUGUST), + "0000-03" to YearMonth(0, Month.MARCH), + )) { + assertEquals(result, YearMonth.parseIso8601(value)) + } + + for (value in listOf("20-01", "06")) { + assertFailsWith(IllegalArgumentException::class) { + YearMonth.parseIso8601(value) + } + } + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearSerializationTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearSerializationTest.kt new file mode 100644 index 00000000..a1107458 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearSerializationTest.kt @@ -0,0 +1,59 @@ +package com.kizitonwose.calendar.core + +import com.kizitonwose.calendar.core.serializers.YearComponentSerializer +import com.kizitonwose.calendar.core.serializers.YearIso8601Serializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class YearSerializationTest { + private fun iso8601Serialization(serializer: KSerializer) { + for ((localDate, json) in listOf( + Pair(Year(2020), "\"2020\""), + Pair(Year(-2020), "\"-2020\""), + Pair(Year(2019), "\"2019\""), + )) { + assertEquals(json, Json.encodeToString(serializer, localDate)) + assertEquals(localDate, Json.decodeFromString(serializer, json)) + } + } + + private fun componentSerialization(serializer: KSerializer) { + for ((localDate, json) in listOf( + Pair(Year(2020), "{\"year\":2020}"), + Pair(Year(-2020), "{\"year\":-2020}"), + Pair(Year(2019), "{\"year\":2019}"), + )) { + assertEquals(json, Json.encodeToString(serializer, localDate)) + assertEquals(localDate, Json.decodeFromString(serializer, json)) + } + // all components must be present + assertFailsWith { + Json.decodeFromString(serializer, "{}") + } + // invalid values must fail to construct + assertFailsWith { + Json.decodeFromString(serializer, "{\"year\":1000000000000}") + } + } + + @Test + fun testIso8601Serialization() { + iso8601Serialization(YearIso8601Serializer) + } + + @Test + fun testComponentSerialization() { + componentSerialization(YearComponentSerializer) + } + + @Test + fun testDefaultSerializers() { + // should be the same as the ISO 8601 + iso8601Serialization(Json.serializersModule.serializer()) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearTest.kt new file mode 100644 index 00000000..25b052e3 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/core/YearTest.kt @@ -0,0 +1,225 @@ +package com.kizitonwose.calendar.core + +import com.kizitonwose.calendar.utils.toTriple +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.number +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class YearTest { + @Test + fun isLeap() { + assertEquals(false, Year.isLeap(1999)) + assertEquals(true, Year.isLeap(2000)) + assertEquals(false, Year.isLeap(2001)) + assertEquals(false, Year.isLeap(2007)) + assertEquals(true, Year.isLeap(2008)) + assertEquals(false, Year.isLeap(2009)) + assertEquals(false, Year.isLeap(2010)) + assertEquals(false, Year.isLeap(2011)) + assertEquals(true, Year.isLeap(2012)) + assertEquals(false, Year.isLeap(2095)) + assertEquals(true, Year.isLeap(2096)) + assertEquals(false, Year.isLeap(2097)) + assertEquals(false, Year.isLeap(2098)) + assertEquals(false, Year.isLeap(2099)) + assertEquals(false, Year.isLeap(2100)) + assertEquals(false, Year.isLeap(2101)) + assertEquals(false, Year.isLeap(2102)) + assertEquals(false, Year.isLeap(2103)) + assertEquals(true, Year.isLeap(2104)) + assertEquals(false, Year.isLeap(2105)) + assertEquals(false, Year.isLeap(-500)) + assertEquals(true, Year.isLeap(-400)) + assertEquals(false, Year.isLeap(-300)) + assertEquals(false, Year.isLeap(-200)) + assertEquals(false, Year.isLeap(-100)) + assertEquals(true, Year.isLeap(0)) + assertEquals(false, Year.isLeap(100)) + assertEquals(false, Year.isLeap(200)) + assertEquals(false, Year.isLeap(300)) + assertEquals(true, Year.isLeap(400)) + assertEquals(false, Year.isLeap(500)) + } + + @Test + fun length() { + val leapYears = listOf(2000, 2008, 2012, 2096, 0, -400) + val nonLeapYears = listOf(2001, 2011, 2095, 500, 1, -500) + + for (year in leapYears) { + assertEquals(366, Year(year).length()) + } + + for (year in nonLeapYears) { + assertEquals(365, Year(year).length()) + } + } + + @Test + fun atMonth() { + for (year in listOf(0, -400, 2024, 1, 1999)) { + for (month in Month.entries) { + assertEquals(YearMonth(year, month), Year(year).atMonth(month)) + } + } + } + + @Test + fun atMonthNumber() { + for (year in listOf(0, -400, 2024, 1, 1999)) { + for (month in Month.entries) { + assertEquals(YearMonth(year, month.number), Year(year).atMonth(month)) + } + } + } + + @Test + fun atMonthDay() { + val validDays = listOf( + 2024 to Month.FEBRUARY toTriple 29, + 1999 to Month.JUNE toTriple 30, + 2030 to Month.DECEMBER toTriple 31, + 1866 to Month.DECEMBER toTriple 1, + ) + val invalidDays = listOf( + 2023 to Month.FEBRUARY toTriple 29, + 1999 to Month.JUNE toTriple 31, + 2030 to Month.DECEMBER toTriple -1, + 1866 to Month.DECEMBER toTriple 0, + ) + + for ((year, month, day) in validDays) { + assertEquals(LocalDate(year, month, day), Year(year).atMonthDay(month, day)) + } + + for ((year, month, day) in invalidDays) { + assertFailsWith(IllegalArgumentException::class) { + Year(year).atMonthDay(month, day) + } + } + } + + @Test + fun atMonthNumberDay() { + val validDays = listOf( + 2024 to 1 toTriple 29, + 1999 to 6 toTriple 30, + 2030 to 12 toTriple 31, + 1866 to 12 toTriple 1, + ) + val invalidDays = listOf( + 2023 to 0 toTriple 29, + 1999 to 13 toTriple 31, + 2030 to 6 toTriple -1, + 1866 to 1 toTriple 0, + ) + + for ((year, monthNumber, day) in validDays) { + assertEquals(LocalDate(year, monthNumber, day), Year(year).atMonthDay(monthNumber, day)) + } + + for ((year, monthNumber, day) in invalidDays) { + assertFailsWith(IllegalArgumentException::class) { + Year(year).atMonthDay(monthNumber, day) + } + } + } + + @Test + fun atDay() { + val validDays = listOf( + 2024 to 366 toTriple LocalDate(2024, Month.DECEMBER, 31), + 2039 to 365 toTriple LocalDate(2039, Month.DECEMBER, 31), + 1999 to 30 toTriple LocalDate(1999, Month.JANUARY, 30), + 1866 to 59 toTriple LocalDate(1866, Month.FEBRUARY, 28), + ) + val invalidDays = listOf( + 2034 to 367, + 2023 to 366, + 2030 to -1, + 2030 to 0, + ) + + for ((year, dayOfYear, date) in validDays) { + assertEquals(date, Year(year).atDay(dayOfYear)) + } + + for ((year, dayOfYear) in invalidDays) { + assertFailsWith(IllegalArgumentException::class) { + Year(year).atDay(dayOfYear) + } + } + } + + @Test + fun yearsUntil() { + for ((start, end, result) in listOf( + 2020 to 2024 toTriple 4, + 2024 to 2030 toTriple 6, + 1999 to 2028 toTriple 29, + 1300 to 1365 toTriple 65, + )) { + assertEquals(result, Year(start).yearsUntil(Year(end))) + assertEquals(-result, Year(end).yearsUntil(Year(start))) + } + } + + @Test + fun plus() { + for ((start, value, result) in listOf( + 2020 to 4 toTriple 2024, + 2024 to 6 toTriple 2030, + 1999 to 29 toTriple 2028, + 1300 to 65 toTriple 1365, + )) { + assertEquals(Year(result), Year(start).plusYears(value)) + assertEquals(Year(start), Year(result).plusYears(-value)) + } + } + + @Test + fun minus() { + for ((start, value, result) in listOf( + 2020 to 4 toTriple 2016, + 2024 to 6 toTriple 2018, + 1999 to 29 toTriple 1970, + 1300 to 65 toTriple 1235, + )) { + assertEquals(Year(result), Year(start).minusYears(value)) + assertEquals(Year(start), Year(result).minusYears(-value)) + } + } + + @Test + fun toIso8601String() { + for ((value, result) in listOf( + 2024 to "2024", + -1999 to "-1999", + 1 to "0001", + 0 to "0000", + )) { + assertEquals(result, Year(value).toString()) + } + } + + @Test + fun parseIso8601() { + for ((value, result) in listOf( + "2025" to Year(2025), + "-1999" to Year(-1999), + "0001" to Year(1), + "0000" to Year(0), + )) { + assertEquals(result, Year.parseIso8601(value)) + } + + for (value in listOf("20", "-6")) { + assertFailsWith(IllegalArgumentException::class) { + YearMonth.parseIso8601(value) + } + } + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/HeatMapDataTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/HeatMapDataTest.kt new file mode 100644 index 00000000..5cfb4928 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/HeatMapDataTest.kt @@ -0,0 +1,154 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.yearMonth +import com.kizitonwose.calendar.utils.nextMonth +import com.kizitonwose.calendar.utils.previousMonth +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HeatMapDataTest { + private val october2022 = YearMonth(2022, Month.OCTOBER) + private val november2022 = YearMonth(2022, Month.NOVEMBER) + private val december2022 = YearMonth(2022, Month.DECEMBER) + private val firstDayOfWeek = DayOfWeek.MONDAY + + /** October, November and December 2022 + * with October as the start month and + * Monday as the first day of week. + * ┌──┬─────────────────┬───────────┬───────────┐ + * │ │Oct 2022 │Nov 2022 │Dec 2022 │ + * ├──┼──┬──┬──┬──┬──┬──┼──┬──┬──┬──┼──┬──┬──┬──┤ + * │Mo│26│03│10│17│24│31│07│14│21│28│05│12│19│26│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │Tu│27│04│11│18│25│01│08│15│22│29│06│13│20│27│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │We│28│05│12│19│26│02│09│16│23│30│07│14│21│28│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │Th│29│06│13│20│27│03│10│17│24│01│08│15│22│29│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │Fr│30│07│14│21│28│04│11│18│24│02│09│16│23│30│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │Sa│01│08│15│22│29│05│12│19│26│03│10│17│24│31│ + * ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┼──┤ + * │Su│02│09│16│23│30│06│13│20│27│04│11│18│25│01│ + * └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + **/ + + @Test + @JsName("test1") + fun `number of day positions are accurate`() { + val monthData = getHeatMapCalendarMonthData(october2022, 0, firstDayOfWeek) + val days = monthData.calendarMonth.weekDays.flatten() + + assertEquals(5, days.count { it.position == DayPosition.InDate }) + assertEquals(6, days.count { it.position == DayPosition.OutDate }) + assertEquals(31, days.count { it.position == DayPosition.MonthDate }) + assertEquals(42, days.count()) + assertEquals(6, monthData.calendarMonth.weekDays.count()) + monthData.calendarMonth.weekDays.forEach { weekDays -> + assertEquals(7, weekDays.count()) + } + } + + @Test + @JsName("test2") + fun `first date in the following month is accurate`() { + val novemberMonthData = getHeatMapCalendarMonthData(october2022, 1, firstDayOfWeek) + val days = novemberMonthData.calendarMonth.weekDays.flatten() + + assertEquals(7, days.first().date.dayOfMonth) + assertEquals(october2022.nextMonth, days.first().date.yearMonth) + assertEquals(DayPosition.MonthDate, days.first().position) + } + + @Test + @JsName("test3") + fun `dates in the following month are in the correct positions`() { + val novemberMonthData = getHeatMapCalendarMonthData(october2022, 1, firstDayOfWeek) + val days = novemberMonthData.calendarMonth.weekDays.flatten() + + val monthDates = days.take(24) + val outDates = days.takeLast(4) + + assertTrue(outDates.all { it.position == DayPosition.OutDate }) + assertTrue(monthDates.all { it.position == DayPosition.MonthDate }) + assertEquals(28, days.count()) + } + + @Test + @JsName("test4") + fun `dates in the following month have the correct month values`() { + val november2022 = october2022.nextMonth + val december2022 = november2022.nextMonth + val novemberMonthData = getHeatMapCalendarMonthData(october2022, 1, firstDayOfWeek) + val days = novemberMonthData.calendarMonth.weekDays.flatten() + + val monthDates = days.take(24) + val outDates = days.takeLast(4) + + assertTrue(outDates.all { it.date.yearMonth == december2022 }) + assertTrue(monthDates.all { it.date.yearMonth == november2022 }) + } + + @Test + @JsName("test5") + fun `dates in the first month are in the correct positions`() { + val monthData = getHeatMapCalendarMonthData(october2022, 0, firstDayOfWeek) + val days = monthData.calendarMonth.weekDays.flatten() + + val inDates = days.take(5) + val outDates = days.takeLast(6) + val monthDates = days.drop(5).dropLast(6) + + assertTrue(inDates.all { it.position == DayPosition.InDate }) + assertTrue(outDates.all { it.position == DayPosition.OutDate }) + assertTrue(monthDates.all { it.position == DayPosition.MonthDate }) + } + + @Test + @JsName("test6") + fun `dates in the first month have the correct month values`() { + val previousMonth = october2022.previousMonth + val nextMonth = october2022.nextMonth + val monthData = getHeatMapCalendarMonthData(october2022, 0, firstDayOfWeek) + val days = monthData.calendarMonth.weekDays.flatten() + + val inDates = days.take(5) + val outDates = days.takeLast(6) + val monthDates = days.drop(5).dropLast(6) + + assertTrue(inDates.all { it.date.yearMonth == previousMonth }) + assertTrue(outDates.all { it.date.yearMonth == nextMonth }) + assertTrue(monthDates.all { it.date.yearMonth == october2022 }) + } + + @Test + @JsName("test7") + fun `days are in the appropriate week columns`() { + val monthData = getHeatMapCalendarMonthData(october2022, 0, firstDayOfWeek) + val daysOfWeek = daysOfWeek(firstDayOfWeek) + + monthData.calendarMonth.weekDays.forEach { week -> + week.forEachIndexed { index, day -> + assertEquals(daysOfWeek[index], day.date.dayOfWeek) + } + } + } + + @Test + @JsName("test8") + fun `generated month is at the correct offset`() { + val novemberMonthData = getHeatMapCalendarMonthData(october2022, 1, firstDayOfWeek) + val decemberMonthData = getHeatMapCalendarMonthData(october2022, 2, firstDayOfWeek) + + assertEquals(november2022, novemberMonthData.calendarMonth.yearMonth) + assertEquals(december2022, decemberMonthData.calendarMonth.yearMonth) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/MonthDataTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/MonthDataTest.kt new file mode 100644 index 00000000..0c53c23a --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/MonthDataTest.kt @@ -0,0 +1,175 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.yearMonth +import com.kizitonwose.calendar.utils.nextMonth +import com.kizitonwose.calendar.utils.previousMonth +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MonthDataTest { + private val may2019 = YearMonth(2019, Month.MAY) + private val november2019 = YearMonth(2019, Month.NOVEMBER) + private val firstDayOfWeek = DayOfWeek.MONDAY + + /** May and November 2019 with Monday as the first day of week. + * ┌────────────────────┐ ┌────────────────────┐ + * │ May 2019 │ │ November 2019 │ + * ├──┬──┬──┬──┬──┬──┬──┤ ├──┬──┬──┬──┬──┬──┬──┤ + * │Mo│Tu│We│Th│Fr│Sa│Su│ │Mo│Tu│We│Th│Fr│Sa│Su│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │29│30│01│02│03│04│05│ │28│29│30│31│01│02│03│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │06│07│08│09│10│11│12│ │04│05│06│07│08│09│10│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │13│14│15│16│17│18│19│ │11│12│13│14│15│16│17│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │20│21│22│23│24│25│26│ │18│19│20│21│22│23│24│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │27│28│29│30│31│01│02│ │25│26│27│28│29│30│01│ + * └──┴──┴──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┴──┴──┘ + **/ + + @Test + @JsName("test1") + fun `number of day positions are accurate with EndOfRow OutDateStyle`() { + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfRow) + val days = monthData.calendarMonth.weekDays.flatten() + assertEquals(2, days.count { it.position == DayPosition.InDate }) + assertEquals(2, days.count { it.position == DayPosition.OutDate }) + assertEquals(31, days.count { it.position == DayPosition.MonthDate }) + assertEquals(35, days.count()) + assertEquals(5, monthData.calendarMonth.weekDays.count()) + monthData.calendarMonth.weekDays.forEach { weekDays -> + assertEquals(7, weekDays.count()) + } + } + + @Test + @JsName("test2") + fun `number of day positions are accurate with EndOfGrid OutDateStyle`() { + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfGrid) + val days = monthData.calendarMonth.weekDays.flatten() + assertEquals(2, days.count { it.position == DayPosition.InDate }) + assertEquals(9, days.count { it.position == DayPosition.OutDate }) + assertEquals(31, days.count { it.position == DayPosition.MonthDate }) + assertEquals(42, days.count()) + assertEquals(6, monthData.calendarMonth.weekDays.count()) + monthData.calendarMonth.weekDays.forEach { weekDays -> + assertEquals(7, weekDays.count()) + } + } + + @Test + @JsName("test3") + fun `dates are in the correct positions with EndOfRow OutDateStyle`() { + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfRow) + val days = monthData.calendarMonth.weekDays.flatten() + + val inDates = days.take(2) + val outDates = days.takeLast(2) + val monthDates = days.drop(2).dropLast(2) + + assertTrue(inDates.all { it.position == DayPosition.InDate }) + assertTrue(outDates.all { it.position == DayPosition.OutDate }) + assertTrue(monthDates.all { it.position == DayPosition.MonthDate }) + } + + @Test + @JsName("test14") + fun `dates are in the correct positions with EndOfGrid OutDateStyle`() { + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfGrid) + val days = monthData.calendarMonth.weekDays.flatten() + + val inDates = days.take(2) + val outDates = days.takeLast(2) + val monthDates = days.drop(2).dropLast(9) + + assertTrue(inDates.all { it.position == DayPosition.InDate }) + assertTrue(outDates.all { it.position == DayPosition.OutDate }) + assertTrue(monthDates.all { it.position == DayPosition.MonthDate }) + } + + @Test + @JsName("test5") + fun `dates have the correct month values`() { + val previousMonth = may2019.previousMonth + val nextMonth = may2019.nextMonth + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfRow) + val days = monthData.calendarMonth.weekDays.flatten() + + val inDates = days.take(2) + val outDates = days.takeLast(2) + val monthDates = days.drop(2).dropLast(2) + + assertTrue(inDates.all { it.date.yearMonth == previousMonth }) + assertTrue(outDates.all { it.date.yearMonth == nextMonth }) + assertTrue(monthDates.all { it.date.yearMonth == may2019 }) + } + + @Test + @JsName("test6") + fun `end of row out date style does not add a new row`() { + val endOfRowMonthData = + getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfRow) + + assertEquals(5, endOfRowMonthData.calendarMonth.weekDays.count()) + } + + @Test + @JsName("test7") + fun `end of grid out date style adds a new row`() { + val endOfGridMonthData = + getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfGrid) + + assertEquals(endOfGridMonthData.calendarMonth.weekDays.count(), 6) + endOfGridMonthData.calendarMonth.weekDays.last().forEach { day -> + assertEquals(DayPosition.OutDate, day.position) + assertEquals(may2019.nextMonth, day.date.yearMonth) + } + } + + @Test + @JsName("test8") + fun `days are in the appropriate week columns`() { + val monthData = getCalendarMonthData(may2019, 0, firstDayOfWeek, OutDateStyle.EndOfRow) + val daysOfWeek = daysOfWeek(firstDayOfWeek) + + monthData.calendarMonth.weekDays.forEach { week -> + week.forEachIndexed { index, day -> + assertEquals(daysOfWeek[index], day.date.dayOfWeek) + } + } + } + + @Test + @JsName("test9") + fun `generated month is at the correct offset`() { + val monthData = getCalendarMonthData(may2019, 6, firstDayOfWeek, OutDateStyle.EndOfRow) + + assertEquals(november2019, monthData.calendarMonth.yearMonth) + } + + @Test + @JsName("test10") + fun `month index calculation works as expected`() { + val index = getMonthIndex(startMonth = may2019, targetMonth = november2019) + + assertEquals(6, index) + } + + @Test + @JsName("test11") + fun `month indices count calculation works as expected`() { + val count = getMonthIndicesCount(startMonth = may2019, endMonth = november2019) + + assertEquals(7, count) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/WeekDataTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/WeekDataTest.kt new file mode 100644 index 00000000..bfa9b0c3 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/WeekDataTest.kt @@ -0,0 +1,138 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.WeekDayPosition +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.atDay +import com.kizitonwose.calendar.core.daysOfWeek +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WeekDataTest { + private val may2019 = YearMonth(2019, Month.MAY) + private val november2019 = YearMonth(2019, Month.NOVEMBER) + private val firstDayOfWeek = DayOfWeek.MONDAY + + /** May and November 2019 with Monday as the first day of week. + * ┌────────────────────┐ ┌────────────────────┐ + * │ May 2019 │ │ November 2019 │ + * ├──┬──┬──┬──┬──┬──┬──┤ ├──┬──┬──┬──┬──┬──┬──┤ + * │Mo│Tu│We│Th│Fr│Sa│Su│ │Mo│Tu│We│Th│Fr│Sa│Su│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │29│30│01│02│03│04│05│ │28│29│30│31│01│02│03│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │06│07│08│09│10│11│12│ │04│05│06│07│08│09│10│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │13│14│15│16│17│18│19│ │11│12│13│14│15│16│17│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │20│21│22│23│24│25│26│ │18│19│20│21│22│23│24│ + * ├──┼──┼──┼──┼──┼──┼──┤ ├──┼──┼──┼──┼──┼──┼──┤ + * │27│28│29│30│31│01│02│ │25│26│27│28│29│30│01│ + * └──┴──┴──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┴──┴──┘ + **/ + + @Test + @JsName("test1") + fun `date range adjustment works as expected`() { + val may01 = may2019.atDay(1) + val nov01 = november2019.atDay(1) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, nov01, firstDayOfWeek) + + assertEquals(LocalDate(2019, Month.APRIL, 29), adjustedWeekRange.startDateAdjusted) + assertEquals(LocalDate(2019, Month.NOVEMBER, 3), adjustedWeekRange.endDateAdjusted) + } + + @Test + @JsName("test2") + fun `week data generation works as expected`() { + val may01 = may2019.atDay(1) + val nov01 = november2019.atDay(1) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, nov01, firstDayOfWeek) + val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 0, may01, nov01).week + + assertEquals(LocalDate(2019, Month.APRIL, 29), week.days.first().date) + assertEquals(LocalDate(2019, Month.MAY, 5), week.days.last().date) + } + + @Test + @JsName("test3") + fun `week in date generation works as expected`() { + val may01 = may2019.atDay(1) + val nov01 = november2019.atDay(1) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, nov01, firstDayOfWeek) + val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 0, may01, nov01).week + + val inDates = week.days.take(2) + val rangeDays = week.days.takeLast(5) + assertTrue(inDates.all { it.position == WeekDayPosition.InDate }) + assertTrue(rangeDays.all { it.position == WeekDayPosition.RangeDate }) + assertEquals(7, week.days.count()) + } + + @Test + @JsName("test4") + fun `week out date generation works as expected`() { + val may01 = may2019.atDay(1) + val may31 = may2019.atDay(31) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, may31, firstDayOfWeek) + val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 4, may01, may31).week + + val outDates = week.days.takeLast(2) + val rangeDays = week.days.take(5) + assertTrue(outDates.all { it.position == WeekDayPosition.OutDate }) + assertTrue(rangeDays.all { it.position == WeekDayPosition.RangeDate }) + assertEquals(7, week.days.count()) + } + + @Test + @JsName("test5") + fun `days are in the appropriate week columns`() { + val may01 = may2019.atDay(2) + val may31 = may2019.atDay(31) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, may31, firstDayOfWeek) + val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 0, may01, may31).week + + val daysOfWeek = daysOfWeek(firstDayOfWeek) + week.days.forEachIndexed { index, day -> + assertEquals(daysOfWeek[index], day.date.dayOfWeek) + } + } + + @Test + @JsName("test6") + fun `generated week is at the correct offset`() { + val may01 = may2019.atDay(2) + val may31 = may2019.atDay(31) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, may31, firstDayOfWeek) + val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 2, may01, may31).week + + assertEquals(may2019.atDay(13), week.days.first().date) + assertEquals(may2019.atDay(19), week.days.last().date) + } + + @Test + @JsName("test7") + fun `week index calculation works as expected`() { + val may01 = may2019.atDay(2) + val may31 = may2019.atDay(31) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, may31, firstDayOfWeek) + val index = getWeekIndex(adjustedWeekRange.startDateAdjusted, may31) + + assertEquals(4, index) + } + + @Test + @JsName("test8") + fun `week indices count calculation works as expected`() { + val may01 = may2019.atDay(2) + val may31 = may2019.atDay(31) + val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, may31, firstDayOfWeek) + val count = getWeekIndicesCount(adjustedWeekRange.startDateAdjusted, may31) + + assertEquals(5, count) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/YearDataTest.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/YearDataTest.kt new file mode 100644 index 00000000..da9ee0c4 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/data/YearDataTest.kt @@ -0,0 +1,100 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.utils.weeksInMonth +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.Month +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.test.assertEquals + +class YearDataTest { + @Test + @JsName("test1") + fun `year data is accurate with non-leap year`() { + val year = Year(2019) + val firstDayOfWeek = DayOfWeek.MONDAY + val outDateStyle = OutDateStyle.EndOfRow + val yearData = getCalendarYearData(year, 0, firstDayOfWeek, outDateStyle) + val months = yearData.months + val days = yearData.months.flatMap { it.weekDays }.flatten() + yearData.months.forEachIndexed { index, month -> + val monthData = getCalendarMonthData( + startMonth = YearMonth(year.value, Month.JANUARY), + offset = month.yearMonth.month.ordinal, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + assertEquals(monthData.calendarMonth, month) + assertEquals(Month.entries[Month.JANUARY.ordinal + index], month.yearMonth.month) + assertEquals(month.yearMonth.weeksInMonth(firstDayOfWeek), month.weekDays.count()) + } + assertEquals(12, months.count()) + assertEquals(year, yearData.year) + assertEquals(36, days.count { it.position == DayPosition.InDate }) + assertEquals(33, days.count { it.position == DayPosition.OutDate }) + assertEquals(365, days.count { it.position == DayPosition.MonthDate }) + } + + @Test + @JsName("test2") + fun `year data is accurate with leap year`() { + val year = Year(2020) + val firstDayOfWeek = DayOfWeek.SUNDAY + val outDateStyle = OutDateStyle.EndOfGrid + val yearData = getCalendarYearData(year, 0, firstDayOfWeek, outDateStyle) + val months = yearData.months + val days = yearData.months.flatMap { it.weekDays }.flatten() + yearData.months.forEachIndexed { index, month -> + val monthData = getCalendarMonthData( + startMonth = YearMonth(year.value, Month.JANUARY), + offset = month.yearMonth.month.ordinal, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + assertEquals(monthData.calendarMonth, month) + assertEquals(Month.entries[Month.JANUARY.ordinal + index], month.yearMonth.month) + assertEquals(6, month.weekDays.count()) + val weeksWithoutGridOutDates = month.weekDays.filterNot { week -> week.all { it.position == DayPosition.OutDate } } + assertEquals(month.yearMonth.weeksInMonth(firstDayOfWeek), weeksWithoutGridOutDates.count()) + } + assertEquals(12, months.count()) + assertEquals(year, yearData.year) + assertEquals(35, days.count { it.position == DayPosition.InDate }) + assertEquals(103, days.count { it.position == DayPosition.OutDate }) + assertEquals(366, days.count { it.position == DayPosition.MonthDate }) + } + + @Test + @JsName("test3") + fun `generated year is at the correct offset`() { + val yearData = getCalendarYearData(Year(2020), 6, DayOfWeek.SUNDAY, OutDateStyle.EndOfGrid) + val yearData2 = getCalendarYearData(Year(2021), 0, DayOfWeek.SUNDAY, OutDateStyle.EndOfRow) + + assertEquals(yearData.year, Year(2026)) + assertEquals(yearData2.year, Year(2021)) + } + + @Test + @JsName("test4") + fun `year index calculation works as expected`() { + val index = getYearIndex(startYear = Year(2020), targetYear = Year(2030)) + val index2 = getYearIndex(startYear = Year(2052), targetYear = Year(2052)) + + assertEquals(10, index) + assertEquals(0, index2) + } + + @Test + @JsName("test5") + fun `year indices count calculation works as expected`() { + val count = getYearIndicesCount(startYear = Year(2020), endYear = Year(2040)) + val count2 = getYearIndicesCount(startYear = Year(2052), endYear = Year(2052)) + + assertEquals(21, count) + assertEquals(1, count2) + } +} diff --git a/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/utils/Utils.kt b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/utils/Utils.kt new file mode 100644 index 00000000..ed4f88c8 --- /dev/null +++ b/compose-multiplatform/library/src/commonTest/kotlin/com/kizitonwose/calendar/utils/Utils.kt @@ -0,0 +1,23 @@ +package com.kizitonwose.calendar.utils + +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.plusMonths +import com.kizitonwose.calendar.data.getCalendarMonthData +import kotlinx.datetime.DayOfWeek + +internal infix fun Pair.toTriple(that: C): Triple = Triple(first, second, that) + +internal fun YearMonth.weeksInMonth(firstDayOfWeek: DayOfWeek) = getCalendarMonthData( + startMonth = this, + offset = 0, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = OutDateStyle.EndOfRow, +).calendarMonth.weekDays.count() + +internal val YearMonth.nextMonth: YearMonth + get() = this.plusMonths(1) + +internal val YearMonth.previousMonth: YearMonth + get() = this.minusMonths(1) diff --git a/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/Converters.kt b/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/Converters.kt index c78263e5..09312a83 100644 --- a/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/Converters.kt +++ b/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/Converters.kt @@ -1,7 +1,12 @@ package com.kizitonwose.calendar.core +import java.time.Year as jtYear import java.time.YearMonth as jtYearMonth public fun YearMonth.toJavaYearMonth(): jtYearMonth = jtYearMonth.of(year, month) public fun jtYearMonth.toKotlinYearMonth(): YearMonth = YearMonth(year, month) + +public fun Year.toJavaYear(): jtYear = jtYear.of(value) + +public fun jtYear.toKotlinYear(): Year = Year(value) diff --git a/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.jvm.kt b/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.jvm.kt deleted file mode 100644 index 199695a3..00000000 --- a/compose-multiplatform/library/src/jvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.jvm.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.kizitonwose.calendar.core - -public actual typealias JvmSerializable = java.io.Serializable diff --git a/compose-multiplatform/library/src/nonJvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.nonJvm.kt b/compose-multiplatform/library/src/nonJvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.nonJvm.kt deleted file mode 100644 index 7147a4bd..00000000 --- a/compose-multiplatform/library/src/nonJvmMain/kotlin/com/kizitonwose/calendar/core/JvmSerializable.nonJvm.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.kizitonwose.calendar.core - -public actual interface JvmSerializable diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/App.kt b/compose-multiplatform/sample/src/commonMain/kotlin/App.kt index 33d27d97..abcc1230 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/App.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/App.kt @@ -39,10 +39,9 @@ import kotlin.math.roundToInt @Composable @Preview fun App() { - MaterialTheme(MaterialTheme.colorScheme.copy(primary = Colors.primary)) { + MaterialTheme(SampleColorScheme) { BoxWithConstraints( - Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, + modifier = Modifier.fillMaxSize(), ) { if (maxWidth >= 600.dp) { val widthPx = maxWidth.value.roundToInt() @@ -170,7 +169,10 @@ private fun AppNavHost( horizontallyAnimatedComposable(Page.Example5.name) { Example5Page { navController.popBackStack() } } horizontallyAnimatedComposable(Page.Example6.name) { Example6Page() } horizontallyAnimatedComposable(Page.Example7.name) { Example7Page() } + horizontallyAnimatedComposable(Page.Example8.name) { Example8Page { navController.popBackStack() } } horizontallyAnimatedComposable(Page.Example9.name) { Example9Page() } + horizontallyAnimatedComposable(Page.Example10.name) { Example10Page() } + horizontallyAnimatedComposable(Page.Example11.name) { Example11Page() } } } diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Colors.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Colors.kt index fcb1535a..f17b67d2 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Colors.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Colors.kt @@ -2,9 +2,12 @@ import androidx.compose.ui.graphics.Color object Colors { val example1Selection = Color(0xFFFCCA3E) - val inactiveText = Color(0xFFBEBEBE) - val example4Gray = Color(0xFF474747) + val example1Bg = Color(0xFF3A284C) + val example1BgLight = Color(0xFF433254) + val example1BgSecondary = Color(0xFF51356E) + val example1WhiteLight = Color(0x4DFFFFFF) val example4GrayPast = Color(0xFFBEBEBE) + val example4Gray = Color(0xFF474747) val example5PageBgColor = Color(0xFF0E0E0E) val example5ItemViewBgColor = Color(0xFF1B1B1B) val example5ToolbarColor = Color(0xFF282828) diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/ContinuousSelectionHelper.kt b/compose-multiplatform/sample/src/commonMain/kotlin/ContinuousSelectionHelper.kt index 4604cd06..e8fdaabb 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/ContinuousSelectionHelper.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/ContinuousSelectionHelper.kt @@ -1,7 +1,5 @@ import com.kizitonwose.calendar.core.atEndOfMonth import com.kizitonwose.calendar.core.atStartOfMonth -import com.kizitonwose.calendar.core.next -import com.kizitonwose.calendar.core.previous import com.kizitonwose.calendar.core.yearMonth import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example10Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example10Page.kt new file mode 100644 index 00000000..0cacf2e6 --- /dev/null +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example10Page.kt @@ -0,0 +1,281 @@ + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.HorizontalYearCalendar +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.minusYears +import com.kizitonwose.calendar.core.plusYears +import com.kizitonwose.calendar.core.yearsUntil +import kotlinx.coroutines.launch +import org.jetbrains.compose.ui.tooling.preview.Preview +import kotlin.math.abs + +@OptIn(ExperimentalCalendarApi::class) +@Composable +fun Example10Page(adjacentYears: Int = 50) { + val currentYear = remember { Year.now() } + val startYear = remember { currentYear.minusYears(adjacentYears) } + val endYear = remember { currentYear.plusYears(adjacentYears) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val isTablet = maxWidth >= 600.dp + val isPortrait = maxHeight > maxWidth + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + ) { + val scope = rememberCoroutineScope() + val state = rememberYearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = currentYear, + firstDayOfWeek = daysOfWeek.first(), + ) + val visibleYear = rememberFirstVisibleYearAfterScroll(state).year + val headerState = rememberLazyListState() + LaunchedEffect(visibleYear) { + val index = startYear.yearsUntil(visibleYear) + headerState.animateScrollAndCenterItem(index) + } + YearHeader( + startYear = startYear, + endYear = endYear, + visibleYear = visibleYear, + headerState = headerState, + isTablet = isTablet, + ) click@{ targetYear -> + if (targetYear == visibleYear) return@click + scope.launch { + if (abs(visibleYear.yearsUntil(targetYear)) <= 8) { + state.animateScrollToYear(targetYear) + } else { + val nearbyYear = if (targetYear > visibleYear) { + targetYear.minusYears(5) + } else { + targetYear.plusYears(5) + } + state.scrollToYear(nearbyYear) + state.animateScrollToYear(targetYear) + } + } + } + HorizontalYearCalendar( + modifier = Modifier + .fillMaxSize() + .testTag("Calendar"), + state = state, + columns = if (isPortrait) { + 3 + } else { + if (isTablet) 4 else 6 + }, + dayContent = { day -> + Day( + day = day, + isSelected = selections.contains(day), + isTablet = isTablet, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + contentHeightMode = YearContentHeightMode.Fill, + monthHorizontalSpacing = if (isTablet) { + if (isPortrait) 52.dp else 92.dp + } else { + 10.dp + }, + monthVerticalSpacing = if (isTablet) 20.dp else 4.dp, + yearBodyContentPadding = if (isTablet) { + PaddingValues(horizontal = if (isPortrait) 52.dp else 92.dp, vertical = 20.dp) + } else { + PaddingValues(all = 10.dp) + }, + monthHeader = { + MonthHeader( + calendarMonth = it, + isTablet = isTablet, + ) + }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun YearHeader( + startYear: Year, + endYear: Year, + visibleYear: Year, + headerState: LazyListState, + isTablet: Boolean, + modifier: Modifier = Modifier, + onClick: (Year) -> Unit, +) { + LazyRow( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(headerBackground), + state = headerState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = headerState, SnapPosition.Center), + contentPadding = PaddingValues(horizontal = if (isTablet) 40.dp else 10.dp), + ) { + items(count = startYear.yearsUntil(endYear)) { index -> + val year = startYear.plusYears(index) + val isSelected = visibleYear == year + Box( + modifier = Modifier + .then( + if (isSelected) { + Modifier.background( + color = simpleTextBackground(isSelected = true), + shape = RoundedCornerShape(4.dp), + ) + } else { + Modifier + }, + ) + .clickable(onClick = { onClick(year) }) + .padding( + horizontal = if (isTablet) 60.dp else 28.dp, + vertical = if (isTablet) 10.dp else 6.dp, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = year.value.toString(), + textAlign = TextAlign.Center, + fontSize = if (isTablet) 24.sp else 18.sp, + color = simpleTextColor(isSelected), + fontWeight = if (isSelected) FontWeight.Black else FontWeight.Light, + ) + } + } + } +} + +@Composable +private fun MonthHeader( + calendarMonth: CalendarMonth, + isTablet: Boolean, + modifier: Modifier = Modifier, +) { + val daysOfWeek = calendarMonth.weekDays.first().map { it.date.dayOfWeek } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(if (isTablet) 12.dp else 8.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = calendarMonth.yearMonth.month.displayText(short = false), + fontSize = if (isTablet) 16.sp else 12.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = if (isTablet) 11.sp else 9.sp, + text = dayOfWeek.displayText(uppercase = true, narrow = true), + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun Day( + day: CalendarDay, + isSelected: Boolean, + isTablet: Boolean, + onClick: (CalendarDay) -> Unit, +) { + Box( + modifier = Modifier + .aspectRatio(1f) // This is important for square-sizing! + .testTag("MonthDay") + .padding(if (isTablet) 2.dp else 0.dp) + .clip(CircleShape) + .background(simpleTextBackground(isSelected)) + // Disable clicks on inDates/outDates + .clickable( + enabled = day.position == DayPosition.MonthDate, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + if (day.position == DayPosition.MonthDate) { + Text( + text = day.date.dayOfMonth.toString(), + fontSize = if (isTablet) 11.sp else 9.sp, + color = simpleTextColor(isSelected), + ) + } + } +} + +@Preview +@Composable +private fun Example10Preview() { + Example10Page() +} + +private val headerBackground = Color(0xFFF1F1F1) +private fun simpleTextColor(isSelected: Boolean) = + if (isSelected) Color.White else Color.Black + +private fun simpleTextBackground(isSelected: Boolean) = + if (isSelected) Color.Black else Color.White diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example11Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example11Page.kt new file mode 100644 index 00000000..a526d1d7 --- /dev/null +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example11Page.kt @@ -0,0 +1,188 @@ +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.VerticalYearCalendar +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.Year +import com.kizitonwose.calendar.core.YearMonth +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.plusYears +import org.jetbrains.compose.ui.tooling.preview.Preview + +@OptIn(ExperimentalCalendarApi::class) +@Composable +fun Example11Page(adjacentYears: Int = 50) { + val currentMonth = remember { YearMonth.now() } + val currentYear = remember { Year(currentMonth.year) } + val endYear = remember { currentYear.plusYears(adjacentYears) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val isTablet = maxWidth >= 600.dp + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + ) { + val state = rememberYearCalendarState( + startYear = currentYear, + endYear = endYear, + firstVisibleYear = currentYear, + firstDayOfWeek = daysOfWeek.first(), + ) + VerticalYearCalendar( + modifier = Modifier + .fillMaxSize() + .testTag("Calendar"), + state = state, + dayContent = { day -> + Day( + day = day, + isSelected = selections.contains(day), + isTablet = isTablet, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + calendarScrollPaged = false, + contentHeightMode = YearContentHeightMode.Wrap, + monthVerticalSpacing = 20.dp, + monthHorizontalSpacing = if (isTablet) 52.dp else 10.dp, + contentPadding = PaddingValues(horizontal = if (isTablet) 52.dp else 10.dp), + isMonthVisible = { + it.yearMonth >= currentMonth + }, + yearHeader = { + YearHeader(it.year) + }, + monthHeader = { + MonthHeader(it) + }, + ) + } + } +} + +@Composable +private fun MonthHeader(calendarMonth: CalendarMonth) { + val daysOfWeek = calendarMonth.weekDays.first().map { it.date.dayOfWeek } + Column( + modifier = Modifier + .wrapContentHeight() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp), + text = calendarMonth.yearMonth.month.displayText(short = false), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 11.sp, + text = dayOfWeek.displayText(uppercase = true, narrow = true), + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +@Composable +private fun YearHeader(year: Year) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 20.dp) + .testTag("MonthHeader"), + fontSize = 52.sp, + text = year.toString(), + fontWeight = FontWeight.Medium, + ) + HorizontalDivider() + } +} + +@Composable +private fun Day( + day: CalendarDay, + isSelected: Boolean, + isTablet: Boolean, + onClick: (CalendarDay) -> Unit, +) { + Box( + modifier = Modifier + .aspectRatio(1f) // This is important for square-sizing! + .testTag("MonthDay") + .padding(if (isTablet) 2.dp else 0.dp) + .clip(CircleShape) + .background(color = if (isSelected) Colors.example1Selection else Color.Transparent) + // Disable clicks on inDates/outDates + .clickable( + enabled = day.position == DayPosition.MonthDate, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + if (day.position == DayPosition.MonthDate) { + Text( + text = day.date.dayOfMonth.toString(), + fontSize = if (isTablet) 10.sp else 9.sp, + color = if (isSelected) Color.White else Color.Unspecified, + ) + } + } +} + +@Preview +@Composable +private fun Example11Preview() { + Example11Page() +} diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example1Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example1Page.kt index 316baaa2..eca27eed 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example1Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example1Page.kt @@ -27,8 +27,8 @@ import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.daysOfWeek -import com.kizitonwose.calendar.core.next -import com.kizitonwose.calendar.core.previous +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.plusMonths import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek import org.jetbrains.compose.ui.tooling.preview.Preview @@ -125,7 +125,7 @@ private fun Day(day: CalendarDay, isSelected: Boolean, onClick: (CalendarDay) -> val textColor = when (day.position) { // Color.Unspecified will use the default text color from the current theme DayPosition.MonthDate -> if (isSelected) Color.White else Color.Unspecified - DayPosition.InDate, DayPosition.OutDate -> Colors.inactiveText + DayPosition.InDate, DayPosition.OutDate -> Colors.example4GrayPast } Text( text = day.date.dayOfMonth.toString(), diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example2Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example2Page.kt index c8356c50..a050dd56 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example2Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example2Page.kt @@ -46,6 +46,7 @@ import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.daysOfWeek import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusMonths import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import org.jetbrains.compose.ui.tooling.preview.Preview diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example2PageHighlight.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example2PageHighlight.kt index 82c1d7cf..03f78536 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example2PageHighlight.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example2PageHighlight.kt @@ -54,7 +54,7 @@ fun Modifier.backgroundHighlight( DayPosition.MonthDate -> { when { day.date < today -> { - textColor(Colors.inactiveText) + textColor(Colors.example4GrayPast) this } @@ -98,7 +98,7 @@ fun Modifier.backgroundHighlight( .border( width = 1.dp, shape = CircleShape, - color = Colors.inactiveText, + color = Colors.example4GrayPast, ) } @@ -111,7 +111,8 @@ fun Modifier.backgroundHighlight( DayPosition.InDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isInDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -123,7 +124,8 @@ fun Modifier.backgroundHighlight( DayPosition.OutDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isOutDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -152,7 +154,7 @@ fun Modifier.backgroundHighlightLegacy( DayPosition.MonthDate -> { when { day.date < today -> { - textColor(Colors.inactiveText) + textColor(Colors.example4GrayPast) this } @@ -195,7 +197,7 @@ fun Modifier.backgroundHighlightLegacy( .border( width = 1.dp, shape = CircleShape, - color = Colors.inactiveText, + color = Colors.example4GrayPast, ) } @@ -208,7 +210,8 @@ fun Modifier.backgroundHighlightLegacy( DayPosition.InDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isInDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -220,7 +223,8 @@ fun Modifier.backgroundHighlightLegacy( DayPosition.OutDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isOutDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example3Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example3Page.kt index 351205df..70f1441a 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example3Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example3Page.kt @@ -1,3 +1,4 @@ + import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -24,7 +25,6 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -48,8 +48,8 @@ import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.OutDateStyle import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.daysOfWeek -import com.kizitonwose.calendar.core.next -import com.kizitonwose.calendar.core.previous +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.plusMonths import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek import org.jetbrains.compose.ui.tooling.preview.Preview @@ -95,7 +95,7 @@ fun Example3Page(close: () -> Unit = {}) { } // Draw light content on dark background. - CompositionLocalProvider(LocalContentColor provides darkColorScheme().onSurface) { + CompositionLocalProvider(LocalContentColor provides Color.White) { SimpleCalendarTitle( modifier = Modifier .background(toolbarColor) diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example4Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example4Page.kt index c57b5c97..e08e587e 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example4Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example4Page.kt @@ -1,3 +1,4 @@ + import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -33,7 +34,7 @@ import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusMonths import org.jetbrains.compose.ui.tooling.preview.Preview @Composable diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example5Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example5Page.kt index 6298e1a6..9a495f6d 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example5Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example5Page.kt @@ -23,7 +23,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kizitonwose.calendar.compose.WeekCalendar import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import com.kizitonwose.calendar.core.minusDays import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays import kotlinx.datetime.LocalDate import kotlinx.datetime.format.Padding import org.jetbrains.compose.ui.tooling.preview.Preview diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example6Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example6Page.kt index 0350e8aa..f61f9b51 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example6Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example6Page.kt @@ -41,6 +41,7 @@ import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays import com.kizitonwose.calendar.core.yearMonth import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -273,8 +274,10 @@ private fun getMonthWithYear( val firstItem = visibleItemsInfo.first() val daySizePx = with(density) { daySize.toPx() } if ( - firstItem.size < daySizePx * 3 || // Ensure the Month + Year text can fit. - firstItem.offset < layoutInfo.viewportStartOffset && // Ensure the week row size - 1 is visible. + // Ensure the Month + Year text can fit. + firstItem.size < daySizePx * 3 || + // Ensure the week row size - 1 is visible. + firstItem.offset < layoutInfo.viewportStartOffset && (layoutInfo.viewportStartOffset - firstItem.offset > daySizePx) ) { visibleItemsInfo[1].month.yearMonth diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example7Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example7Page.kt index aa93c795..d6cb43ba 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example7Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example7Page.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Text -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -29,7 +28,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kizitonwose.calendar.compose.WeekCalendar import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import com.kizitonwose.calendar.core.minusDays import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusDays import kotlinx.datetime.LocalDate import kotlinx.datetime.format.Padding import org.jetbrains.compose.ui.tooling.preview.Preview @@ -51,7 +52,7 @@ fun Example7Page() { firstVisibleWeekDate = currentDate, ) // Draw light content on dark background. - CompositionLocalProvider(LocalContentColor provides darkColorScheme().onSurface) { + CompositionLocalProvider(LocalContentColor provides Color.White) { WeekCalendar( modifier = Modifier.padding(vertical = 4.dp), state = state, diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example8Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example8Page.kt new file mode 100644 index 00000000..414765b5 --- /dev/null +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example8Page.kt @@ -0,0 +1,296 @@ + +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.CalendarState +import com.kizitonwose.calendar.compose.ContentHeightMode +import com.kizitonwose.calendar.compose.HorizontalCalendar +import com.kizitonwose.calendar.compose.VerticalCalendar +import com.kizitonwose.calendar.compose.rememberCalendarState +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusMonths +import com.kizitonwose.calendar.core.yearMonth +import kotlinx.coroutines.launch +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun Example8Page(close: () -> Unit = {}) { + val today = remember { LocalDate.now() } + val currentMonth = remember(today) { today.yearMonth } + val startMonth = remember { currentMonth.minusMonths(500) } + val endMonth = remember { currentMonth.plusMonths(500) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + Column( + modifier = Modifier + .fillMaxSize() + .background(Colors.example1BgLight) + .padding(top = 20.dp), + ) { + // Draw light content on dark background. + CompositionLocalProvider(LocalContentColor provides Color.White) { + var selectedIndex by remember { mutableIntStateOf(0) } + PageOptions(selectedIndex, close = close) { selectedIndex = it } + val state = rememberCalendarState( + startMonth = startMonth, + endMonth = endMonth, + firstVisibleMonth = currentMonth, + firstDayOfWeek = daysOfWeek.first(), + outDateStyle = OutDateStyle.EndOfGrid, + ) + val coroutineScope = rememberCoroutineScope() + val visibleMonth = rememberFirstVisibleMonthAfterScroll(state) + SimpleCalendarTitle( + modifier = Modifier.padding(bottom = 14.dp, top = 4.dp, start = 8.dp, end = 8.dp), + isHorizontal = selectedIndex == 0, + currentMonth = visibleMonth.yearMonth, + goToPrevious = { + coroutineScope.launch { + state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.previous) + } + }, + goToNext = { + coroutineScope.launch { + state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.next) + } + }, + ) + FullScreenCalendar( + modifier = Modifier + .fillMaxSize() + .background(Colors.example1Bg) + .testTag("Calendar"), + state = state, + horizontal = selectedIndex == 0, + dayContent = { day -> + Day( + day = day, + isSelected = selections.contains(day), + isToday = day.position == DayPosition.MonthDate && day.date == today, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + // The month body is only needed for ui test tag. + monthBody = { _, content -> + Box( + modifier = Modifier + .weight(1f) + .testTag("MonthBody"), + ) { + content() + } + }, + monthHeader = { + MonthHeader(daysOfWeek = daysOfWeek) + }, + monthFooter = { month -> + val count = month.weekDays.flatten() + .count { selections.contains(it) } + MonthFooter(selectionCount = count) + }, + ) + } + } +} + +@Composable +private fun FullScreenCalendar( + modifier: Modifier, + state: CalendarState, + horizontal: Boolean, + dayContent: @Composable BoxScope.(CalendarDay) -> Unit, + monthHeader: @Composable ColumnScope.(CalendarMonth) -> Unit, + monthBody: @Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit, + monthFooter: @Composable ColumnScope.(CalendarMonth) -> Unit, +) { + if (horizontal) { + HorizontalCalendar( + modifier = modifier, + state = state, + calendarScrollPaged = true, + contentHeightMode = ContentHeightMode.Fill, + dayContent = dayContent, + monthBody = monthBody, + monthHeader = monthHeader, + monthFooter = monthFooter, + ) + } else { + VerticalCalendar( + modifier = modifier, + state = state, + calendarScrollPaged = true, + contentHeightMode = ContentHeightMode.Fill, + dayContent = dayContent, + monthBody = monthBody, + monthHeader = monthHeader, + monthFooter = monthFooter, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PageOptions(selectedIndex: Int, close: () -> Unit = {}, onSelect: (Int) -> Unit) { + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!isMobile()) { + Button( + onClick = close, + colors = ButtonDefaults.buttonColors().copy(containerColor = Colors.example1Bg), + ) { + Text("Close") + } + } + val options = listOf("Horizontal", "Vertical") + SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) { + options.forEachIndexed { index, label -> + SegmentedButton( + colors = SegmentedButtonDefaults.colors().copy( + activeContainerColor = Colors.example1Bg, + activeContentColor = Color.White, + inactiveContainerColor = Color.Transparent, + inactiveContentColor = Color.White, + ), + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + onClick = { onSelect(index) }, + selected = index == selectedIndex, + ) { + Text(label) + } + } + } + } +} + +@Composable +private fun MonthHeader(daysOfWeek: List) { + Row( + Modifier + .fillMaxWidth() + .testTag("MonthHeader") + .background(Colors.example1BgSecondary) + .padding(vertical = 8.dp), + ) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 15.sp, + text = dayOfWeek.displayText(), + ) + } + } +} + +@Composable +private fun MonthFooter(selectionCount: Int) { + Box( + Modifier + .fillMaxWidth() + .testTag("MonthFooter") + .background(Colors.example1BgSecondary) + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + val text = when (selectionCount) { + 0 -> "No selections in this month" + 1 -> "$selectionCount selection in this month" + else -> "$selectionCount selections in this month" + } + Text(text = text) + } +} + +@Composable +private fun Day( + day: CalendarDay, + isSelected: Boolean, + isToday: Boolean, + onClick: (CalendarDay) -> Unit, +) { + Box( + Modifier + .fillMaxWidth() + .fillMaxHeight() + .clip(RectangleShape) + .background( + color = when { + isSelected -> Colors.example1Selection + isToday -> Colors.example1WhiteLight + else -> Color.Transparent + }, + ) + // Disable clicks on inDates/outDates + .clickable( + enabled = day.position == DayPosition.MonthDate, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + val textColor = when (day.position) { + // Color.Unspecified will use the default text color from the current theme + DayPosition.MonthDate -> if (isSelected) Colors.example1Bg else Color.Unspecified + DayPosition.InDate, DayPosition.OutDate -> Colors.example1WhiteLight + } + Text( + text = day.date.dayOfMonth.toString(), + color = textColor, + fontSize = 15.sp, + ) + } +} + +@Preview +@Composable +private fun Example8Preview() { + Example8Page() +} diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example9Page.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example9Page.kt index d548d25b..362f93f7 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example9Page.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example9Page.kt @@ -55,9 +55,11 @@ import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.atEndOfMonth import com.kizitonwose.calendar.core.atStartOfMonth import com.kizitonwose.calendar.core.daysOfWeek -import com.kizitonwose.calendar.core.next +import com.kizitonwose.calendar.core.minusDays +import com.kizitonwose.calendar.core.minusMonths import com.kizitonwose.calendar.core.now -import com.kizitonwose.calendar.core.previous +import com.kizitonwose.calendar.core.plusDays +import com.kizitonwose.calendar.core.plusMonths import com.kizitonwose.calendar.core.yearMonth import kotlinx.coroutines.launch import kotlinx.datetime.DayOfWeek @@ -282,7 +284,7 @@ object Example9PageSharedComponents { val textColor = when { isSelected -> Color.White isSelectable -> Color.Unspecified - else -> Colors.inactiveText + else -> Colors.example4GrayPast } Text( text = day.dayOfMonth.toString(), diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Example9PageAnimatedVisibility.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Example9PageAnimatedVisibility.kt index 078b5b74..16a36949 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Example9PageAnimatedVisibility.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Example9PageAnimatedVisibility.kt @@ -28,7 +28,9 @@ import com.kizitonwose.calendar.core.WeekDayPosition import com.kizitonwose.calendar.core.atEndOfMonth import com.kizitonwose.calendar.core.atStartOfMonth import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.core.minusMonths import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.plusMonths import com.kizitonwose.calendar.core.yearMonth import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Flight.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Flight.kt index 8e68fafe..2cbb4087 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Flight.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Flight.kt @@ -1,7 +1,9 @@ + import androidx.compose.ui.graphics.Color import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.atDay -import com.kizitonwose.calendar.core.now +import com.kizitonwose.calendar.core.minusMonths +import com.kizitonwose.calendar.core.plusMonths import kotlinx.datetime.LocalDateTime import kotlinx.datetime.atTime import kotlinx.datetime.format.DayOfWeekNames diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Format.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Format.kt index 405ebb9d..d7ccf16b 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Format.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Format.kt @@ -14,15 +14,15 @@ fun Month.displayText(short: Boolean = true): String { return getDisplayName(short, enLocale) } -fun DayOfWeek.displayText(uppercase: Boolean = false): String { - return getShortDisplayName(enLocale).let { value -> +fun DayOfWeek.displayText(uppercase: Boolean = false, narrow: Boolean = false): String { + return getDisplayName(narrow, enLocale).let { value -> if (uppercase) value.toUpperCase(enLocale) else value } } expect fun Month.getDisplayName(short: Boolean, locale: Locale): String -expect fun DayOfWeek.getShortDisplayName(locale: Locale): String +expect fun DayOfWeek.getDisplayName(narrow: Boolean = false, locale: Locale): String private val enLocale = Locale("en-US") diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/ListPage.kt b/compose-multiplatform/sample/src/commonMain/kotlin/ListPage.kt index b88cffc9..f226b615 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/ListPage.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/ListPage.kt @@ -57,17 +57,26 @@ enum class Page(val title: String, val subtitle: String, val showToolBar: Boolea subtitle = "Week Calendar - Continuous scroll, custom day content width, single selection.", showToolBar = true, ), - - // Example8( -// title = "Example 8", -// subtitle = "Fullscreen Horizontal Calendar - Month header and footer, paged horizontal scrolling. Shows the \"Fill\" option of ContentHeightMode property.", -// showToolBar = false, -// ), - Example9( + Example8( title = "Example 8", + subtitle = "Fullscreen Horizontal Calendar - Month header and footer, paged horizontal scrolling. Shows the \"Fill\" option of ContentHeightMode property.", + showToolBar = false, + ), + Example9( + title = "Example 9", subtitle = "Month and week calendar toggle with animations.", showToolBar = true, ), + Example10( + title = "Example 10", + subtitle = "Horizontal year calendar - Year header and paged scrolling. Best suited for large screens.", + showToolBar = true, + ), + Example11( + title = "Example 11", + subtitle = "Vertical year calendar - Hidden past months with continuous scroll. Best suited for large screens.", + showToolBar = true, + ), } @Composable diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/SimpleCalendarTitle.kt b/compose-multiplatform/sample/src/commonMain/kotlin/SimpleCalendarTitle.kt index 0fe2edbf..f0cf13ef 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/SimpleCalendarTitle.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/SimpleCalendarTitle.kt @@ -1,4 +1,4 @@ - +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -14,9 +14,11 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -30,6 +32,7 @@ import com.kizitonwose.calendar.core.YearMonth fun SimpleCalendarTitle( modifier: Modifier, currentMonth: YearMonth, + isHorizontal: Boolean = true, goToPrevious: () -> Unit, goToNext: () -> Unit, ) { @@ -41,6 +44,7 @@ fun SimpleCalendarTitle( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous", onClick = goToPrevious, + isHorizontal = isHorizontal, ) Text( modifier = Modifier @@ -55,6 +59,7 @@ fun SimpleCalendarTitle( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next", onClick = goToNext, + isHorizontal = isHorizontal, ) } } @@ -63,6 +68,7 @@ fun SimpleCalendarTitle( private fun CalendarNavigationIcon( imageVector: ImageVector, contentDescription: String, + isHorizontal: Boolean = true, onClick: () -> Unit, ) = Box( modifier = Modifier @@ -71,11 +77,13 @@ private fun CalendarNavigationIcon( .clip(shape = CircleShape) .clickable(role = Role.Button, onClick = onClick), ) { + val rotation by animateFloatAsState(if (isHorizontal) 0f else 90f) Icon( modifier = Modifier .fillMaxSize() .padding(4.dp) - .align(Alignment.Center), + .align(Alignment.Center) + .rotate(rotation), imageVector = imageVector, contentDescription = contentDescription, ) diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Theme.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Theme.kt new file mode 100644 index 00000000..ae8d73b6 --- /dev/null +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Theme.kt @@ -0,0 +1,41 @@ +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +val SampleColorScheme = lightColorScheme( + primary = Color(0xFF3F51B5), + onPrimary = Color.White, + primaryContainer = Color(0xFF3F51B5), + onPrimaryContainer = Color.White, + inversePrimary = Color.Black, + secondary = Color(0xFF3F51B5), + onSecondary = Color.White, + secondaryContainer = Color(0xFF3F51B5), + onSecondaryContainer = Color.White, + tertiary = Color(0xFF3F51B5), + onTertiary = Color.White, + tertiaryContainer = Color(0xFF3F51B5), + onTertiaryContainer = Color.White, + background = Color.White, + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color.White, + onSurfaceVariant = Color.Black, + surfaceTint = Color.White, + inverseSurface = Color(0xFF121212), + inverseOnSurface = Color.White, + error = Color(0xFFB00020), + onError = Color.White, + errorContainer = Color(0xFFB00020), + onErrorContainer = Color.White, + outline = Color(0xFFAFAFAF), + outlineVariant = Color(0xFFCCCCCC), + scrim = Color.Black, + surfaceBright = Color.White, + surfaceContainer = Color.White, + surfaceContainerHigh = Color.White, + surfaceContainerHighest = Color.White, + surfaceContainerLow = Color.White, + surfaceContainerLowest = Color.White, + surfaceDim = Color.White, +) diff --git a/compose-multiplatform/sample/src/commonMain/kotlin/Utils.kt b/compose-multiplatform/sample/src/commonMain/kotlin/Utils.kt index 11e85a6f..d894f57e 100644 --- a/compose-multiplatform/sample/src/commonMain/kotlin/Utils.kt +++ b/compose-multiplatform/sample/src/commonMain/kotlin/Utils.kt @@ -1,10 +1,13 @@ + import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -25,7 +28,10 @@ import androidx.compose.ui.unit.dp import com.kizitonwose.calendar.compose.CalendarLayoutInfo import com.kizitonwose.calendar.compose.CalendarState import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarLayoutInfo +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.YearMonth import com.kizitonwose.calendar.core.minus @@ -33,9 +39,6 @@ import com.kizitonwose.calendar.core.plus import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.LocalDate -import kotlinx.datetime.minus -import kotlinx.datetime.plus fun Modifier.clickable( enabled: Boolean = true, @@ -147,6 +150,41 @@ fun rememberFirstMostVisibleMonth( return visibleMonth.value } +/** + * Find the first year on the calendar visible up to the given [viewportPercent] size. + * + * @see [rememberFirstVisibleYearAfterScroll] + */ +@Composable +fun rememberFirstMostVisibleYear( + state: YearCalendarState, + viewportPercent: Float = 50f, +): CalendarYear { + val visibleMonth = remember(state) { mutableStateOf(state.firstVisibleYear) } + LaunchedEffect(state) { + snapshotFlow { state.layoutInfo.firstMostVisibleYear(viewportPercent) } + .filterNotNull() + .collect { month -> visibleMonth.value = month } + } + return visibleMonth.value +} + +/** + * Returns the first visible year in a paged calendar **after** scrolling stops. + * + * @see [rememberFirstMostVisibleYear] + */ +@Composable +fun rememberFirstVisibleYearAfterScroll(state: YearCalendarState): CalendarYear { + val visibleYear = remember(state) { mutableStateOf(state.firstVisibleYear) } + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { scrolling -> !scrolling } + .collect { visibleYear.value = state.firstVisibleYear } + } + return visibleYear.value +} + private val CalendarLayoutInfo.completelyVisibleMonths: List get() { val visibleItemsInfo = this.visibleMonthsInfo.toMutableList() @@ -181,7 +219,43 @@ private fun CalendarLayoutInfo.firstMostVisibleMonth(viewportPercent: Float = 50 } } -internal fun LocalDate.plusDays(value: Int): LocalDate = plus(value, DateTimeUnit.DAY) -internal fun LocalDate.minusDays(value: Int): LocalDate = minus(value, DateTimeUnit.DAY) -internal fun YearMonth.plusMonths(value: Int): YearMonth = plus(value, DateTimeUnit.MONTH) -internal fun YearMonth.minusMonths(value: Int): YearMonth = minus(value, DateTimeUnit.MONTH) +private fun YearCalendarLayoutInfo.firstMostVisibleYear(viewportPercent: Float = 50f): CalendarYear? { + return if (visibleYearsInfo.isEmpty()) { + null + } else { + val viewportSize = (viewportEndOffset + viewportStartOffset) * viewportPercent / 100f + visibleYearsInfo.firstOrNull { itemInfo -> + if (itemInfo.offset < 0) { + itemInfo.offset + itemInfo.size >= viewportSize + } else { + itemInfo.size - itemInfo.offset >= viewportSize + } + }?.year + } +} + +suspend fun LazyListState.animateScrollAndCenterItem(index: Int) { + suspend fun animateScrollIfVisible(): Boolean { + val layoutInfo = layoutInfo + val containerSize = layoutInfo.viewportSize.width - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + val target = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return false + val targetOffset = containerSize / 2f - target.size / 2f + animateScrollBy(target.offset - targetOffset) + return true + } + if (!animateScrollIfVisible()) { + val visibleItemsInfo = layoutInfo.visibleItemsInfo + val currentIndex = visibleItemsInfo.getOrNull(visibleItemsInfo.size / 2)?.index ?: -1 + scrollToItem( + if (index > currentIndex) { + (index - visibleItemsInfo.size + 1) + } else { + index + }.coerceIn(0, layoutInfo.totalItemsCount), + ) + animateScrollIfVisible() + } +} + +val YearMonth.next: YearMonth get() = this.plus(1, DateTimeUnit.MONTH) +val YearMonth.previous: YearMonth get() = this.minus(1, DateTimeUnit.MONTH) diff --git a/compose-multiplatform/sample/src/iosMain/kotlin/Format.ios.kt b/compose-multiplatform/sample/src/iosMain/kotlin/Format.ios.kt index 5291f3fa..438ab831 100644 --- a/compose-multiplatform/sample/src/iosMain/kotlin/Format.ios.kt +++ b/compose-multiplatform/sample/src/iosMain/kotlin/Format.ios.kt @@ -11,10 +11,15 @@ actual fun Month.getDisplayName(short: Boolean, locale: Locale): String = it.monthSymbols[Month.entries.indexOf(this)] as String } -actual fun DayOfWeek.getShortDisplayName(locale: Locale): String = +actual fun DayOfWeek.getDisplayName(narrow: Boolean, locale: Locale): String = NSCalendar.currentCalendar.let { it.setLocale(NSLocale(locale.toLanguageTag())) - it.shortWeekdaySymbols[sundayBasedWeek.indexOf(this)] as String + val values = if (narrow) { + it.veryShortWeekdaySymbols + } else { + it.shortWeekdaySymbols + } + values[sundayBasedWeek.indexOf(this)] as String } private val sundayBasedWeek = daysOfWeek(firstDayOfWeek = DayOfWeek.SUNDAY) diff --git a/compose-multiplatform/sample/src/jvmMain/kotlin/Format.jvm.kt b/compose-multiplatform/sample/src/jvmMain/kotlin/Format.jvm.kt index 13b90094..3f2ffbeb 100644 --- a/compose-multiplatform/sample/src/jvmMain/kotlin/Format.jvm.kt +++ b/compose-multiplatform/sample/src/jvmMain/kotlin/Format.jvm.kt @@ -9,6 +9,7 @@ actual fun Month.getDisplayName(short: Boolean, locale: Locale): String { return getDisplayName(style, JavaLocale.forLanguageTag(locale.toLanguageTag())) } -actual fun DayOfWeek.getShortDisplayName(locale: Locale): String { - return getDisplayName(TextStyle.SHORT, JavaLocale.forLanguageTag(locale.toLanguageTag())) +actual fun DayOfWeek.getDisplayName(narrow: Boolean, locale: Locale): String { + val style = if (narrow) TextStyle.NARROW else TextStyle.SHORT + return getDisplayName(style, JavaLocale.forLanguageTag(locale.toLanguageTag())) } diff --git a/compose-multiplatform/sample/src/wasmJsMain/kotlin/Format.wasmJs.kt b/compose-multiplatform/sample/src/wasmJsMain/kotlin/Format.wasmJs.kt index 7be940b6..b20385d0 100644 --- a/compose-multiplatform/sample/src/wasmJsMain/kotlin/Format.wasmJs.kt +++ b/compose-multiplatform/sample/src/wasmJsMain/kotlin/Format.wasmJs.kt @@ -9,8 +9,8 @@ actual fun Month.getDisplayName(short: Boolean, locale: Locale): String { return if (short) name.take(3) else name } -actual fun DayOfWeek.getShortDisplayName(locale: Locale): String { - return name.toLowerCase(enLocale).capitalize(enLocale).take(3) +actual fun DayOfWeek.getDisplayName(narrow: Boolean, locale: Locale): String { + return name.toLowerCase(enLocale).capitalize(enLocale).take(if (narrow) 1 else 3) } private val enLocale = Locale("en-US") diff --git a/compose/api/compose.api b/compose/api/compose.api index 81615668..4a4796d1 100644 --- a/compose/api/compose.api +++ b/compose/api/compose.api @@ -12,7 +12,9 @@ public final class com/kizitonwose/calendar/compose/CalendarItemInfo : androidx/ public final class com/kizitonwose/calendar/compose/CalendarKt { public static final fun HeatMapCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState;Lcom/kizitonwose/calendar/compose/heatmapcalendar/HeatMapWeekHeaderPosition;ZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun HorizontalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun HorizontalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun VerticalCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/CalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lcom/kizitonwose/calendar/compose/ContentHeightMode;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;III)V + public static final fun VerticalYearCalendar-Y3kUhCI (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState;IZZZLandroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/foundation/layout/PaddingValues;FFLcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function5;Landroidx/compose/runtime/Composer;IIII)V public static final fun WeekCalendar (Landroidx/compose/ui/Modifier;Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState;ZZZLandroidx/compose/foundation/layout/PaddingValues;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V } @@ -179,3 +181,81 @@ public final class com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarSta public static final fun rememberWeekCalendarState (Ljava/time/LocalDate;Ljava/time/LocalDate;Ljava/time/LocalDate;Ljava/time/DayOfWeek;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState; } +public final class com/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt { + public static final field INSTANCE Lcom/kizitonwose/calendar/compose/yearcalendar/ComposableSingletons$YearCalendarMonthsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function5; + public static field lambda-2 Lkotlin/jvm/functions/Function5; + public static field lambda-3 Lkotlin/jvm/functions/Function5; + public static field lambda-4 Lkotlin/jvm/functions/Function5; + public fun ()V + public final fun getLambda-1$compose_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-2$compose_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-3$compose_release ()Lkotlin/jvm/functions/Function5; + public final fun getLambda-4$compose_release ()Lkotlin/jvm/functions/Function5; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarItemInfo : androidx/compose/foundation/lazy/LazyListItemInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListItemInfo;Lcom/kizitonwose/calendar/core/CalendarYear;)V + public fun getContentType ()Ljava/lang/Object; + public fun getIndex ()I + public fun getKey ()Ljava/lang/Object; + public fun getOffset ()I + public fun getSize ()I + public final fun getYear ()Lcom/kizitonwose/calendar/core/CalendarYear; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo : androidx/compose/foundation/lazy/LazyListLayoutInfo { + public static final field $stable I + public fun (Landroidx/compose/foundation/lazy/LazyListLayoutInfo;Lkotlin/jvm/functions/Function1;)V + public fun getAfterContentPadding ()I + public fun getBeforeContentPadding ()I + public fun getMainAxisItemSpacing ()I + public fun getOrientation ()Landroidx/compose/foundation/gestures/Orientation; + public fun getReverseLayout ()Z + public fun getTotalItemsCount ()I + public fun getViewportEndOffset ()I + public fun getViewportSize-YbymL2g ()J + public fun getViewportStartOffset ()I + public fun getVisibleItemsInfo ()Ljava/util/List; + public final fun getVisibleYearsInfo ()Ljava/util/List; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState : androidx/compose/foundation/gestures/ScrollableState { + public static final field $stable I + public static final field Companion Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion; + public final fun animateScrollToYear (Ljava/time/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun dispatchRawDelta (F)F + public final fun getEndYear ()Ljava/time/Year; + public final fun getFirstDayOfWeek ()Ljava/time/DayOfWeek; + public final fun getFirstVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getInteractionSource ()Landroidx/compose/foundation/interaction/InteractionSource; + public final fun getLastVisibleYear ()Lcom/kizitonwose/calendar/core/CalendarYear; + public final fun getLayoutInfo ()Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo; + public final fun getOutDateStyle ()Lcom/kizitonwose/calendar/core/OutDateStyle; + public final fun getStartYear ()Ljava/time/Year; + public fun isScrollInProgress ()Z + public fun scroll (Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun scrollToYear (Ljava/time/Year;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun setEndYear (Ljava/time/Year;)V + public final fun setFirstDayOfWeek (Ljava/time/DayOfWeek;)V + public final fun setOutDateStyle (Lcom/kizitonwose/calendar/core/OutDateStyle;)V + public final fun setStartYear (Ljava/time/Year;)V +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState$Companion { +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearCalendarStateKt { + public static final fun rememberYearCalendarState (Ljava/time/Year;Ljava/time/Year;Ljava/time/Year;Ljava/time/DayOfWeek;Lcom/kizitonwose/calendar/core/OutDateStyle;Landroidx/compose/runtime/Composer;II)Lcom/kizitonwose/calendar/compose/yearcalendar/YearCalendarState; +} + +public final class com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode : java/lang/Enum { + public static final field Fill Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Stretch Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static final field Wrap Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; + public static fun values ()[Lcom/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode; +} + diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index bd55c5ec..7529aa2e 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -41,7 +41,8 @@ dependencies { implementation(libs.compose.foundation) implementation(libs.compose.runtime) - testImplementation(libs.test.junit) + testImplementation(libs.test.junit5.api) + testRuntimeOnly(libs.test.junit5.engine) } mavenPublishing { diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/Calendar.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/Calendar.kt index 9d5bee28..81a50ddc 100644 --- a/compose/src/main/java/com/kizitonwose/calendar/compose/Calendar.kt +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/Calendar.kt @@ -7,7 +7,9 @@ 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.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.kizitonwose.calendar.compose.CalendarDefaults.flingBehavior import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarImpl @@ -18,14 +20,20 @@ import com.kizitonwose.calendar.compose.heatmapcalendar.rememberHeatMapCalendarS import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarImpl import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState import com.kizitonwose.calendar.compose.weekcalendar.rememberWeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarMonths +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.ExperimentalCalendarApi import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.WeekDay import java.time.DayOfWeek /** - * A horizontally scrolling calendar. + * 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. @@ -39,7 +47,7 @@ import java.time.DayOfWeek * @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. + * 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 @@ -88,7 +96,7 @@ public fun HorizontalCalendar( ) /** - * A vertically scrolling calendar. + * 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. @@ -102,7 +110,7 @@ public fun HorizontalCalendar( * @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. + * 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 @@ -293,3 +301,295 @@ public fun HeatMapCalendar( 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 columns the number of months columns in each year on the calendar. + * @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. Alternatively, you can + * also provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows. + * @param monthHorizontalSpacing the horizontal spacing between month columns. + * @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. + */ +@ExperimentalCalendarApi +@Composable +public fun HorizontalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + columns: 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 = remember { { true } }, + 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, + columns = columns, + 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 columns the number of months columns in each year on the calendar. + * @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. Alternatively, you can + * also provide a [yearBody] with the desired padding to achieve the same result. + * @param monthVerticalSpacing the vertical spacing between month rows. + * @param monthHorizontalSpacing the horizontal spacing between month columns. + * @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. + */ +@ExperimentalCalendarApi +@Composable +public fun VerticalYearCalendar( + modifier: Modifier = Modifier, + state: YearCalendarState = rememberYearCalendarState(), + columns: 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 = remember { { true } }, + 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, + columns = columns, + 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, + columns: 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)?, +) { + 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 = { offset -> state.store[offset] }, + columns = columns, + 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, + ) + } + } else { + LazyColumn( + modifier = modifier, + state = state.listState, + flingBehavior = flingBehavior(calendarScrollPaged, state.listState), + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + ) { + YearCalendarMonths( + yearCount = state.calendarInfo.indexCount, + yearData = { offset -> state.store[offset] }, + columns = columns, + 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, + ) + } + } +} diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarMonths.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarMonths.kt index ea2aafcc..eeccedb8 100644 --- a/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarMonths.kt +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarMonths.kt @@ -87,4 +87,4 @@ private val defaultMonthContainer: (@Composable LazyItemScope.(CalendarMonth, co private val defaultMonthBody: (@Composable ColumnScope.(CalendarMonth, content: @Composable () -> Unit) -> Unit) = { _, content -> content() } -private fun T?.or(default: T) = this ?: default +internal fun T?.or(default: T) = this ?: default diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarState.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarState.kt index 0f5e901b..f11e66f7 100644 --- a/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarState.kt +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/CalendarState.kt @@ -20,7 +20,7 @@ import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.OutDateStyle import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale import com.kizitonwose.calendar.data.DataStore -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getCalendarMonthData import com.kizitonwose.calendar.data.getMonthIndex import com.kizitonwose.calendar.data.getMonthIndicesCount @@ -202,12 +202,12 @@ public class CalendarState internal constructor( } init { - monthDataChanged() // Update monthIndexCount initially. + monthDataChanged() // Update indexCount initially. } private fun monthDataChanged() { store.clear() - checkDateRange(startMonth, endMonth) + 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 diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt index bb13151e..5b821179 100644 --- a/compose/src/main/java/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/heatmapcalendar/HeatMapCalendarState.kt @@ -21,7 +21,7 @@ import com.kizitonwose.calendar.compose.VisibleItemState import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale import com.kizitonwose.calendar.data.DataStore -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getHeatMapCalendarMonthData import com.kizitonwose.calendar.data.getMonthIndex import com.kizitonwose.calendar.data.getMonthIndicesCount @@ -181,12 +181,12 @@ public class HeatMapCalendarState internal constructor( } init { - monthDataChanged() // Update monthIndexCount initially. + monthDataChanged() // Update indexCount initially. } private fun monthDataChanged() { store.clear() - checkDateRange(startMonth, endMonth) + checkRange(startMonth, endMonth) calendarInfo = CalendarInfo( indexCount = getMonthIndicesCount(startMonth, endMonth), firstDayOfWeek = firstDayOfWeek, diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt index 7cc76c4f..26441b6f 100644 --- a/compose/src/main/java/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/weekcalendar/WeekCalendarState.kt @@ -22,6 +22,7 @@ import com.kizitonwose.calendar.core.WeekDayPosition import com.kizitonwose.calendar.core.atStartOfMonth import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale import com.kizitonwose.calendar.data.DataStore +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.data.getWeekCalendarAdjustedRange import com.kizitonwose.calendar.data.getWeekCalendarData import com.kizitonwose.calendar.data.getWeekIndex @@ -204,6 +205,7 @@ public class WeekCalendarState internal constructor( } private fun adjustDateRange() { + checkRange(startDate, endDate) val data = getWeekCalendarAdjustedRange(startDate, endDate, firstDayOfWeek) startDateAdjusted = data.startDateAdjusted endDateAdjusted = data.endDateAdjusted diff --git a/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt new file mode 100644 index 00000000..d8eda40a --- /dev/null +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarLayoutInfo.kt @@ -0,0 +1,37 @@ +package com.kizitonwose.calendar.compose.yearcalendar + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import com.kizitonwose.calendar.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/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt new file mode 100644 index 00000000..3c2bbe80 --- /dev/null +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarMonths.kt @@ -0,0 +1,194 @@ +package com.kizitonwose.calendar.compose.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.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.Dp +import com.kizitonwose.calendar.compose.or +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear + +@Suppress("FunctionName") +internal fun LazyListScope.YearCalendarMonths( + yearCount: Int, + yearData: (offset: Int) -> CalendarYear, + columns: 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)?, +) { + items( + count = yearCount, + key = { offset -> yearData(offset).year }, + ) { yearOffset -> + val year = yearData(yearOffset) + val fillHeight = when (contentHeightMode) { + YearContentHeightMode.Wrap -> false + YearContentHeightMode.Fill, + YearContentHeightMode.Stretch, + -> true + } + val hasYearContainer = yearContainer != null + 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() + }, + ), + ) { + val months = year.months.filter(isMonthVisible) + yearHeader?.invoke(this, year) + yearBody.or(defaultYearBody)(year) { + CalendarGrid( + modifier = Modifier + .fillMaxWidth() + .then(if (fillHeight) Modifier.weight(1f) else Modifier.wrapContentHeight()) + .padding(yearBodyContentPadding), + columns = columns, + itemCount = months.count(), + fillHeight = fillHeight, + monthVerticalSpacing = monthVerticalSpacing, + monthHorizontalSpacing = monthHorizontalSpacing, + ) { monthOffset -> + val month = months[monthOffset] + val hasContainer = monthContainer != null + monthContainer.or(defaultMonthContainer)(month) { + Column( + modifier = Modifier + .then(if (hasContainer) Modifier.fillMaxWidth() else Modifier) + .then( + if (fillHeight) { + if (hasContainer) 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 (week in month.weekDays) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentHeightMode == YearContentHeightMode.Stretch) { + Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() + }, + ), + ) { + for (day in week) { + Box( + modifier = Modifier + .weight(1f) + .clipToBounds(), + ) { + dayContent(day) + } + } + } + } + } + } + monthFooter?.invoke(this, month) + } + } + } + } + yearFooter?.invoke(this, year) + } + } + } +} + +@Composable +private fun CalendarGrid( + columns: Int, + fillHeight: Boolean, + monthVerticalSpacing: Dp, + monthHorizontalSpacing: Dp, + itemCount: Int, + modifier: Modifier = Modifier, + content: @Composable BoxScope.(Int) -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(monthVerticalSpacing), + ) { + var rows = (itemCount / columns) + if (itemCount.mod(columns) > 0) { + rows += 1 + } + + for (rowId in 0 until rows) { + val firstIndex = rowId * columns + + Row( + modifier = Modifier.then( + if (fillHeight) Modifier.weight(1f) else Modifier, + ), + horizontalArrangement = Arrangement.spacedBy(monthHorizontalSpacing), + ) { + for (columnId in 0 until columns) { + val index = firstIndex + columnId + Box( + modifier = Modifier + .weight(1f), + ) { + if (index < itemCount) { + content(index) + } + } + } + } + } + } +} + +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/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt new file mode 100644 index 00000000..72810054 --- /dev/null +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearCalendarState.kt @@ -0,0 +1,299 @@ +package com.kizitonwose.calendar.compose.yearcalendar + +import android.util.Log +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.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.kizitonwose.calendar.compose.CalendarInfo +import com.kizitonwose.calendar.compose.CalendarLayoutInfo +import com.kizitonwose.calendar.compose.VisibleItemState +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import com.kizitonwose.calendar.data.DataStore +import com.kizitonwose.calendar.data.checkRange +import com.kizitonwose.calendar.data.getCalendarYearData +import com.kizitonwose.calendar.data.getYearIndex +import com.kizitonwose.calendar.data.getYearIndicesCount +import java.time.DayOfWeek +import java.time.Year + +/** + * 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] + */ +@ExperimentalCalendarApi +@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 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]. + */ + public suspend fun animateScrollToYear(year: Year) { + listState.animateScrollToItem(getScrollIndex(year) ?: return) + } + + private fun getScrollIndex(year: Year): Int? { + if (year !in startYear..endYear) { + Log.d("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, + it.endYear, + it.firstVisibleYear.year, + it.firstDayOfWeek, + it.outDateStyle, + it.listState.firstVisibleItemIndex, + it.listState.firstVisibleItemScrollOffset, + ) + }, + restore = { + YearCalendarState( + startYear = it[0] as Year, + endYear = it[1] as Year, + firstVisibleYear = it[2] as Year, + 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/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt new file mode 100644 index 00000000..227c72b3 --- /dev/null +++ b/compose/src/main/java/com/kizitonwose/calendar/compose/yearcalendar/YearContentHeightMode.kt @@ -0,0 +1,37 @@ +package com.kizitonwose.calendar.compose.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/compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTests.kt b/compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTest.kt similarity index 96% rename from compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTests.kt rename to compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTest.kt index 7c7eb0aa..2f515094 100644 --- a/compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTests.kt +++ b/compose/src/test/java/com/kizitonwose/calendar/compose/CalendarStateTest.kt @@ -2,13 +2,13 @@ package com.kizitonwose.calendar.compose import com.kizitonwose.calendar.core.OutDateStyle import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import junit.framework.TestCase.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth -class CalendarStateTests { +class CalendarStateTest { @Test fun `start month update is reflected in the state`() { val now = YearMonth.now() diff --git a/compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTests.kt b/compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt similarity index 95% rename from compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTests.kt rename to compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt index 9c6367a9..cabe5edb 100644 --- a/compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTests.kt +++ b/compose/src/test/java/com/kizitonwose/calendar/compose/HeatMapCalendarStateTest.kt @@ -2,13 +2,13 @@ package com.kizitonwose.calendar.compose import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarState import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import junit.framework.TestCase.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth -class HeatMapCalendarStateTests { +class HeatMapCalendarStateTest { @Test fun `start month update is reflected in the state`() { val now = YearMonth.now() diff --git a/compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTests.kt b/compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTest.kt similarity index 70% rename from compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTests.kt rename to compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTest.kt index 02417ca3..041146b0 100644 --- a/compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTests.kt +++ b/compose/src/test/java/com/kizitonwose/calendar/compose/StateSaverTest.kt @@ -5,11 +5,13 @@ import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.listSaver import com.kizitonwose.calendar.compose.heatmapcalendar.HeatMapCalendarState import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState import com.kizitonwose.calendar.core.OutDateStyle -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.time.DayOfWeek import java.time.LocalDate +import java.time.Year import java.time.YearMonth /** @@ -18,16 +20,17 @@ import java.time.YearMonth * being saved. Such issues are typically not caught during development since * state restoration (e.g via rotation) will likely not happen often. */ -class StateSaverTests { +class StateSaverTest { @Test fun `month calendar state can be restored`() { val now = YearMonth.now() - val firstDayOfWeek = DayOfWeek.values().random() + val firstDayOfWeek = DayOfWeek.entries.random() + val outDateStyle = OutDateStyle.entries.random() val state = CalendarState( startMonth = now, endMonth = now, firstVisibleMonth = now, - outDateStyle = OutDateStyle.EndOfRow, + outDateStyle = outDateStyle, firstDayOfWeek = firstDayOfWeek, visibleItemState = VisibleItemState(), ) @@ -42,7 +45,7 @@ class StateSaverTests { @Test fun `week calendar state can be restored`() { val now = LocalDate.now() - val firstDayOfWeek = DayOfWeek.values().random() + val firstDayOfWeek = DayOfWeek.entries.random() val state = WeekCalendarState( startDate = now, endDate = now, @@ -60,7 +63,7 @@ class StateSaverTests { @Test fun `heatmap calendar state can be restored`() { val now = YearMonth.now() - val firstDayOfWeek = DayOfWeek.values().random() + val firstDayOfWeek = DayOfWeek.entries.random() val state = HeatMapCalendarState( startMonth = now, endMonth = now, @@ -75,6 +78,26 @@ class StateSaverTests { assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) } + @Test + fun `year calendar state can be restored`() { + val now = Year.now() + val firstDayOfWeek = DayOfWeek.entries.random() + val outDateStyle = OutDateStyle.entries.random() + val state = YearCalendarState( + startYear = now, + endYear = now, + firstVisibleYear = now, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + visibleItemState = VisibleItemState(), + ) + val restored = restore(state, YearCalendarState.Saver) + assertEquals(state.startYear, restored.startYear) + assertEquals(state.endYear, restored.endYear) + assertEquals(state.firstVisibleYear, restored.firstVisibleYear) + assertEquals(state.firstDayOfWeek, restored.firstDayOfWeek) + } + private fun restore(value: State, saver: Saver): State { with(saver) { val saved = SaverScope { true }.save(value)!! diff --git a/compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTests.kt b/compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt similarity index 93% rename from compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTests.kt rename to compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt index 15bf885a..933d8beb 100644 --- a/compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTests.kt +++ b/compose/src/test/java/com/kizitonwose/calendar/compose/WeekCalendarStateTest.kt @@ -2,13 +2,13 @@ package com.kizitonwose.calendar.compose import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale -import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertTrue -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test import java.time.DayOfWeek import java.time.LocalDate -class WeekCalendarStateTests { +class WeekCalendarStateTest { @Test fun `start date update is reflected in the state`() { val now = LocalDate.now() diff --git a/compose/src/test/java/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt b/compose/src/test/java/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt new file mode 100644 index 00000000..362def3b --- /dev/null +++ b/compose/src/test/java/com/kizitonwose/calendar/compose/YearCalendarStateTest.kt @@ -0,0 +1,103 @@ +package com.kizitonwose.calendar.compose + +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState +import com.kizitonwose.calendar.core.OutDateStyle +import com.kizitonwose.calendar.core.firstDayOfWeekFromLocale +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.Month +import java.time.Year + +class YearCalendarStateTest { + @Test + fun `start year update is reflected in the state`() { + val now = Year.now() + val updatedStartYear = now.minusYears(8) + val state = createState( + startYear = now, + endYear = now, + ) + + assertEquals(state.store[0].year, now) + + state.startYear = updatedStartYear + + assertEquals(state.store[0].year, updatedStartYear) + } + + @Test + fun `end year update is reflected in the state`() { + val now = Year.now() + val updatedEndMonth = now.plusYears(8) + val state = createState( + startYear = now, + endYear = now, + ) + + assertEquals(state.store[0].year, now) + + state.endYear = updatedEndMonth + + assertEquals(state.store[8].year, updatedEndMonth) + } + + @Test + fun `first day of the week update is reflected in the state`() { + val firstDayOfWeek = LocalDate.now().dayOfWeek + + val state = createState(firstDayOfWeek = firstDayOfWeek) + + state.store[0].months + .flatMap { month -> month.weekDays } + .forEach { week -> + assertEquals(week.first().date.dayOfWeek, firstDayOfWeek) + } + do { + val updatedFirstDayOfWeek = state.firstDayOfWeek.plus(1) + state.firstDayOfWeek = updatedFirstDayOfWeek + + state.store[0].months + .flatMap { month -> month.weekDays } + .forEach { week -> + assertEquals(week.first().date.dayOfWeek, updatedFirstDayOfWeek) + } + } while (firstDayOfWeek != state.firstDayOfWeek) + } + + @Test + fun `out date style update is reflected in the state`() { + val outDateStyle = OutDateStyle.EndOfRow + // Nov 2022 has 5 weeks when Sun is the first day. + val startYear = Year.of(2022) + val state = createState( + startYear = startYear, + endYear = startYear, + outDateStyle = outDateStyle, + firstDayOfWeek = DayOfWeek.SUNDAY, + ) + + assertEquals(state.store[0].months[Month.NOVEMBER.ordinal].weekDays.count(), 5) + + state.outDateStyle = OutDateStyle.EndOfGrid + + assertEquals(state.store[0].months[Month.NOVEMBER.ordinal].weekDays.count(), 6) + } + + private fun createState( + startYear: Year = Year.now(), + endYear: Year = startYear, + firstVisibleYear: Year = startYear, + outDateStyle: OutDateStyle = OutDateStyle.EndOfRow, + firstDayOfWeek: DayOfWeek = firstDayOfWeekFromLocale(), + visibleItemState: VisibleItemState = VisibleItemState(), + ) = YearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = firstVisibleYear, + outDateStyle = outDateStyle, + firstDayOfWeek = firstDayOfWeek, + visibleItemState = visibleItemState, + ) +} diff --git a/core/api/core.api b/core/api/core.api index dca5da5f..7aa710cb 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -24,6 +24,19 @@ public final class com/kizitonwose/calendar/core/CalendarMonth : java/io/Seriali public fun toString ()Ljava/lang/String; } +public final class com/kizitonwose/calendar/core/CalendarYear : java/io/Serializable { + public fun (Ljava/time/Year;Ljava/util/List;)V + public final fun component1 ()Ljava/time/Year; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/time/Year;Ljava/util/List;)Lcom/kizitonwose/calendar/core/CalendarYear; + public static synthetic fun copy$default (Lcom/kizitonwose/calendar/core/CalendarYear;Ljava/time/Year;Ljava/util/List;ILjava/lang/Object;)Lcom/kizitonwose/calendar/core/CalendarYear; + public fun equals (Ljava/lang/Object;)Z + public final fun getMonths ()Ljava/util/List; + public final fun getYear ()Ljava/time/Year; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/kizitonwose/calendar/core/DayPosition : java/lang/Enum { public static final field InDate Lcom/kizitonwose/calendar/core/DayPosition; public static final field MonthDate Lcom/kizitonwose/calendar/core/DayPosition; @@ -33,6 +46,9 @@ public final class com/kizitonwose/calendar/core/DayPosition : java/lang/Enum { public static fun values ()[Lcom/kizitonwose/calendar/core/DayPosition; } +public abstract interface annotation class com/kizitonwose/calendar/core/ExperimentalCalendarApi : java/lang/annotation/Annotation { +} + public final class com/kizitonwose/calendar/core/ExtensionsKt { public static final fun atStartOfMonth (Ljava/time/YearMonth;)Ljava/time/LocalDate; public static final fun daysOfWeek ()Ljava/util/List; diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c3daf731..9690897d 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -19,7 +19,7 @@ kotlin { } dependencies { - implementation(libs.compose.runtime) // Only needed for @Immutable annotation. + compileOnly(libs.compose.runtime) // Only needed for @Immutable annotation. } mavenPublishing { diff --git a/core/src/main/java/com/kizitonwose/calendar/core/CalendarMonth.kt b/core/src/main/java/com/kizitonwose/calendar/core/CalendarMonth.kt index 62511279..4f476729 100644 --- a/core/src/main/java/com/kizitonwose/calendar/core/CalendarMonth.kt +++ b/core/src/main/java/com/kizitonwose/calendar/core/CalendarMonth.kt @@ -37,8 +37,9 @@ public data class CalendarMonth( override fun toString(): String { return "CalendarMonth { " + - "first = ${weekDays.first().first()}, " + - "last = ${weekDays.last().last()} " + + "yearMonth = $yearMonth, " + + "firstDay = ${weekDays.first().first()}, " + + "lastDay = ${weekDays.last().last()} " + "} " } } diff --git a/core/src/main/java/com/kizitonwose/calendar/core/CalendarYear.kt b/core/src/main/java/com/kizitonwose/calendar/core/CalendarYear.kt new file mode 100644 index 00000000..1f785a0e --- /dev/null +++ b/core/src/main/java/com/kizitonwose/calendar/core/CalendarYear.kt @@ -0,0 +1,45 @@ +package com.kizitonwose.calendar.core + +import androidx.compose.runtime.Immutable +import java.io.Serializable +import java.time.Year + +/** + * 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, +) : Serializable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) 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/core/src/main/java/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt b/core/src/main/java/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt new file mode 100644 index 00000000..df02b05a --- /dev/null +++ b/core/src/main/java/com/kizitonwose/calendar/core/ExperimentalCalendarApi.kt @@ -0,0 +1,9 @@ +package com.kizitonwose.calendar.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/data/api/data.api b/data/api/data.api index 8c53e103..b8777a33 100644 --- a/data/api/data.api +++ b/data/api/data.api @@ -44,8 +44,7 @@ public final class com/kizitonwose/calendar/data/MonthDataKt { } public final class com/kizitonwose/calendar/data/UtilsKt { - public static final fun checkDateRange (Ljava/time/LocalDate;Ljava/time/LocalDate;)V - public static final fun checkDateRange (Ljava/time/YearMonth;Ljava/time/YearMonth;)V + public static final fun checkRange (Ljava/lang/Comparable;Ljava/lang/Comparable;)V } public final class com/kizitonwose/calendar/data/WeekData { @@ -77,3 +76,9 @@ public final class com/kizitonwose/calendar/data/WeekDateRange { public fun toString ()Ljava/lang/String; } +public final class com/kizitonwose/calendar/data/YearDataKt { + public static final fun getCalendarYearData (Ljava/time/Year;ILjava/time/DayOfWeek;Lcom/kizitonwose/calendar/core/OutDateStyle;)Lcom/kizitonwose/calendar/core/CalendarYear; + public static final fun getYearIndex (Ljava/time/Year;Ljava/time/Year;)I + public static final fun getYearIndicesCount (Ljava/time/Year;Ljava/time/Year;)I +} + diff --git a/data/build.gradle.kts b/data/build.gradle.kts index c3150500..2f500ddf 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -23,7 +23,8 @@ dependencies { implementation(project(":core")) implementation(libs.kotlin.stdlib) - testImplementation(libs.test.junit) + testImplementation(libs.test.junit5.api) + testRuntimeOnly(libs.test.junit5.engine) } mavenPublishing { diff --git a/data/src/main/java/com/kizitonwose/calendar/data/Extensions.kt b/data/src/main/java/com/kizitonwose/calendar/data/Extensions.kt index eb53661f..90aa6038 100644 --- a/data/src/main/java/com/kizitonwose/calendar/data/Extensions.kt +++ b/data/src/main/java/com/kizitonwose/calendar/data/Extensions.kt @@ -3,4 +3,4 @@ package com.kizitonwose.calendar.data import java.time.DayOfWeek // E.g DayOfWeek.SATURDAY.daysUntil(DayOfWeek.TUESDAY) = 3 -public fun DayOfWeek.daysUntil(other: DayOfWeek): Int = (7 + (other.value - value)) % 7 +public fun DayOfWeek.daysUntil(other: DayOfWeek): Int = (7 + (other.ordinal - ordinal)) % 7 diff --git a/data/src/main/java/com/kizitonwose/calendar/data/Utils.kt b/data/src/main/java/com/kizitonwose/calendar/data/Utils.kt index ab9cfc8f..7b08c82b 100644 --- a/data/src/main/java/com/kizitonwose/calendar/data/Utils.kt +++ b/data/src/main/java/com/kizitonwose/calendar/data/Utils.kt @@ -1,16 +1,7 @@ package com.kizitonwose.calendar.data -import java.time.LocalDate -import java.time.YearMonth - -public fun checkDateRange(startMonth: YearMonth, endMonth: YearMonth) { - check(endMonth >= startMonth) { - "startMonth: $startMonth is greater than endMonth: $endMonth" - } -} - -public fun checkDateRange(startDate: LocalDate, endDate: LocalDate) { - check(endDate >= startDate) { - "startDate: $startDate is greater than endDate: $endDate" +public fun > checkRange(start: T, end: T) { + check(end >= start) { + "start: $start is greater than end: $end" } } diff --git a/data/src/main/java/com/kizitonwose/calendar/data/YearData.kt b/data/src/main/java/com/kizitonwose/calendar/data/YearData.kt new file mode 100644 index 00000000..13096349 --- /dev/null +++ b/data/src/main/java/com/kizitonwose/calendar/data/YearData.kt @@ -0,0 +1,35 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.CalendarYear +import com.kizitonwose.calendar.core.OutDateStyle +import java.time.DayOfWeek +import java.time.Month +import java.time.Year +import java.time.temporal.ChronoUnit + +public fun getCalendarYearData( + startYear: Year, + offset: Int, + firstDayOfWeek: DayOfWeek, + outDateStyle: OutDateStyle, +): CalendarYear { + val year = startYear.plusYears(offset.toLong()) + val months = List(Month.entries.size) { index -> + getCalendarMonthData( + startMonth = year.atMonth(Month.JANUARY), + offset = index, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ).calendarMonth + } + return CalendarYear(year, months) +} + +public fun getYearIndex(startYear: Year, targetYear: Year): Int { + return ChronoUnit.YEARS.between(startYear, targetYear).toInt() +} + +public fun getYearIndicesCount(startYear: Year, endYear: Year): Int { + // Add one to include the start year itself! + return getYearIndex(startYear, endYear) + 1 +} diff --git a/data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTests.kt b/data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTest.kt similarity index 85% rename from data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTests.kt rename to data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTest.kt index 0b57ed5c..e3ca9979 100644 --- a/data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTests.kt +++ b/data/src/test/java/com/kizitonwose/calendar/data/DayOfWeekTest.kt @@ -1,11 +1,11 @@ package com.kizitonwose.calendar.data import com.kizitonwose.calendar.core.daysOfWeek -import org.junit.Assert.assertEquals -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import java.time.DayOfWeek -class DayOfWeekTests { +class DayOfWeekTest { @Test fun `days until works as expected`() { assertEquals(5, DayOfWeek.FRIDAY.daysUntil(DayOfWeek.WEDNESDAY)) @@ -20,7 +20,7 @@ class DayOfWeekTests { @Test fun `first day of the week works as expected`() { - DayOfWeek.values().forEach { dayOfWeek -> + DayOfWeek.entries.forEach { dayOfWeek -> assertEquals(dayOfWeek, daysOfWeek(firstDayOfWeek = dayOfWeek).first()) } } diff --git a/data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTests.kt b/data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTest.kt similarity index 94% rename from data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTests.kt rename to data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTest.kt index 408a0d10..a5389248 100644 --- a/data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTests.kt +++ b/data/src/test/java/com/kizitonwose/calendar/data/HeatMapDataTest.kt @@ -5,19 +5,17 @@ import com.kizitonwose.calendar.core.daysOfWeek import com.kizitonwose.calendar.core.nextMonth import com.kizitonwose.calendar.core.previousMonth import com.kizitonwose.calendar.core.yearMonth -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test import java.time.DayOfWeek -import java.time.Month.DECEMBER -import java.time.Month.NOVEMBER -import java.time.Month.OCTOBER +import java.time.Month import java.time.YearMonth -class HeatMapDataTests { - private val october2022 = YearMonth.of(2022, OCTOBER) - private val november2022 = YearMonth.of(2022, NOVEMBER) - private val december2022 = YearMonth.of(2022, DECEMBER) +class HeatMapDataTest { + private val october2022 = YearMonth.of(2022, Month.OCTOBER) + private val november2022 = YearMonth.of(2022, Month.NOVEMBER) + private val december2022 = YearMonth.of(2022, Month.DECEMBER) private val firstDayOfWeek = DayOfWeek.MONDAY /** October, November and December 2022 diff --git a/data/src/test/java/com/kizitonwose/calendar/data/MonthDataTests.kt b/data/src/test/java/com/kizitonwose/calendar/data/MonthDataTest.kt similarity index 95% rename from data/src/test/java/com/kizitonwose/calendar/data/MonthDataTests.kt rename to data/src/test/java/com/kizitonwose/calendar/data/MonthDataTest.kt index 71a0e1f8..9988790e 100644 --- a/data/src/test/java/com/kizitonwose/calendar/data/MonthDataTests.kt +++ b/data/src/test/java/com/kizitonwose/calendar/data/MonthDataTest.kt @@ -6,17 +6,16 @@ import com.kizitonwose.calendar.core.daysOfWeek import com.kizitonwose.calendar.core.nextMonth import com.kizitonwose.calendar.core.previousMonth import com.kizitonwose.calendar.core.yearMonth -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test import java.time.DayOfWeek -import java.time.Month.MAY -import java.time.Month.NOVEMBER +import java.time.Month import java.time.YearMonth -class MonthDataTests { - private val may2019 = YearMonth.of(2019, MAY) - private val november2019 = YearMonth.of(2019, NOVEMBER) +class MonthDataTest { + private val may2019 = YearMonth.of(2019, Month.MAY) + private val november2019 = YearMonth.of(2019, Month.NOVEMBER) private val firstDayOfWeek = DayOfWeek.MONDAY /** May and November 2019 with Monday as the first day of week. diff --git a/data/src/test/java/com/kizitonwose/calendar/data/Utils.kt b/data/src/test/java/com/kizitonwose/calendar/data/Utils.kt new file mode 100644 index 00000000..5138abb7 --- /dev/null +++ b/data/src/test/java/com/kizitonwose/calendar/data/Utils.kt @@ -0,0 +1,7 @@ +package com.kizitonwose.calendar.data + +import java.time.DayOfWeek +import java.time.YearMonth +import java.time.temporal.WeekFields + +fun YearMonth.weeksInMonth(firstDayOfWeek: DayOfWeek) = atEndOfMonth().get(WeekFields.of(firstDayOfWeek, 1).weekOfMonth()) diff --git a/data/src/test/java/com/kizitonwose/calendar/data/WeekDataTests.kt b/data/src/test/java/com/kizitonwose/calendar/data/WeekDataTest.kt similarity index 89% rename from data/src/test/java/com/kizitonwose/calendar/data/WeekDataTests.kt rename to data/src/test/java/com/kizitonwose/calendar/data/WeekDataTest.kt index deea9e77..7b471530 100644 --- a/data/src/test/java/com/kizitonwose/calendar/data/WeekDataTests.kt +++ b/data/src/test/java/com/kizitonwose/calendar/data/WeekDataTest.kt @@ -2,19 +2,17 @@ package com.kizitonwose.calendar.data import com.kizitonwose.calendar.core.WeekDayPosition import com.kizitonwose.calendar.core.daysOfWeek -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test import java.time.DayOfWeek import java.time.LocalDate -import java.time.Month.APRIL -import java.time.Month.MAY -import java.time.Month.NOVEMBER +import java.time.Month import java.time.YearMonth -class WeekDataTests { - private val may2019 = YearMonth.of(2019, MAY) - private val november2019 = YearMonth.of(2019, NOVEMBER) +class WeekDataTest { + private val may2019 = YearMonth.of(2019, Month.MAY) + private val november2019 = YearMonth.of(2019, Month.NOVEMBER) private val firstDayOfWeek = DayOfWeek.MONDAY /** May and November 2019 with Monday as the first day of week. @@ -41,8 +39,8 @@ class WeekDataTests { val nov01 = november2019.atDay(1) val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, nov01, firstDayOfWeek) - assertEquals(LocalDate.of(2019, APRIL, 29), adjustedWeekRange.startDateAdjusted) - assertEquals(LocalDate.of(2019, NOVEMBER, 3), adjustedWeekRange.endDateAdjusted) + assertEquals(LocalDate.of(2019, Month.APRIL, 29), adjustedWeekRange.startDateAdjusted) + assertEquals(LocalDate.of(2019, Month.NOVEMBER, 3), adjustedWeekRange.endDateAdjusted) } @Test @@ -52,8 +50,8 @@ class WeekDataTests { val adjustedWeekRange = getWeekCalendarAdjustedRange(may01, nov01, firstDayOfWeek) val week = getWeekCalendarData(adjustedWeekRange.startDateAdjusted, 0, may01, nov01).week - assertEquals(LocalDate.of(2019, APRIL, 29), week.days.first().date) - assertEquals(LocalDate.of(2019, MAY, 5), week.days.last().date) + assertEquals(LocalDate.of(2019, Month.APRIL, 29), week.days.first().date) + assertEquals(LocalDate.of(2019, Month.MAY, 5), week.days.last().date) } @Test diff --git a/data/src/test/java/com/kizitonwose/calendar/data/YearDataTest.kt b/data/src/test/java/com/kizitonwose/calendar/data/YearDataTest.kt new file mode 100644 index 00000000..ef864114 --- /dev/null +++ b/data/src/test/java/com/kizitonwose/calendar/data/YearDataTest.kt @@ -0,0 +1,93 @@ +package com.kizitonwose.calendar.data + +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.OutDateStyle +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.DayOfWeek +import java.time.Month +import java.time.Year +import java.time.YearMonth + +class YearDataTest { + @Test + fun `year data is accurate with non-leap year`() { + val year = Year.of(2019) + val firstDayOfWeek = DayOfWeek.MONDAY + val outDateStyle = OutDateStyle.EndOfRow + val yearData = getCalendarYearData(year, 0, firstDayOfWeek, outDateStyle) + val months = yearData.months + val days = yearData.months.flatMap { it.weekDays }.flatten() + yearData.months.forEachIndexed { index, month -> + val monthData = getCalendarMonthData( + startMonth = YearMonth.of(year.value, Month.JANUARY), + offset = month.yearMonth.month.ordinal, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + assertEquals(monthData.calendarMonth, month) + assertEquals(Month.entries[Month.JANUARY.ordinal + index], month.yearMonth.month) + assertEquals(month.yearMonth.weeksInMonth(firstDayOfWeek), month.weekDays.count()) + } + assertEquals(12, months.count()) + assertEquals(year, yearData.year) + assertEquals(36, days.count { it.position == DayPosition.InDate }) + assertEquals(33, days.count { it.position == DayPosition.OutDate }) + assertEquals(365, days.count { it.position == DayPosition.MonthDate }) + } + + @Test + fun `year data is accurate with leap year`() { + val year = Year.of(2020) + val firstDayOfWeek = DayOfWeek.SUNDAY + val outDateStyle = OutDateStyle.EndOfGrid + val yearData = getCalendarYearData(year, 0, firstDayOfWeek, outDateStyle) + val months = yearData.months + val days = yearData.months.flatMap { it.weekDays }.flatten() + yearData.months.forEachIndexed { index, month -> + val monthData = getCalendarMonthData( + startMonth = YearMonth.of(year.value, Month.JANUARY), + offset = month.yearMonth.month.ordinal, + firstDayOfWeek = firstDayOfWeek, + outDateStyle = outDateStyle, + ) + assertEquals(monthData.calendarMonth, month) + assertEquals(Month.entries[Month.JANUARY.ordinal + index], month.yearMonth.month) + assertEquals(6, month.weekDays.count()) + val weeksWithoutGridOutDates = month.weekDays.filterNot { week -> week.all { it.position == DayPosition.OutDate } } + assertEquals(month.yearMonth.weeksInMonth(firstDayOfWeek), weeksWithoutGridOutDates.count()) + } + assertEquals(12, months.count()) + assertEquals(year, yearData.year) + assertEquals(35, days.count { it.position == DayPosition.InDate }) + assertEquals(103, days.count { it.position == DayPosition.OutDate }) + assertEquals(366, days.count { it.position == DayPosition.MonthDate }) + } + + @Test + fun `generated year is at the correct offset`() { + val yearData = getCalendarYearData(Year.of(2020), 6, DayOfWeek.SUNDAY, OutDateStyle.EndOfGrid) + val yearData2 = getCalendarYearData(Year.of(2021), 0, DayOfWeek.SUNDAY, OutDateStyle.EndOfRow) + + assertEquals(yearData.year, Year.of(2026)) + assertEquals(yearData2.year, Year.of(2021)) + } + + @Test + fun `year index calculation works as expected`() { + val index = getYearIndex(startYear = Year.of(2020), targetYear = Year.of(2030)) + val index2 = getYearIndex(startYear = Year.of(2052), targetYear = Year.of(2052)) + + assertEquals(10, index) + assertEquals(0, index2) + } + + @Test + fun `year indices count calculation works as expected`() { + val count = getYearIndicesCount(startYear = Year.of(2020), endYear = Year.of(2040)) + val count2 = getYearIndicesCount(startYear = Year.of(2052), endYear = Year.of(2052)) + + assertEquals(21, count) + assertEquals(1, count2) + } +} diff --git a/docs/Compose.md b/docs/Compose.md index 682cae2c..f740684c 100644 --- a/docs/Compose.md +++ b/docs/Compose.md @@ -4,7 +4,7 @@ - [Quick links](#quick-links) - [Compose Multiplatform Information](#compose-multiplatform-information) -- [Compose UI version compatibility](#compose-ui-versions) +- [Compose UI version compatibility](#compose-ui-version-compatibility) - [Calendar Composables](#calendar-composables) - [Usage](#usage) * [Calendar state](#usage) @@ -19,6 +19,7 @@ * [Disabling dates](#disabling-dates) - [Week calendar](#week-calendar) - [HeatMap calendar](#heatmap-calendar) +- [Year calendar](#year-calendar) ## Quick links @@ -38,18 +39,9 @@ Add the library to your project [here](https://github.com/kizitonwose/Calendar#s ## Compose Multiplatform Information -The APIs for the compose libraries for Android and Multiplatform projects have been designed such that you can copy examples across both projects and they would work without code changes as the classes have the same names and package declarations. The only difference in some cases would be that the code for the Android calendar library needs to import classes such as `LocalDate` and `YearMonth` from the `java.time` package while the multiplaform calendar library needs to import such classes from the [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) library. +The APIs for the compose libraries for Android and Multiplatform projects have been designed such that you can copy examples across both projects and they would work without code changes as the classes have the same names and package declarations. The only difference in some cases would be that the code for the Android calendar library needs to import classes such as `LocalDate`, `YearMonth` and `Year` from the `java.time` package while the multiplaform calendar library needs to import such classes from the [kotlinx-datetime](https://github.com/Kotlin/kotlinx-datetime) library. -Note that the `YearMonth` class does not yet exist in the `kotlinx-datetime` library, therefore the multiplatfrom calendar library includes a minimal `YearMonth` class implementation to bridge this gap until the class [is hopefully added](https://github.com/Kotlin/kotlinx-datetime/issues/168) to the `kotlinx-datetime` library. - -The functions `plusMonths`, `minusMonths`, `plusDays` and `minusDays` used in the examples are provided out of the box by the `java.time` library, but can be easily added for multiplatform projects using the `kotlinx-datetime` library if needed: - -```kotlin -fun YearMonth.plusMonths(value: Int): YearMonth = plus(value, DateTimeUnit.MONTH) -fun YearMonth.minusMonths(value: Int): YearMonth = minus(value, DateTimeUnit.MONTH) -fun LocalDate.plusDays(value: Int): LocalDate = plus(value, DateTimeUnit.DAY) -fun LocalDate.minusDays(value: Int): LocalDate = minus(value, DateTimeUnit.DAY) -``` +Note that the `YearMonth` and `Year` classes do not yet exist in the `kotlinx-datetime` library, therefore the multiplaform calendar library includes minimal `YearMonth` and `Year` class implementations to bridge this gap until these classes [are hopefully added](https://github.com/Kotlin/kotlinx-datetime/issues/168) to the `kotlinx-datetime` library. ## Compose UI version compatibility @@ -57,7 +49,7 @@ Ensure that you are using the library version that matches the Compose UI versio ## Calendar Composables -The library can be used via four composables: +The library can be used via six composables: `HorizontalCalendar()`: Horizontally scrolling month-based calendar. @@ -67,9 +59,21 @@ The library can be used via four composables: `HeatMapCalendar()`: Horizontally scrolling heatmap calendar, useful for showing how data changes over time. A popular example is the user contribution chart on GitHub. +`HorizontalYearCalendar()`: Horizontally scrolling year-based calendar. + +`VerticalYearCalendar()`: Vertically scrolling year-based calendar. + All composables are based on LazyRow/LazyColumn for efficiency. -In the examples below, we will mostly use the month-based `HorizontalCalendar` and `VerticalCalendar` composables since all the calendar composables share the same basic concept. If you want a week-based calendar, use the `WeekCalendar` composable instead. Most state properties/methods with the name prefix/suffix `month` (e.g `firstVisibleMonth`) in the month-base calendar will have an equivalent with the name prefix/suffix `week` (e.g `firstVisibleWeek`) in the week-based calendar. +In the examples below, we will mostly use the month-based `HorizontalCalendar` +and `VerticalCalendar` composables since all the calendar composables share the same basic concept. +If you need a week-based calendar, use the `WeekCalendar` composable instead. If you need a +year-based calendar, use the `HorizontalYearCalendar` and `VerticalYearCalendar` composables. + +Most state properties/methods with the name prefix/suffix `month` (e.g `firstVisibleMonth`) in the +month-base calendar will have an equivalent with the name prefix/suffix `week` ( +e.g `firstVisibleWeek`) in the week-based calendar and `year` (e.g `firstVisibleYear`) in the +year-based calendar. ## Usage @@ -508,7 +512,9 @@ See the sample project for some complex implementations. ## Week calendar -As discussed previously, the library provides `HorizontalCalendar`, `VerticalCalendar`, and `WeekCalendar` composables. The `WeekCalendar` is a week-based calendar. Almost all topics covered above for the month calendar will apply to the week calendar. The main difference is that state properties/methods will have a slightly different name, typically with a `week` prefix/suffix instead of `month`. +The `WeekCalendar` is a week-based calendar. Almost all topics covered above for the month calendar +will apply to the week calendar. The main difference is that state properties/methods will have a +slightly different name, typically with a `week` prefix/suffix instead of `month`. For example: `firstVisibleMonth` => `firstVisibleWeek`, `scrollToMonth()` => `scrollToWeek()` and many others, but you get the idea. @@ -573,6 +579,95 @@ fun MainScreen() { Please see the `HeatMapCalendar` composable for the full documentation. There are also examples in the sample app. +## Year calendar + +The year-based calendar is best suited for large screens and can be used via +the `HorizontalYearCalendar` and `VerticalYearCalendar` composables. All topics covered above for +the month calendar will apply to the year calendar. The main difference is that state +properties/methods will have a slightly different name, typically with a `year` prefix/suffix +instead of `month`. + +For example: `firstVisibleMonth` => `firstVisibleYear`, `scrollToMonth()` => `scrollToYear()` and +many others, but you get the idea. + +The `monthHeader` and `monthFooter` parameters are available in both the month and year calendars +and serve the same purpose in both cases. The year calendar additionally provides the `yearHeader` +and `yearFooter` parameters to add a header or footer to each year on the calendar. + +Basic year calendar usage: + +```kotlin +@Composable +fun MainScreen() { + val currentYear = remember { Year.now() } + val startYear = remember { currentYear.minusYears(100) } // Adjust as needed + val endYear = remember { currentYear.plusYears(100) } // Adjust as needed + val firstDayOfWeek = remember { firstDayOfWeekFromLocale() } // Available from the library + + val state = rememberYearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = currentYear, + firstDayOfWeek = firstDayOfWeek, + ) + HorizontalYearCalendar( + state = state, + dayContent = { Day(it) }, + yearHeader = { YearHeader(it) }, + monthHeader = { MonthHeader(it) }, + ) + +// If you need a vertical year calendar. +// VerticalYearCalendar( +// state = state, +// dayContent = { Day(it) } +// ) +} +``` + +There is an additional `outDateStyle` parameter that can be provided when creating the state +via `rememberYearCalendarState`. This determines how the out-dates are generated. See +the [properties](#state-properties) section to understand this parameter. + +A year calendar implementation from the sample app: + +Year calendar + +The year calendar composables also provide a parameter `isMonthVisible` which determines if a month +is added to the calendar year grid. For example, if you want a calendar that starts in the year 2024 +and ends in the year 2054, but only shows months from from July 2024, the logic would look like +this: + +```kotlin +@Composable +fun MainScreen() { + val july2024 = remember { YearMonth.of(2024, Month.JULY) } + val startYear = remember { Year.of(2024) } + val endYear = remember { Year.of(2054) } + val firstDayOfWeek = remember { firstDayOfWeekFromLocale() } + + val state = rememberYearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = startYear, + firstDayOfWeek = firstDayOfWeek, + ) + HorizontalYearCalendar( + state = state, + dayContent = { Day(it) }, + yearHeader = { YearHeader(it) }, + monthHeader = { MonthHeader(it) }, + isMonthVisible = { month -> + month.yearMonth >= july2024 + } + ) +} +``` + +The logic above will produce this result: + +Year calendar + Remember that all the screenshots shown so far are just examples of what you can achieve with the library and you can absolutely build your calendar to look however you want. **Made a cool calendar with this library? Share an image [here](https://github.com/kizitonwose/Calendar/issues/1).** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75d3da56..8831358e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,17 @@ [versions] -agp = "8.5.0" +agp = "8.5.1" kotlin = "2.0.0" -compose = "1.7.0-beta03" -espresso = "3.5.1" +composeAndroid = "1.7.0-beta06" +composeMultiplatform = "1.7.0-alpha01" +espresso = "3.6.1" +junit5 = "5.10.3" +kotlinxSerialization = "1.7.1" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.0" } desugar = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" } @@ -18,23 +24,25 @@ material-view = { module = "com.google.android.material:material", version = "1. androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } -androidx-test-runner = { module = "androidx.test:runner", version = "1.5.2" } -androidx-test-rules = { module = "androidx.test:rules", version = "1.5.0" } -androidx-test-junit = { module = "androidx.test.ext:junit", version = "1.1.5" } -test-junit = { module = "junit:junit", version = "4.13.2" } - -compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } -compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } -compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } -compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } -compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } -compose-activity = { module = "androidx.activity:activity-compose", version = "1.9.0" } -compose-navigation = { module = "androidx.navigation:navigation-compose", version = "2.7.7" } - -compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } -compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } - -jetbrains-compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.8.0-alpha02" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.6.1" } +androidx-test-rules = { module = "androidx.test:rules", version = "1.6.1" } +androidx-test-junit = { module = "androidx.test.ext:junit", version = "1.2.1" } +test-junit4 = { module = "junit:junit", version = "4.13.2" } +test-junit5-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +test-junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } + +compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "composeAndroid" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "composeAndroid" } +compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "composeAndroid" } +compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "composeAndroid" } +compose-material3 = { module = "androidx.compose.material3:material3", version = "1.3.0-beta05" } +compose-activity = { module = "androidx.activity:activity-compose", version = "1.9.1" } +compose-navigation = { module = "androidx.navigation:navigation-compose", version = "2.8.0-beta06" } + +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "composeAndroid" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "composeAndroid" } + +jetbrains-compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.8.0-alpha08" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -42,11 +50,12 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlinter = { id = "org.jmailen.kotlinter", version = "4.3.0" } -mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.28.0" } +kotlinter = { id = "org.jmailen.kotlinter", version = "4.4.1" } +mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.29.0" } versionCheck = { id = "com.github.ben-manes.versions", version = "0.51.0" } bcv = "org.jetbrains.kotlinx.binary-compatibility-validator:0.15.1" # KMM kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -jetbrainsCompose = { id = "org.jetbrains.compose", version = "1.7.0-alpha01" } +jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index ee357143..b37110cd 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -59,11 +59,11 @@ dependencies { implementation(libs.compose.ui.tooling) implementation(libs.compose.foundation) implementation(libs.compose.runtime) - implementation(libs.compose.material) + implementation(libs.compose.material3) implementation(libs.compose.activity) implementation(libs.compose.navigation) - testImplementation(libs.test.junit) + testImplementation(libs.test.junit4) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.espresso.contrib) // RecyclerView actions. diff --git a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTests.kt b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTest.kt similarity index 99% rename from sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTests.kt rename to sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTest.kt index 4efb7bf6..857ef4a2 100644 --- a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTests.kt +++ b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarComposeTest.kt @@ -44,7 +44,7 @@ import java.time.temporal.WeekFields */ @RunWith(AndroidJUnit4::class) @LargeTest -class CalendarComposeTests { +class CalendarComposeTest { @get:Rule val composeTestRule = createComposeRule() diff --git a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTests.kt b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTest.kt similarity index 99% rename from sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTests.kt rename to sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTest.kt index 96f6141e..1fc86863 100644 --- a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTests.kt +++ b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/CalendarViewTest.kt @@ -41,7 +41,7 @@ import java.time.YearMonth */ @RunWith(AndroidJUnit4::class) @LargeTest -class CalendarViewTests { +class CalendarViewTest { @get:Rule val homeScreenRule = ActivityScenarioRule(CalendarViewActivity::class.java) diff --git a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTests.kt b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTest.kt similarity index 99% rename from sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTests.kt rename to sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTest.kt index 6dbcc75a..9f1dbea4 100644 --- a/sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTests.kt +++ b/sample/src/androidTest/java/com/kizitonwose/calendar/sample/WeekCalendarViewTest.kt @@ -30,7 +30,7 @@ import java.time.YearMonth */ @RunWith(AndroidJUnit4::class) @LargeTest -class WeekCalendarViewTests { +class WeekCalendarViewTest { @get:Rule val homeScreenRule = ActivityScenarioRule(CalendarViewActivity::class.java) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/CalendarComposeActivity.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/CalendarComposeActivity.kt index dadd1eef..324fa467 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/CalendarComposeActivity.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/CalendarComposeActivity.kt @@ -4,11 +4,14 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,12 +20,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource +import androidx.compose.ui.graphics.Color import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.kizitonwose.calendar.sample.R import com.kizitonwose.calendar.sample.shared.dateRangeDisplayText import kotlinx.coroutines.launch @@ -30,12 +32,11 @@ class CalendarComposeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - val primaryColor = colorResource(id = R.color.colorPrimary) var toolBarTitle by remember { mutableStateOf("") } var toolBarVisible by remember { mutableStateOf(true) } + val snackbarHostState = remember { SnackbarHostState() } val navController = rememberNavController() val coroutineScope = rememberCoroutineScope() - val scaffoldState = rememberScaffoldState() LaunchedEffect(navController) { navController.currentBackStackEntryFlow.collect { backStackEntry -> val page = Page.valueOf(backStackEntry.destination.route ?: return@collect) @@ -43,21 +44,23 @@ class CalendarComposeActivity : AppCompatActivity() { toolBarVisible = page.showToolBar } } - MaterialTheme(colors = MaterialTheme.colors.copy(primary = primaryColor)) { + MaterialTheme(colorScheme = SampleColorScheme) { Scaffold( - scaffoldState = scaffoldState, topBar = { if (toolBarVisible) { AppToolBar(title = toolBarTitle, navController) } }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, content = { AppNavHost( modifier = Modifier.padding(it), navController = navController, showSnack = { message -> coroutineScope.launch { - scaffoldState.snackbarHostState.showSnackbar(message) + snackbarHostState.showSnackbar(message) } }, ) @@ -67,10 +70,15 @@ class CalendarComposeActivity : AppCompatActivity() { } } + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AppToolBar(title: String, navController: NavHostController) { TopAppBar( title = { Text(text = title) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + ), navigationIcon = { NavigationIcon icon@{ val destination = @@ -117,6 +125,8 @@ class CalendarComposeActivity : AppCompatActivity() { horizontallyAnimatedComposable(Page.Example7.name) { Example7Page() } verticallyAnimatedComposable(Page.Example8.name) { Example8Page() } horizontallyAnimatedComposable(Page.Example9.name) { Example9Page() } + horizontallyAnimatedComposable(Page.Example10.name) { Example10Page() } + horizontallyAnimatedComposable(Page.Example11.name) { Example11Page() } } } } diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example10Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example10Page.kt new file mode 100644 index 00000000..d0dae98d --- /dev/null +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example10Page.kt @@ -0,0 +1,281 @@ +package com.kizitonwose.calendar.sample.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.SnapPosition +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices.PIXEL_7 +import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.HorizontalYearCalendar +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.sample.shared.displayText +import com.kizitonwose.calendar.sample.shared.yearsUntil +import kotlinx.coroutines.launch +import java.time.Year +import kotlin.math.abs + +@OptIn(ExperimentalCalendarApi::class) +@Composable +fun Example10Page(adjacentYears: Long = 50) { + val currentYear = remember { Year.now() } + val startYear = remember { currentYear.minusYears(adjacentYears) } + val endYear = remember { currentYear.plusYears(adjacentYears) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + val config = LocalConfiguration.current + val isTablet = config.smallestScreenWidthDp >= 600 + val isPortrait = config.screenHeightDp > config.screenWidthDp + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + ) { + val scope = rememberCoroutineScope() + val state = rememberYearCalendarState( + startYear = startYear, + endYear = endYear, + firstVisibleYear = currentYear, + firstDayOfWeek = daysOfWeek.first(), + ) + val visibleYear = rememberFirstVisibleYearAfterScroll(state).year + val headerState = rememberLazyListState() + LaunchedEffect(visibleYear) { + val index = startYear.yearsUntil(visibleYear).toInt() + headerState.animateScrollAndCenterItem(index) + } + YearHeader( + startYear = startYear, + endYear = endYear, + visibleYear = visibleYear, + headerState = headerState, + isTablet = isTablet, + ) click@{ targetYear -> + if (targetYear == visibleYear) return@click + scope.launch { + if (abs(visibleYear.yearsUntil(targetYear)) <= 8) { + state.animateScrollToYear(targetYear) + } else { + val nearbyYear = if (targetYear > visibleYear) { + targetYear.minusYears(5) + } else { + targetYear.plusYears(5) + } + state.scrollToYear(nearbyYear) + state.animateScrollToYear(targetYear) + } + } + } + HorizontalYearCalendar( + modifier = Modifier + .fillMaxSize() + .testTag("Calendar"), + state = state, + columns = if (isPortrait) { + 3 + } else { + if (isTablet) 4 else 6 + }, + dayContent = { day -> + Day( + day = day, + isSelected = selections.contains(day), + isTablet = isTablet, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + contentHeightMode = YearContentHeightMode.Fill, + monthHorizontalSpacing = if (isTablet) { + if (isPortrait) 52.dp else 92.dp + } else { + 10.dp + }, + monthVerticalSpacing = if (isTablet) 20.dp else 4.dp, + yearBodyContentPadding = if (isTablet) { + PaddingValues(horizontal = if (isPortrait) 52.dp else 92.dp, vertical = 20.dp) + } else { + PaddingValues(all = 10.dp) + }, + monthHeader = { + MonthHeader( + calendarMonth = it, + isTablet = isTablet, + ) + }, + ) + } +} + +@Composable +private fun YearHeader( + startYear: Year, + endYear: Year, + visibleYear: Year, + headerState: LazyListState, + isTablet: Boolean, + modifier: Modifier = Modifier, + onClick: (Year) -> Unit, +) { + LazyRow( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .background(headerBackground), + state = headerState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = headerState, SnapPosition.Center), + contentPadding = PaddingValues(horizontal = if (isTablet) 40.dp else 10.dp), + ) { + items(count = startYear.yearsUntil(endYear).toInt()) { index -> + val year = startYear.plusYears(index.toLong()) + val isSelected = visibleYear == year + Box( + modifier = Modifier + .then( + if (isSelected) { + Modifier.background( + color = simpleTextBackground(isSelected = true), + shape = RoundedCornerShape(4.dp), + ) + } else { + Modifier + }, + ) + .clickable(onClick = { onClick(year) }) + .padding( + horizontal = if (isTablet) 60.dp else 28.dp, + vertical = if (isTablet) 10.dp else 6.dp, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = year.value.toString(), + textAlign = TextAlign.Center, + fontSize = if (isTablet) 24.sp else 18.sp, + color = simpleTextColor(isSelected), + fontWeight = if (isSelected) FontWeight.Black else FontWeight.Light, + ) + } + } + } +} + +@Composable +private fun MonthHeader( + calendarMonth: CalendarMonth, + isTablet: Boolean, + modifier: Modifier = Modifier, +) { + val daysOfWeek = calendarMonth.weekDays.first().map { it.date.dayOfWeek } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(if (isTablet) 12.dp else 8.dp), + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = calendarMonth.yearMonth.month.displayText(short = false), + fontSize = if (isTablet) 16.sp else 12.sp, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Medium, + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = if (isTablet) 11.sp else 9.sp, + text = dayOfWeek.displayText(uppercase = true, narrow = true), + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun Day( + day: CalendarDay, + isSelected: Boolean, + isTablet: Boolean, + onClick: (CalendarDay) -> Unit, +) { + Box( + modifier = Modifier + .aspectRatio(1f) // This is important for square-sizing! + .testTag("MonthDay") + .padding(if (isTablet) 2.dp else 0.dp) + .clip(CircleShape) + .background(simpleTextBackground(isSelected)) + // Disable clicks on inDates/outDates + .clickable( + enabled = day.position == DayPosition.MonthDate, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + if (day.position == DayPosition.MonthDate) { + Text( + text = day.date.dayOfMonth.toString(), + fontSize = if (isTablet) 11.sp else 9.sp, + color = simpleTextColor(isSelected), + ) + } + } +} + +@Preview(showBackground = true, heightDp = 1280, widthDp = 800, device = PIXEL_TABLET) +@Preview(showBackground = true, heightDp = 800, widthDp = 1280, device = PIXEL_TABLET) +@Preview(showBackground = true, heightDp = 891, widthDp = 411, device = PIXEL_7) +@Preview(showBackground = true, heightDp = 411, widthDp = 891, device = PIXEL_7) +@Composable +private fun Example10Preview() { + Example10Page() +} + +private val headerBackground = Color(0xFFF1F1F1) +private fun simpleTextColor(isSelected: Boolean) = + if (isSelected) Color.White else Color.Black + +private fun simpleTextBackground(isSelected: Boolean) = + if (isSelected) Color.Black else Color.White diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example11Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example11Page.kt new file mode 100644 index 00000000..6cdea3b5 --- /dev/null +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example11Page.kt @@ -0,0 +1,192 @@ +package com.kizitonwose.calendar.sample.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices.PIXEL_7 +import androidx.compose.ui.tooling.preview.Devices.PIXEL_TABLET +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kizitonwose.calendar.compose.VerticalYearCalendar +import com.kizitonwose.calendar.compose.yearcalendar.YearContentHeightMode +import com.kizitonwose.calendar.compose.yearcalendar.rememberYearCalendarState +import com.kizitonwose.calendar.core.CalendarDay +import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.DayPosition +import com.kizitonwose.calendar.core.ExperimentalCalendarApi +import com.kizitonwose.calendar.core.daysOfWeek +import com.kizitonwose.calendar.sample.R +import com.kizitonwose.calendar.sample.shared.displayText +import java.time.Year +import java.time.YearMonth + +@OptIn(ExperimentalCalendarApi::class) +@Composable +fun Example11Page(adjacentYears: Long = 50) { + val currentMonth = remember { YearMonth.now() } + val currentYear = remember { Year.of(currentMonth.year) } + val endYear = remember { currentYear.plusYears(adjacentYears) } + val selections = remember { mutableStateListOf() } + val daysOfWeek = remember { daysOfWeek() } + val config = LocalConfiguration.current + val isTablet = config.smallestScreenWidthDp >= 600 + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + ) { + val state = rememberYearCalendarState( + startYear = currentYear, + endYear = endYear, + firstVisibleYear = currentYear, + firstDayOfWeek = daysOfWeek.first(), + ) + VerticalYearCalendar( + modifier = Modifier + .fillMaxSize() + .testTag("Calendar"), + state = state, + dayContent = { day -> + Day( + day = day, + isSelected = selections.contains(day), + isTablet = isTablet, + ) { clicked -> + if (selections.contains(clicked)) { + selections.remove(clicked) + } else { + selections.add(clicked) + } + } + }, + calendarScrollPaged = false, + contentHeightMode = YearContentHeightMode.Wrap, + monthVerticalSpacing = 20.dp, + monthHorizontalSpacing = if (isTablet) 52.dp else 10.dp, + contentPadding = PaddingValues(horizontal = if (isTablet) 52.dp else 10.dp), + isMonthVisible = { + it.yearMonth >= currentMonth + }, + yearHeader = { + YearHeader(it.year) + }, + monthHeader = { + MonthHeader(it) + }, + ) + } +} + +@Composable +private fun MonthHeader(calendarMonth: CalendarMonth) { + val daysOfWeek = calendarMonth.weekDays.first().map { it.date.dayOfWeek } + Column( + modifier = Modifier + .wrapContentHeight() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp), + text = calendarMonth.yearMonth.month.displayText(short = false), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (dayOfWeek in daysOfWeek) { + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 11.sp, + text = dayOfWeek.displayText(uppercase = true, narrow = true), + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +@Composable +private fun YearHeader(year: Year) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 20.dp) + .testTag("MonthHeader"), + fontSize = 52.sp, + text = year.toString(), + fontWeight = FontWeight.Medium, + ) + HorizontalDivider() + } +} + +@Composable +private fun Day( + day: CalendarDay, + isSelected: Boolean, + isTablet: Boolean, + onClick: (CalendarDay) -> Unit, +) { + Box( + modifier = Modifier + .aspectRatio(1f) // This is important for square-sizing! + .testTag("MonthDay") + .padding(if (isTablet) 2.dp else 0.dp) + .clip(CircleShape) + .background(color = if (isSelected) colorResource(R.color.example_1_selection_color) else Color.Transparent) + // Disable clicks on inDates/outDates + .clickable( + enabled = day.position == DayPosition.MonthDate, + showRipple = !isSelected, + onClick = { onClick(day) }, + ), + contentAlignment = Alignment.Center, + ) { + if (day.position == DayPosition.MonthDate) { + Text( + text = day.date.dayOfMonth.toString(), + fontSize = if (isTablet) 10.sp else 9.sp, + color = if (isSelected) Color.White else Color.Unspecified, + ) + } + } +} + +@Preview(showBackground = true, heightDp = 1280, widthDp = 800, device = PIXEL_TABLET) +@Preview(showBackground = true, heightDp = 891, widthDp = 411, device = PIXEL_7) +@Composable +private fun Example11Preview() { + Example11Page() +} diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example1Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example1Page.kt index 89d20a9d..842f8cd3 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example1Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example1Page.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2Page.kt index 9c4c3f55..5b7c1bc8 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2Page.kt @@ -19,11 +19,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -69,7 +69,7 @@ fun Example2Page( var selection by remember { mutableStateOf(DateSelection()) } val daysOfWeek = remember { daysOfWeek() } StatusBarColorUpdateEffect(Color.White) - MaterialTheme(colors = MaterialTheme.colors.copy(primary = primaryColor)) { + MaterialTheme(colorScheme = MaterialTheme.colorScheme.copy(primary = primaryColor)) { Box( modifier = Modifier .fillMaxSize() @@ -247,7 +247,7 @@ private fun CalendarTop( } } } - Divider() + HorizontalDivider() } } @@ -258,7 +258,7 @@ private fun CalendarBottom( save: () -> Unit, ) { Column(modifier.fillMaxWidth()) { - Divider() + HorizontalDivider() Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2PageHighlight.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2PageHighlight.kt index a90030f2..50a9269d 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2PageHighlight.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example2PageHighlight.kt @@ -109,7 +109,8 @@ fun Modifier.backgroundHighlight( } DayPosition.InDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isInDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -120,7 +121,8 @@ fun Modifier.backgroundHighlight( } DayPosition.OutDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isOutDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -198,7 +200,8 @@ fun Modifier.backgroundHighlightLegacy( } DayPosition.InDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isInDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) @@ -209,7 +212,8 @@ fun Modifier.backgroundHighlightLegacy( } DayPosition.OutDate -> { textColor(Color.Transparent) - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isOutDateBetweenSelection(day.date, startDate, endDate) ) { padding(vertical = padding) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example3Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example3Page.kt index 225307de..27fcccb1 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example3Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example3Page.kt @@ -19,14 +19,13 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.LocalContentColor -import androidx.compose.material.LocalRippleConfiguration -import androidx.compose.material.RippleConfiguration -import androidx.compose.material.Text -import androidx.compose.material.darkColors import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.RippleConfiguration +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -107,7 +106,7 @@ fun Example3Page() { } // Draw light content on dark background. - CompositionLocalProvider(LocalContentColor provides darkColors().onSurface) { + CompositionLocalProvider(LocalContentColor provides Color.White) { SimpleCalendarTitle( modifier = Modifier .background(toolbarColor) @@ -128,7 +127,7 @@ fun Example3Page() { modifier = Modifier.wrapContentWidth(), state = state, dayContent = { day -> - @OptIn(ExperimentalMaterialApi::class) + @OptIn(ExperimentalMaterial3Api::class) CompositionLocalProvider(LocalRippleConfiguration provides Example3RippleConfiguration) { val colors = if (day.position == DayPosition.MonthDate) { flights[day.date].orEmpty().map { colorResource(it.color) } @@ -151,7 +150,7 @@ fun Example3Page() { ) }, ) - Divider(color = pageBackgroundColor) + HorizontalDivider(color = pageBackgroundColor) LazyColumn(modifier = Modifier.fillMaxWidth()) { items(items = flightsInSelectedDate.value) { flight -> FlightInformation(flight) @@ -272,7 +271,7 @@ private fun LazyItemScope.FlightInformation(flight: Flight) { AirportInformation(flight.destination, isDeparture = false) } } - Divider(color = pageBackgroundColor, thickness = 2.dp) + HorizontalDivider(thickness = 2.dp, color = pageBackgroundColor) } @Composable @@ -322,7 +321,7 @@ private fun AirportInformation(airport: Airport, isDeparture: Boolean) { } // The default dark them ripple is too bright so we tone it down. -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) private val Example3RippleConfiguration = RippleConfiguration( color = Color.Gray, // Copied from RippleTheme#DarkThemeRippleAlpha diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example4Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example4Page.kt index c289c55c..45cbd928 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example4Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example4Page.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Divider -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -120,12 +120,12 @@ private fun MonthHeader(calendarMonth: CalendarMonth) { modifier = Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 15.sp, - text = dayOfWeek.name.first().toString(), + text = dayOfWeek.displayText(uppercase = true, narrow = true), fontWeight = FontWeight.Medium, ) } } - Divider(color = Color.Black) + HorizontalDivider(color = Color.Black) } } diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example5Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example5Page.kt index 76ecbe3e..c2f58dde 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example5Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example5Page.kt @@ -10,8 +10,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,6 +34,7 @@ import com.kizitonwose.calendar.sample.shared.getWeekPageTitle import java.time.LocalDate import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable fun Example5Page(close: () -> Unit = {}) { val currentDate = remember { LocalDate.now() } @@ -51,7 +53,6 @@ fun Example5Page(close: () -> Unit = {}) { ) val visibleWeek = rememberFirstVisibleWeekAfterScroll(state) TopAppBar( - elevation = 0.dp, title = { Text(text = getWeekPageTitle(visibleWeek)) }, navigationIcon = { NavigationIcon(onBackClick = close) }, ) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example6Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example6Page.kt index d9ff85e0..ce2eb27f 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example6Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example6Page.kt @@ -13,8 +13,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -275,8 +275,10 @@ private fun getMonthWithYear( val firstItem = visibleItemsInfo.first() val daySizePx = with(density) { daySize.toPx() } if ( - firstItem.size < daySizePx * 3 || // Ensure the Month + Year text can fit. - firstItem.offset < layoutInfo.viewportStartOffset && // Ensure the week row size - 1 is visible. + // Ensure the Month + Year text can fit. + firstItem.size < daySizePx * 3 || + // Ensure the week row size - 1 is visible. + firstItem.offset < layoutInfo.viewportStartOffset && (layoutInfo.viewportStartOffset - firstItem.offset > daySizePx) ) { visibleItemsInfo[1].month.yearMonth diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example7Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example7Page.kt index 04e60159..b2c53d66 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example7Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example7Page.kt @@ -11,9 +11,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Text -import androidx.compose.material.darkColors +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -54,7 +53,7 @@ fun Example7Page() { firstVisibleWeekDate = currentDate, ) // Draw light content on dark background. - CompositionLocalProvider(LocalContentColor provides darkColors().onSurface) { + CompositionLocalProvider(LocalContentColor provides Color.White) { WeekCalendar( modifier = Modifier.padding(vertical = 4.dp), state = state, diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example8Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example8Page.kt index 1543e869..880c70d8 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example8Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example8Page.kt @@ -10,14 +10,19 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.LocalContentColor -import androidx.compose.material.Text -import androidx.compose.material.darkColors +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,7 +56,7 @@ import java.time.DayOfWeek import java.time.LocalDate @Composable -fun Example8Page(horizontal: Boolean = true) { +fun Example8Page(horizontal: Boolean? = null) { val today = remember { LocalDate.now() } val currentMonth = remember(today) { today.yearMonth } val startMonth = remember { currentMonth.minusMonths(500) } @@ -65,6 +70,8 @@ fun Example8Page(horizontal: Boolean = true) { .background(colorResource(id = R.color.example_1_bg_light)) .padding(top = 20.dp), ) { + var selectedIndex by remember { mutableIntStateOf(0) } + PageOptions(selectedIndex) { selectedIndex = it } val state = rememberCalendarState( startMonth = startMonth, endMonth = endMonth, @@ -75,10 +82,11 @@ fun Example8Page(horizontal: Boolean = true) { val coroutineScope = rememberCoroutineScope() val visibleMonth = rememberFirstVisibleMonthAfterScroll(state) // Draw light content on dark background. - CompositionLocalProvider(LocalContentColor provides darkColors().onSurface) { + CompositionLocalProvider(LocalContentColor provides Color.White) { SimpleCalendarTitle( modifier = Modifier.padding(vertical = 10.dp, horizontal = 8.dp), currentMonth = visibleMonth.yearMonth, + isHorizontal = selectedIndex == 0, goToPrevious = { coroutineScope.launch { state.animateScrollToMonth(state.firstVisibleMonth.yearMonth.previousMonth) @@ -96,7 +104,7 @@ fun Example8Page(horizontal: Boolean = true) { .background(colorResource(id = R.color.example_1_bg)) .testTag("Calendar"), state = state, - horizontal = horizontal, + horizontal = horizontal ?: (selectedIndex == 0), dayContent = { day -> Day( day = day, @@ -168,6 +176,32 @@ private fun FullScreenCalendar( } } +@Composable +private fun PageOptions(selectedIndex: Int, onSelect: (Int) -> Unit) { + val options = listOf("Horizontal", "Vertical") + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp, horizontal = 16.dp), + ) { + options.forEachIndexed { index, label -> + SegmentedButton( + colors = SegmentedButtonDefaults.colors().copy( + activeContainerColor = colorResource(R.color.example_1_bg), + activeContentColor = Color.White, + inactiveContainerColor = Color.Transparent, + inactiveContentColor = Color.White, + ), + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + onClick = { onSelect(index) }, + selected = index == selectedIndex, + ) { + Text(label) + } + } + } +} + @Composable private fun MonthHeader(daysOfWeek: List) { Row( diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example9Page.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example9Page.kt index 0218c36d..7ce49e64 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example9Page.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Example9Page.kt @@ -17,10 +17,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/ListPage.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/ListPage.kt index 36615fa5..f63c0bdd 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/ListPage.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/ListPage.kt @@ -8,16 +8,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp enum class Page(val title: String, val subtitle: String, val showToolBar: Boolean) { List( @@ -71,6 +69,16 @@ enum class Page(val title: String, val subtitle: String, val showToolBar: Boolea subtitle = "Month and week calendar toggle with animations.", showToolBar = true, ), + Example10( + title = "Example 10", + subtitle = "Horizontal year calendar - Year header and paged scrolling. Best suited for large screens.", + showToolBar = true, + ), + Example11( + title = "Example 11", + subtitle = "Vertical year calendar - Hidden past months with continuous scroll. Best suited for large screens.", + showToolBar = true, + ), } @Composable @@ -88,25 +96,19 @@ fun ListPage(click: (Page) -> Unit) { .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - val titleStyle = MaterialTheme.typography.subtitle1 + val titleStyle = MaterialTheme.typography.titleMedium Text( text = item.title, fontWeight = FontWeight.Medium, - style = titleStyle.copy( - fontSize = 20.sp, - color = titleStyle.color.copy(alpha = ContentAlpha.high), - ), + style = titleStyle, ) - val subtitleStyle = MaterialTheme.typography.body2 + val subtitleStyle = MaterialTheme.typography.bodyMedium Text( text = item.subtitle, - style = subtitleStyle.copy( - fontSize = 16.sp, - color = subtitleStyle.color.copy(alpha = ContentAlpha.medium), - ), + style = subtitleStyle, ) } - Divider() + HorizontalDivider() } } } diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/SimpleCalendarTitle.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/SimpleCalendarTitle.kt index 173a2e21..2b6359a4 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/SimpleCalendarTitle.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/SimpleCalendarTitle.kt @@ -1,5 +1,6 @@ package com.kizitonwose.calendar.sample.compose +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -9,15 +10,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -32,6 +35,7 @@ import java.time.YearMonth fun SimpleCalendarTitle( modifier: Modifier, currentMonth: YearMonth, + isHorizontal: Boolean = true, goToPrevious: () -> Unit, goToNext: () -> Unit, ) { @@ -43,6 +47,7 @@ fun SimpleCalendarTitle( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous", onClick = goToPrevious, + isHorizontal = isHorizontal, ) Text( modifier = Modifier @@ -57,6 +62,7 @@ fun SimpleCalendarTitle( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next", onClick = goToNext, + isHorizontal = isHorizontal, ) } } @@ -65,6 +71,7 @@ fun SimpleCalendarTitle( private fun CalendarNavigationIcon( imageVector: ImageVector, contentDescription: String, + isHorizontal: Boolean = true, onClick: () -> Unit, ) = Box( modifier = Modifier @@ -73,11 +80,16 @@ private fun CalendarNavigationIcon( .clip(shape = CircleShape) .clickable(role = Role.Button, onClick = onClick), ) { + val rotation by animateFloatAsState( + targetValue = if (isHorizontal) 0f else 90f, + label = "CalendarNavigationIconAnimation", + ) Icon( modifier = Modifier .fillMaxSize() .padding(4.dp) - .align(Alignment.Center), + .align(Alignment.Center) + .rotate(rotation), imageVector = imageVector, contentDescription = contentDescription, ) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Theme.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Theme.kt new file mode 100644 index 00000000..5fce7d6e --- /dev/null +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Theme.kt @@ -0,0 +1,43 @@ +package com.kizitonwose.calendar.sample.compose + +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +val SampleColorScheme = lightColorScheme( + primary = Color(0xFF3F51B5), + onPrimary = Color.White, + primaryContainer = Color(0xFF3F51B5), + onPrimaryContainer = Color.White, + inversePrimary = Color.Black, + secondary = Color(0xFF3F51B5), + onSecondary = Color.White, + secondaryContainer = Color(0xFF3F51B5), + onSecondaryContainer = Color.White, + tertiary = Color(0xFF3F51B5), + onTertiary = Color.White, + tertiaryContainer = Color(0xFF3F51B5), + onTertiaryContainer = Color.White, + background = Color.White, + onBackground = Color.Black, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color.White, + onSurfaceVariant = Color.Black, + surfaceTint = Color.White, + inverseSurface = Color(0xFF121212), + inverseOnSurface = Color.White, + error = Color(0xFFB00020), + onError = Color.White, + errorContainer = Color(0xFFB00020), + onErrorContainer = Color.White, + outline = Color(0xFFAFAFAF), + outlineVariant = Color(0xFFCCCCCC), + scrim = Color.Black, + surfaceBright = Color.White, + surfaceContainer = Color.White, + surfaceContainerHigh = Color.White, + surfaceContainerHighest = Color.White, + surfaceContainerLow = Color.White, + surfaceContainerLowest = Color.White, + surfaceDim = Color.White, +) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Utils.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Utils.kt index b5e699e5..1535fd4b 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Utils.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/compose/Utils.kt @@ -2,15 +2,17 @@ package com.kizitonwose.calendar.sample.compose import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf @@ -30,7 +32,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.kizitonwose.calendar.compose.CalendarLayoutInfo import com.kizitonwose.calendar.compose.CalendarState import com.kizitonwose.calendar.compose.weekcalendar.WeekCalendarState +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarLayoutInfo +import com.kizitonwose.calendar.compose.yearcalendar.YearCalendarState import com.kizitonwose.calendar.core.CalendarMonth +import com.kizitonwose.calendar.core.CalendarYear import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.sample.shared.StatusBarColorLifecycleObserver import com.kizitonwose.calendar.sample.shared.findActivity @@ -156,6 +161,41 @@ fun rememberFirstMostVisibleMonth( return visibleMonth.value } +/** + * Find the first year on the calendar visible up to the given [viewportPercent] size. + * + * @see [rememberFirstVisibleYearAfterScroll] + */ +@Composable +fun rememberFirstMostVisibleYear( + state: YearCalendarState, + viewportPercent: Float = 50f, +): CalendarYear { + val visibleMonth = remember(state) { mutableStateOf(state.firstVisibleYear) } + LaunchedEffect(state) { + snapshotFlow { state.layoutInfo.firstMostVisibleYear(viewportPercent) } + .filterNotNull() + .collect { month -> visibleMonth.value = month } + } + return visibleMonth.value +} + +/** + * Returns the first visible year in a paged calendar **after** scrolling stops. + * + * @see [rememberFirstMostVisibleYear] + */ +@Composable +fun rememberFirstVisibleYearAfterScroll(state: YearCalendarState): CalendarYear { + val visibleYear = remember(state) { mutableStateOf(state.firstVisibleYear) } + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { scrolling -> !scrolling } + .collect { visibleYear.value = state.firstVisibleYear } + } + return visibleYear.value +} + private val CalendarLayoutInfo.completelyVisibleMonths: List get() { val visibleItemsInfo = this.visibleMonthsInfo.toMutableList() @@ -189,3 +229,41 @@ private fun CalendarLayoutInfo.firstMostVisibleMonth(viewportPercent: Float = 50 }?.month } } + +private fun YearCalendarLayoutInfo.firstMostVisibleYear(viewportPercent: Float = 50f): CalendarYear? { + return if (visibleYearsInfo.isEmpty()) { + null + } else { + val viewportSize = (viewportEndOffset + viewportStartOffset) * viewportPercent / 100f + visibleYearsInfo.firstOrNull { itemInfo -> + if (itemInfo.offset < 0) { + itemInfo.offset + itemInfo.size >= viewportSize + } else { + itemInfo.size - itemInfo.offset >= viewportSize + } + }?.year + } +} + +suspend fun LazyListState.animateScrollAndCenterItem(index: Int) { + suspend fun animateScrollIfVisible(): Boolean { + val layoutInfo = layoutInfo + val containerSize = layoutInfo.viewportSize.width - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + val target = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return false + val targetOffset = containerSize / 2f - target.size / 2f + animateScrollBy(target.offset - targetOffset) + return true + } + if (!animateScrollIfVisible()) { + val visibleItemsInfo = layoutInfo.visibleItemsInfo + val currentIndex = visibleItemsInfo.getOrNull(visibleItemsInfo.size / 2)?.index ?: -1 + scrollToItem( + if (index > currentIndex) { + (index - visibleItemsInfo.size + 1) + } else { + index + }.coerceIn(0, layoutInfo.totalItemsCount), + ) + animateScrollIfVisible() + } +} diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/shared/Utils.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/shared/Utils.kt index 798103c1..a54d4e8e 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/shared/Utils.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/shared/Utils.kt @@ -7,8 +7,10 @@ import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.yearMonth import java.time.DayOfWeek import java.time.Month +import java.time.Year import java.time.YearMonth import java.time.format.TextStyle +import java.time.temporal.ChronoUnit import java.util.Locale fun YearMonth.displayText(short: Boolean = false): String { @@ -20,8 +22,9 @@ fun Month.displayText(short: Boolean = true): String { return getDisplayName(style, Locale.ENGLISH) } -fun DayOfWeek.displayText(uppercase: Boolean = false): String { - return getDisplayName(TextStyle.SHORT, Locale.ENGLISH).let { value -> +fun DayOfWeek.displayText(uppercase: Boolean = false, narrow: Boolean = false): String { + val style = if (narrow) TextStyle.NARROW else TextStyle.SHORT + return getDisplayName(style, Locale.ENGLISH).let { value -> if (uppercase) value.uppercase(Locale.ENGLISH) else value } } @@ -50,3 +53,5 @@ fun getWeekPageTitle(week: Week): String { } } } + +fun Year.yearsUntil(other: Year) = ChronoUnit.YEARS.between(this, other) diff --git a/sample/src/main/java/com/kizitonwose/calendar/sample/view/Example4Fragment.kt b/sample/src/main/java/com/kizitonwose/calendar/sample/view/Example4Fragment.kt index aefcf2f3..b105d78b 100644 --- a/sample/src/main/java/com/kizitonwose/calendar/sample/view/Example4Fragment.kt +++ b/sample/src/main/java/com/kizitonwose/calendar/sample/view/Example4Fragment.kt @@ -224,13 +224,15 @@ class Example4Fragment : BaseFragment(R.layout.example_4_fragment), HasToolbar, // Make the coloured selection background continuous on the // invisible in and out dates across various months. DayPosition.InDate -> - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isInDateBetweenSelection(data.date, startDate, endDate) ) { continuousBgView.applyBackground(rangeMiddleBackground) } DayPosition.OutDate -> - if (startDate != null && endDate != null && + if (startDate != null && + endDate != null && isOutDateBetweenSelection(data.date, startDate, endDate) ) { continuousBgView.applyBackground(rangeMiddleBackground) diff --git a/sample/src/main/res/layout/calendar_view_activity.xml b/sample/src/main/res/layout/calendar_view_activity.xml index d001e1a0..39b066f3 100644 --- a/sample/src/main/res/layout/calendar_view_activity.xml +++ b/sample/src/main/res/layout/calendar_view_activity.xml @@ -5,6 +5,7 @@ android:id="@+id/homeRootLayout" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@color/white" tools:context=".view.CalendarViewActivity"> - \ No newline at end of file + diff --git a/sample/src/main/res/layout/home_activity.xml b/sample/src/main/res/layout/home_activity.xml index c3b8d749..8197c798 100644 --- a/sample/src/main/res/layout/home_activity.xml +++ b/sample/src/main/res/layout/home_activity.xml @@ -6,6 +6,7 @@ android:layout_width="match_parent" android:orientation="vertical" android:layout_height="match_parent" + android:background="@color/white" tools:context=".HomeActivity"> - \ No newline at end of file + diff --git a/view/src/main/java/com/kizitonwose/calendar/view/CalendarView.kt b/view/src/main/java/com/kizitonwose/calendar/view/CalendarView.kt index ec6f77de..1769ad55 100644 --- a/view/src/main/java/com/kizitonwose/calendar/view/CalendarView.kt +++ b/view/src/main/java/com/kizitonwose/calendar/view/CalendarView.kt @@ -10,7 +10,7 @@ import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.CalendarMonth import com.kizitonwose.calendar.core.DayPosition import com.kizitonwose.calendar.core.OutDateStyle -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.view.internal.CalendarPageSnapHelper import com.kizitonwose.calendar.view.internal.CalendarPageSnapHelperLegacy import com.kizitonwose.calendar.view.internal.monthcalendar.MonthCalendarAdapter @@ -434,7 +434,7 @@ public open class CalendarView : RecyclerView { * @param firstDayOfWeek A [DayOfWeek] to be the first day of week. */ public fun setup(startMonth: YearMonth, endMonth: YearMonth, firstDayOfWeek: DayOfWeek) { - checkDateRange(startMonth = startMonth, endMonth = endMonth) + checkRange(start = startMonth, end = endMonth) this.startMonth = startMonth this.endMonth = endMonth this.firstDayOfWeek = firstDayOfWeek @@ -464,7 +464,7 @@ public open class CalendarView : RecyclerView { endMonth: YearMonth = requireEndMonth(), firstDayOfWeek: DayOfWeek = requireFirstDayOfWeek(), ) { - checkDateRange(startMonth = startMonth, endMonth = endMonth) + checkRange(start = startMonth, end = endMonth) this.startMonth = startMonth this.endMonth = endMonth this.firstDayOfWeek = firstDayOfWeek diff --git a/view/src/main/java/com/kizitonwose/calendar/view/WeekCalendarView.kt b/view/src/main/java/com/kizitonwose/calendar/view/WeekCalendarView.kt index e4fc63f4..7e461c1f 100644 --- a/view/src/main/java/com/kizitonwose/calendar/view/WeekCalendarView.kt +++ b/view/src/main/java/com/kizitonwose/calendar/view/WeekCalendarView.kt @@ -7,7 +7,7 @@ import androidx.core.content.withStyledAttributes import androidx.recyclerview.widget.RecyclerView import com.kizitonwose.calendar.core.Week import com.kizitonwose.calendar.core.WeekDay -import com.kizitonwose.calendar.data.checkDateRange +import com.kizitonwose.calendar.data.checkRange import com.kizitonwose.calendar.view.internal.CalendarPageSnapHelperLegacy import com.kizitonwose.calendar.view.internal.weekcalendar.WeekCalendarAdapter import com.kizitonwose.calendar.view.internal.weekcalendar.WeekCalendarLayoutManager @@ -387,7 +387,7 @@ public open class WeekCalendarView : RecyclerView { * @param firstDayOfWeek A [DayOfWeek] to be the first day of week. */ public fun setup(startDate: LocalDate, endDate: LocalDate, firstDayOfWeek: DayOfWeek) { - checkDateRange(startDate = startDate, endDate = endDate) + checkRange(start = startDate, end = endDate) this.startDate = startDate this.endDate = endDate this.firstDayOfWeek = firstDayOfWeek @@ -416,7 +416,7 @@ public open class WeekCalendarView : RecyclerView { endDate: LocalDate = requireEndDate(), firstDayOfWeek: DayOfWeek = requireFirstDayOfWeek(), ) { - checkDateRange(startDate = startDate, endDate = endDate) + checkRange(start = startDate, end = endDate) this.startDate = startDate this.endDate = endDate this.firstDayOfWeek = firstDayOfWeek