diff --git a/ComposeCalendar/.gitignore b/ComposeCalendar/.gitignore new file mode 100644 index 000000000..14a4e7e33 --- /dev/null +++ b/ComposeCalendar/.gitignore @@ -0,0 +1,71 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ diff --git a/ComposeCalendar/.idea/.gitignore b/ComposeCalendar/.idea/.gitignore new file mode 100644 index 000000000..e9dc156a8 --- /dev/null +++ b/ComposeCalendar/.idea/.gitignore @@ -0,0 +1,6 @@ +# Default ignored files +/* +!codeStyles/ +!fileTemplates/ +!file.template.settings.xml +!.gitignore diff --git a/ComposeCalendar/.idea/codeStyles/Project.xml b/ComposeCalendar/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..55e76659b --- /dev/null +++ b/ComposeCalendar/.idea/codeStyles/Project.xml @@ -0,0 +1,133 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ComposeCalendar/.idea/codeStyles/codeStyleConfig.xml b/ComposeCalendar/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/ComposeCalendar/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/ComposeCalendar/LICENSE b/ComposeCalendar/LICENSE new file mode 100644 index 000000000..4ad935286 --- /dev/null +++ b/ComposeCalendar/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Bogusz Pawłowski + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ComposeCalendar/README.md b/ComposeCalendar/README.md new file mode 100644 index 000000000..68b23a599 --- /dev/null +++ b/ComposeCalendar/README.md @@ -0,0 +1,250 @@ + + +Compose Calendar is a composable handling all complexity of rendering calendar component and date selection. +Due to flexibility provided by slot API's, you can decide how the calendar will look like, the library will handle proper calendar elements arrangement and it's state. + +![Github Actions](https://github.com/boguszpawlowski/composecalendar/actions/workflows/publish-snapshot.yml/badge.svg?branch=main) +[](https://search.maven.org/search?q=g:io.github.boguszpawlowski.composecalendar) +[](https://s01.oss.sonatype.org/content/repositories/snapshots/io/github/boguszpawlowski/composecalendar/) +[](https://androidweekly.net/issues/issue-501/) +## Setup +Library is available on Maven Central repository. +```kotlin + // module-level build.gradle + dependecies { + implementation "io.github.boguszpawlowski.composecalendar:composecalendar:" + + // separate artifact with utilities for working with kotlinx-datetime + implementation "io.github.boguszpawlowski.composecalendar:kotlinx-datetime:" + } +``` +Snapshots are available on [Sonatype’s snapshots repository](https://s01.oss.sonatype.org/content/repositories/snapshots/io/github/boguszpawlowski/composecalendar/). + +### 1.3.0 Changes +From version 1.3.0 library no longer uses `coreLibraryDesugaring` itself. If you are using it in project supporting min SDK < 26, you need to add it to prevent runtime crashes. +```kotlin + // app-level build.gradle + android { + compileOptions { + coreLibraryDesugaringEnabled true + } + kotlinOptions { + jvmTarget = "1.8" + } + } + dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" + } +``` + +## Supported features +- Selection (single, multiple or a range of days) +- Chose day as first day of week +- Showing/hiding adjacent months +- Month and week headers +- Customizable month container +- Fully customizable day content +- Horizontal swipe for changing a current month +- Month / Week mode +- Min / Max Month (or Week) + +## Basic Usage + +### Static calendar +To show the basic version of the calendar, without any kind of selection mechanism, you can simply use the `StaticCalendar` composable without passing any parameters: +```kotlin + + @Composable + fun MainScreen() { + StaticCalendar() + } + +``` +This chunk will render the calendar with default components for each day, and also month and week headers. +See the `StaticCalendarSample` file for a full example. For showing a week calendar, you can similarly use `StaticWeekCalendar`: +```kotlin + + @Composable + fun MainScreen() { + StaticWeekCalendar() + } + +``` + + + +> :exclamation: By default, at first the calendar will show current month. If you want to start with some different date, you have to pass an `initialMonth` parameter to the initial state of the calendar. See [Initial State section](#initial-state) + +### Selectable calendar +Calendar with a mechanism for selection. The default implementation uses `DynamicSelectionState` (see [Dynamic Selection section](#dynamic-selection-state)) which allows to change `SelectionMode` in the runtime. +```kotlin + + @Composable + fun MainScreen() { + SelectableCalendar() + } + +``` +By the default, after changing the selection mode, selection is cleared. +See the `SelectableCalendarSample` file for a full example. For showing a week calendar, you can similarly use `SelectableWeekCalendar`: +```kotlin + + @Composable + fun MainScreen() { + SelectableWeekCalendar() + } + +``` +See the `WeekCalendarSample` file for a full example. + +https://user-images.githubusercontent.com/36514058/126049987-685a7b81-1596-48a4-95a1-c342f6c796bf.mp4 + +> :exclamation: If you want to define your own selection behavior, please check out the [Custom Selection section](#custom-selection) and/or `CustomSelectionSample`. + +### Calendar with custom components +For the customization you should pass your own composable functions as day content, moth header etc.: +```kotlin + + @Composable + fun MyDay(dayState: DayState) { + Text(dayState.date.dayOfMonth.toString()) + } + + @Composable + fun MainScreen() { + StaticCalendar( + dayContent = { dayState -> MyDay(dayState) } + ) + } + +``` +The same you can do for every customizable element: +- Day content - responsible for single day content +- Month header - responsible for showing the current month (and by default for changing the current month) +- Week header - responsible for showing the names of week days. +- Month container - wrapping the month content, it defaults to a plain `Box`, but can be any layout. + +The `Calendar` composable accepts a `Modifier` for simple customization of the overall appearance. +See the `CustomComponentsSample` for a full example. + +### Custom selection +As the selection state is represented by an interface, you can provide your own implementation, to suit your +use-case. E.g: +```kotlin + class MonthSelectionState( + initialSelection: YearMonth? = null, + ) : SelectionState { + private var selection by mutableStateOf(initialSelection) + + override fun isDateSelected(date: LocalDate): Boolean = + date.yearMonth == selection + + override fun onDateSelected(date: LocalDate) { + selection = if (date.yearMonth == selection) null else date.yearMonth + } + } +``` +To use the defined selection state, you have to pass it into a generic version of `Calendar` composable. +This chunk is an implementation that will select all days in a clicked day's month. For a full example +please check out `CustomSelectionSample` file. + +### Calendar properties customization +Apart from rendering your own components inside the calendar, you can modify it by passing different properties.: +- `showAdjacentMonths` - whenever to render days from adjacent months. Defaults to `true`. +- `firstDayOfWeek` - you can pass the `DayOfWeek` which you want you week to start with. It defaults to the first day of week of the `Locale.default()`. +- `horizontalScrollEnabled` - a Boolean flag which enables month to be changed by a horizontal swipe. Defaults to `true`. +- `minMonth` - a `YearMonth` object representing the minimum month that can be shown in the calendar. By default there is no minimum month. +- `maxMonth` - a `YearMonth` object representing the maximum month that can be shown in the calendar. By default there is no maximum month. + +> :exclamation: You cannot set `minMonth` to be lower than `maxMonth` and vice versa. If you do so, the calendar state won't change. + +Apart from this, `Calendar` you can pass a `Modifier` object like in any other composable. + +## State +Calendar composable holds its state as an `CalendarState` object, which consists of 2 properties. +- `MonthState` - current value of the presented month. +- `SelectionState` - current value of the selection. + +Both properties are represented by interfaces, so the default implementation can be overwritten if needed. +The calendar state is leveraging Compose saving mechanism, so that the state will survive any configuration change, or the process death. + +### Initial state +Initial state for the static calendar is provided by the `rememberCalendarState()` function. If you need to change the initial conditions, you can pass the params to it: + +```kotlin + + @Composable + fun MainScreen() { + StaticCalendar( + calendarState = rememberCalendarState( + initialMonth = YearMonth.now().plusYears(1), + ) + ) + } + +``` +In case of the selectable calendar, the state has additional parameters, used to calculate the initial selection: + +```kotlin + + @Composable + fun MainScreen() { + SelectableCalendar( + calendarState = rememberSelectableCalendarState( + initialMonth = YearMonth.now().plusYears(1), + initialSelection = listOf(LocalDate.parse("20-01-2020")), + initialSelectionMode = SelectionMode.Period, + ) + ) + } + +``` + +### State hoisting +In case you need to react to the state changes, or change the state from the outside of the composable, +you need to hoist the state out of the `Calendar` composable: + +```kotlin + + @Composable + fun MainScreen() { + val calendarState = rememberCalendarState() + StaticCalendar(calendarState = calendarState) + + // now you can manipulate the state from scope of this composable + calendarState.monthState.currentMonth = YearMonth.of(2020, 5) + } + +``` + +### Dynamic Selection State +By default, the `SelectableCalendar` is using a `DynamicSelectionState` implementation of `SelectionState`. The selection is kept as a list of `LocalDate` objects. For a purpose of flexibility, `DynamicSelectionState` allows for 4 different selection modes, each one varying how the selection is changing after interacting with the calendar. Furthermore, selection mode can be changed in the runtime, for some specific use-cases. +Selection modes are represented by `SelectionMode` enum, with following values: +- `None` - no selection allowed - selection will always be an empty list. +- `Single` - only single day is selectable - selection will contain one or zero days selected. +- `Multiple` - a list of dates can be selected. +- `Period` - selectable period - implemented by `start` and `end` dates. - selection will contain all dates between start and the end date. +This implementation of SelectionState also allows for handling side-effects and vetoing the state change via `confirmSelectionChange` callback. + +## Week Calendar +Apart from the default calendar, there is also a week calendar, which shows a single week at a time. It can be used in the same way as the default calendar, and has the same customization options. + +## KotlinX DateTime +As the core of the library is built on `java.time` library, on Android SDK < 26 it requires to use [core libary desugaring](https://developer.android.com/studio/write/java8-support) to access it's API. +As a result it's features may be unavailable to some project built around different date-time libraries (e.g. kotlinx-datetime). Although the project wont be migrating from `java.time`, as it's the best suited for it, there is a separate `kotlinx-datetime` artifact for those who need to use the library from a codebase based on it. It doesn't consist of a separate version of `ComposeCalendar` features, but offers a small bunch of utilities, that will enable you to create your own wrapper, as briefly presented in `KotlinDateTimeSample`. If the provided functionality, doesn't match your use-case, please submit an issue. + +## License + + Copyright 2024 Bogusz Pawłowski + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ComposeCalendar/blob/screenshot_1.jpg b/ComposeCalendar/blob/screenshot_1.jpg new file mode 100644 index 000000000..3bb37214f Binary files /dev/null and b/ComposeCalendar/blob/screenshot_1.jpg differ diff --git a/ComposeCalendar/build.gradle.kts b/ComposeCalendar/build.gradle.kts new file mode 100644 index 000000000..057fb2192 --- /dev/null +++ b/ComposeCalendar/build.gradle.kts @@ -0,0 +1,115 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(DetektLib.PluginId) version DetektLib.Version + id(GradleVersions.PluginId) version GradleVersions.Version + id(GrGit.PluginId) version GrGit.Version + id(Shipkit.AutoVersion.PluginId) version Shipkit.AutoVersion.Version + id(Shipkit.Changelog.PluginId) version Shipkit.Changelog.Version + id(Shipkit.GithubRelease.PluginId) version Shipkit.GithubRelease.Version + id(Kotlin.CompatibilityPlugin) version Kotlin.CompatibilityPluginVersion +} + +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath(Android.GradlePlugin) + classpath(Kotlin.GradlePlugin) + classpath(Kotlin.DokkaGradlePlugin) + classpath(DetektLib.Plugin) + classpath(GradleVersions.Plugin) + classpath(MavenPublish.GradlePlugin) + classpath(Firebase.GoogleServicesPlugin) + } +} + +allprojects { + repositories { + mavenCentral() + gradlePluginPortal() + google() + mavenLocal() + } + + tasks.withType { + kotlinOptions { + jvmTarget = "17" + freeCompilerArgs = freeCompilerArgs + listOf( + "-progressive", + "-Xopt-in=kotlin.RequiresOptIn", + "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlin.contracts.ExperimentalContracts", + ) + } + } + + tasks.withType { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + } +} + +dependencies { + detekt(DetektLib.Formatting) + detekt(DetektLib.Cli) +} + +apiValidation { + ignoredProjects.add("sample") +} + +tasks { + withType { + parallel = true + config.setFrom(rootProject.file("detekt-config.yml")) + setSource(files(projectDir)) + exclude(subprojects.map { "${it.buildDir.relativeTo(rootDir).path}/" }) + exclude("**/.gradle/**") + reports { + xml { + required.set(true) + outputLocation.set(file("build/reports/detekt/detekt-results.xml")) + } + html.required.set(false) + txt.required.set(false) + } + } + + register("check") { + group = "Verification" + description = "Allows to attach Detekt to the root project." + } + + withType { + rejectVersionIf { + isNonStable(candidate.version) && !isNonStable(currentVersion) + } + } + + withType(org.shipkit.changelog.GenerateChangelogTask::class) { + previousRevision = project.ext["shipkit-auto-version.previous-tag"] as String? + githubToken = System.getenv("GITHUB_TOKEN") + repository = "boguszpawlowski/composecalendar" + } + + withType(org.shipkit.github.release.GithubReleaseTask::class) { + dependsOn(named("generateChangelog")) + repository = "boguszpawlowski/composecalendar" + changelog = named("generateChangelog").get().outputs.files.singleFile + githubToken = System.getenv("GITHUB_TOKEN") + newTagRevision = System.getenv("GITHUB_SHA") + } +} + +fun isNonStable(version: String): Boolean { + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + return !regex.matches(version) +} diff --git a/ComposeCalendar/buildSrc/build.gradle.kts b/ComposeCalendar/buildSrc/build.gradle.kts new file mode 100644 index 000000000..d8ab500d1 --- /dev/null +++ b/ComposeCalendar/buildSrc/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + google() +} + +gradlePlugin { + plugins { + register("common-android-plugin") { + id = "common-android-plugin" + implementationClass = "CommonAndroidPlugin" + } + } +} + +dependencies { + implementation("com.android.tools.build:gradle:8.2.0") + implementation(kotlin("gradle-plugin", "1.9.20")) +} diff --git a/ComposeCalendar/buildSrc/src/main/kotlin/CommonAndroidPlugin.kt b/ComposeCalendar/buildSrc/src/main/kotlin/CommonAndroidPlugin.kt new file mode 100644 index 000000000..a4c3e2d1d --- /dev/null +++ b/ComposeCalendar/buildSrc/src/main/kotlin/CommonAndroidPlugin.kt @@ -0,0 +1,32 @@ +import com.android.build.gradle.BaseExtension +import org.gradle.api.JavaVersion.VERSION_17 +import org.gradle.api.Plugin +import org.gradle.api.Project + +class CommonAndroidPlugin : Plugin { + + @Suppress("LongMethod") + override fun apply(target: Project) { + val androidExtension = target.extensions.getByName("android") + + (androidExtension as? BaseExtension)?.apply { + compileSdkVersion(AndroidSdk.Compile) + defaultConfig { + minSdk = AndroidSdk.Min + targetSdk = AndroidSdk.Target + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = VERSION_17 + targetCompatibility = VERSION_17 + } + + buildFeatures.compose = true + + composeOptions { + kotlinCompilerExtensionVersion = Compose.CompilerVersion + } + } + } +} diff --git a/ComposeCalendar/buildSrc/src/main/kotlin/Dependencies.kt b/ComposeCalendar/buildSrc/src/main/kotlin/Dependencies.kt new file mode 100644 index 000000000..16c4ae3cf --- /dev/null +++ b/ComposeCalendar/buildSrc/src/main/kotlin/Dependencies.kt @@ -0,0 +1,141 @@ +@file:Suppress("ObjectPropertyNaming", "ClassNaming", "UnderscoresInNumericLiterals") +object MavenPublish { + const val PluginId = "com.vanniktech.maven.publish" + const val GradlePlugin = "com.vanniktech:gradle-maven-publish-plugin:0.27.0" +} + +object AndroidSdk { + const val Min = 21 + const val Compile = 33 + const val Target = Compile +} + +object Kotlin { + const val Version = "1.9.21" + const val CompatibilityPluginVersion = "0.13.2" + + const val GradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$Version" + const val DokkaGradlePlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.9.10" + + const val AndroidPluginId = "android" + const val JvmPluginId = "jvm" + + const val CompatibilityPlugin = "org.jetbrains.kotlinx.binary-compatibility-validator" + const val CompatibilityPluginId = "binary-compatibility-validator" + + const val DesugarJdkLibs = "com.android.tools:desugar_jdk_libs:2.0.4" + const val DateTime = "org.jetbrains.kotlinx:kotlinx-datetime:0.5.0" +} + +object Android { + const val GradlePlugin = "com.android.tools.build:gradle:8.2.0" + + const val ApplicationPluginId = "com.android.application" + const val LibraryPluginId = "com.android.library" +} + +object GradleVersions { + const val Version = "0.42.0" + + const val PluginId = "com.github.ben-manes.versions" + const val Plugin = "com.github.ben-manes:gradle-versions-plugin:$Version" +} + +object GrGit { + const val Version = "5.0.0" + + const val PluginId = "org.ajoberstar.grgit" +} + +object Shipkit { + object AutoVersion { + const val Version = "1.1.19" + + const val PluginId = "org.shipkit.shipkit-auto-version" + } + + object Changelog { + const val Version = "1.1.15" + + const val PluginId = "org.shipkit.shipkit-changelog" + } + + object GithubRelease { + const val Version = "1.1.15" + + const val PluginId = "org.shipkit.shipkit-github-release" + } +} + +object AndroidX { + const val Version = "1.0.0" + const val LifecycleVersion = "2.2.0" + + const val AppCompat = "androidx.appcompat:appcompat:1.6.1" + const val ComposeActivity = "androidx.activity:activity-compose:1.6.1" +} + +object Material { + const val Core = "com.google.android.material:material:1.5.0" +} + +object DetektLib { + const val Version = "1.23.4" + + const val PluginId = "io.gitlab.arturbosch.detekt" + const val Plugin = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:$Version" + + const val Formatting = "io.gitlab.arturbosch.detekt:detekt-formatting:$Version" + const val Cli = "io.gitlab.arturbosch.detekt:detekt-cli:$Version" +} + +object Timber { + const val Version = "5.0.1" + const val Core = "com.jakewharton.timber:timber:$Version" +} + +object Compose { + const val Version = "1.3.1" + const val CompilerVersion = "1.5.5" + const val AccompanistVersion = "0.25.1" + + const val Runtime = "androidx.compose.runtime:runtime:$Version" + const val Compiler = "androidx.compose.compiler:compiler:$CompilerVersion" + const val Foundation = "androidx.compose.foundation:foundation:$Version" + const val FoundationLayout = "androidx.compose.foundation:foundation-layout:$Version" + const val Material = "androidx.compose.material:material:$Version" + const val Ui = "androidx.compose.ui:ui:$Version" + const val UiTooling = "androidx.compose.ui:ui-tooling:$Version" + const val AccompanistPager = "com.google.accompanist:accompanist-pager:$AccompanistVersion" + const val Navigation = "androidx.navigation:navigation-compose:2.5.0-alpha03" +} + +object Debug { + const val LeakCanary = "com.squareup.leakcanary:leakcanary-android:2.7" +} + +object Hyperion { + private const val Version = "0.9.34" + const val Core = "com.willowtreeapps.hyperion:hyperion-core:$Version" + const val Crash = "com.willowtreeapps.hyperion:hyperion-crash:$Version" + const val GeigerCounter = "com.willowtreeapps.hyperion:hyperion-geiger-counter:$Version" + const val Measurement = "com.willowtreeapps.hyperion:hyperion-measurement:$Version" +} + +object Kotest { + const val Version = "5.4.2" + + const val RunnerJunit5 = "io.kotest:kotest-runner-junit5-jvm:$Version" + + const val Assertions = "io.kotest:kotest-assertions-core-jvm:$Version" +} + +object ComposeTest { + const val Core = "androidx.compose.ui:ui-test-junit4:${Compose.Version}" + const val Manifest = "androidx.compose.ui:ui-test-manifest:${Compose.Version}" +} + +object Firebase { + const val Bom = "com.google.firebase:firebase-bom:30.0.0" + const val GoogleServicesPlugin = "com.google.gms:google-services:4.3.10" +} diff --git a/ComposeCalendar/detekt-config.yml b/ComposeCalendar/detekt-config.yml new file mode 100644 index 000000000..92c2fccc9 --- /dev/null +++ b/ComposeCalendar/detekt-config.yml @@ -0,0 +1,294 @@ +build: + maxIssues: 0 + weights: + +complexity: + active: true + ComplexCondition: + active: true + threshold: 3 + ComplexInterface: + active: true + ComplexMethod: + active: true + threshold: 10 + ignoreSingleWhenExpression: true + LargeClass: + active: true + threshold: 400 + LongMethod: + active: true + threshold: 40 + LongParameterList: + active: true + functionThreshold: 10 + constructorThreshold: 5 + ignoreAnnotated: [ 'Composable' ] + NestedBlockDepth: + active: true + StringLiteralDuplication: + active: false + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: 'toString,hashCode,equals,finalize' + InstanceOfCheckForException: + active: true + NotImplementedDeclaration: + active: true + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + SwallowedException: + active: true + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionsWithoutMessageOrCause: + active: true + ThrowingNewInstanceOfSameException: + active: true + +formatting: + active: true + ChainWrapping: + active: true + CommentSpacing: + active: true + ImportOrdering: + active: true + Indentation: + active: true + indentSize: 2 + continuationIndentSize: 4 + NoBlankLineBeforeRbrace: + active: true + NoConsecutiveBlankLines: + active: true + NoLineBreakBeforeAssignment: + active: true + NoMultipleSpaces: + active: true + NoSemicolons: + active: true + NoTrailingSpaces: + active: true + ParameterListWrapping: + active: true + indentSize: 2 + SpacingAroundColon: + active: true + SpacingAroundComma: + active: true + SpacingAroundCurly: + active: true + SpacingAroundKeyword: + active: true + SpacingAroundOperators: + active: true + SpacingAroundRangeOperator: + active: true + StringTemplate: + active: true + +naming: + active: true + ClassNaming: + active: true + classPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$' + ConstructorParameterNaming: + active: true + parameterPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + privateParameterPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + EnumNaming: + active: true + enumEntryPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$' + FunctionNaming: + active: false + functionPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + ignoreAnnotated: [ 'Composable' ] + FunctionParameterNaming: + active: true + parameterPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + MatchingDeclarationName: + active: true + MemberNameEqualsClassName: + active: true + ObjectPropertyNaming: + active: false + constantPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$' + propertyPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$' + privatePropertyPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$' + PackageNaming: + active: true + packagePattern: '^[a-z]+(\.[a-z][a-zA-Z]*)*$' + TopLevelPropertyNaming: + active: false + constantPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + propertyPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + privatePropertyPattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + VariableNaming: + active: true + variablePattern: '^[a-z]+(?:[A-Z][a-z]+)*$' + privateVariablePattern: '^[_a-z]+(?:[A-Z][a-z]+)*$' + +performance: + active: true + ForEachOnRange: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + DuplicateCaseInWhenExpression: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: false + EqualsWithHashCodeExist: + active: true + ExplicitGarbageCollectionCall: + active: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + MissingWhenCase: + active: true + RedundantElseInWhen: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnreachableCode: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + CollapsibleIfStatements: + active: true + DataClassShouldBeImmutable: + active: true + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: true + ExplicitItLambdaParameter: + active: true + ForbiddenVoid: + active: true + LibraryCodeMustSpecifyReturnType: + active: true + LoopWithTooManyJumpStatements: + active: false + maxJumpCount: 1 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: true + MayBeConst: + active: true + ModifierOrder: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: true + OptionalAbstractKeyword: + active: true + OptionalWhenBraces: + active: true + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantVisibilityModifierRule: + active: true + excludes: ['**/library/**', '**/kotlinx-datetime/**'] + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: true + TrailingWhitespace: + active: true + UnderscoresInNumericLiterals: + active: false + acceptableDecimalLength: 3 + UnnecessaryAbstractClass: + active: true + UnnecessaryApply: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: true + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: true + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: false # Due to compose preview + UseArrayLiteralsInAnnotations: + active: true + UseCheckOrError: + active: true + UseIfInsteadOfWhen: + active: false + UseRequire: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true diff --git a/ComposeCalendar/gradle.properties b/ComposeCalendar/gradle.properties new file mode 100644 index 000000000..269fd8b11 --- /dev/null +++ b/ComposeCalendar/gradle.properties @@ -0,0 +1,27 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=true +kotlin.code.style=official + +POM_NAME=Compose Calendar +GROUP=io.github.boguszpawlowski.composecalendar +POM_URL=https://github.com/boguszpawlowski/ComposeCalendar +POM_SCM_URL=https://github.com/boguszpawlowski/ComposeCalendar +POM_SCM_CONNECTION=git@github.com:boguszpawlowski/ComposeCalendar.git +POM_SCM_DEV_CONNECTION=git@github.com:boguszpawlowski/ComposeCalendar.git +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo +POM_DEVELOPER_ID=boguszp +POM_DEVELOPER_NAME=Bogusz Pawlowski +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/ComposeCalendar/gradle/dependencies.gradle b/ComposeCalendar/gradle/dependencies.gradle new file mode 100644 index 000000000..e69de29bb diff --git a/ComposeCalendar/gradle/wrapper/gradle-wrapper.jar b/ComposeCalendar/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d64cd4917 Binary files /dev/null and b/ComposeCalendar/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ComposeCalendar/gradle/wrapper/gradle-wrapper.properties b/ComposeCalendar/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/ComposeCalendar/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ComposeCalendar/gradlew b/ComposeCalendar/gradlew new file mode 100644 index 000000000..1aa94a426 --- /dev/null +++ b/ComposeCalendar/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ComposeCalendar/gradlew.bat b/ComposeCalendar/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/ComposeCalendar/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ComposeCalendar/kotlinx-datetime/.gitignore b/ComposeCalendar/kotlinx-datetime/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ComposeCalendar/kotlinx-datetime/api/kotlinx-datetime.api b/ComposeCalendar/kotlinx-datetime/api/kotlinx-datetime.api new file mode 100644 index 000000000..c5e8ba1a5 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/api/kotlinx-datetime.api @@ -0,0 +1,37 @@ +public final class io/github/boguszpawlowski/composecalendar/kotlinxDateTime/ConvertersKt { + public static final fun toJavaYearMonth (Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth;)Ljava/time/YearMonth; + public static final fun toKotlinYearMonth (Ljava/time/YearMonth;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; +} + +public final class io/github/boguszpawlowski/composecalendar/kotlinxDateTime/DateTimeExtensionsKt { + public static final fun getFirstDayOfWeek (Ljava/util/Locale;)Ljava/time/DayOfWeek; + public static final fun now (Lkotlinx/datetime/LocalDate$Companion;)Lkotlinx/datetime/LocalDate; +} + +public final class io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth { + public static final field Companion Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth$Companion; + public synthetic fun (ILjava/time/Month;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/time/Month; + public final fun copy (ILjava/time/Month;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public static synthetic fun copy$default (Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth;ILjava/time/Month;ILjava/lang/Object;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun dec ()Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public fun equals (Ljava/lang/Object;)Z + public final fun getMonth ()Ljava/time/Month; + public final fun getYear ()I + public fun hashCode ()I + public final fun inc ()Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun minus (ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun minus (JLkotlinx/datetime/DateTimeUnit$MonthBased;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun plus (ILkotlinx/datetime/DateTimeUnit$MonthBased;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun plus (JLkotlinx/datetime/DateTimeUnit$MonthBased;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public fun toString ()Ljava/lang/String; +} + +public final class io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth$Companion { + public final fun now ()Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun of (II)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun of (ILjava/time/Month;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; + public final fun parse (Ljava/lang/String;)Lio/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth; +} + diff --git a/ComposeCalendar/kotlinx-datetime/build.gradle.kts b/ComposeCalendar/kotlinx-datetime/build.gradle.kts new file mode 100644 index 000000000..4c35546f9 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + kotlin(Kotlin.JvmPluginId) + id(MavenPublish.PluginId) +} + +kotlin { + explicitApi() +} + +dependencies { + api(Kotlin.DateTime) + + testImplementation(Kotest.Assertions) + testImplementation(Kotest.RunnerJunit5) +} diff --git a/ComposeCalendar/kotlinx-datetime/gradle.properties b/ComposeCalendar/kotlinx-datetime/gradle.properties new file mode 100644 index 000000000..5b55af44f --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/gradle.properties @@ -0,0 +1,5 @@ +POM_ARTIFACT_ID=kotlinx-datetime +POM_DESCRIPTION=A set of utilities supporting usage of kotlin datetime instead of java.time. +POM_PACKAGING=aar +SONATYPE_HOST=S01 +RELEASE_SIGNING_ENABLED=true diff --git a/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/Converters.kt b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/Converters.kt new file mode 100644 index 000000000..04f2cc336 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/Converters.kt @@ -0,0 +1,5 @@ +package io.github.boguszpawlowski.composecalendar.kotlinxDateTime + +public fun YearMonth.toJavaYearMonth(): java.time.YearMonth = java.time.YearMonth.of(year, month) + +public fun java.time.YearMonth.toKotlinYearMonth(): YearMonth = YearMonth.of(year, month) diff --git a/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/DateTimeExtensions.kt b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/DateTimeExtensions.kt new file mode 100644 index 000000000..7887914b4 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/DateTimeExtensions.kt @@ -0,0 +1,15 @@ +package io.github.boguszpawlowski.composecalendar.kotlinxDateTime + +import kotlinx.datetime.Clock +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.time.temporal.WeekFields +import java.util.Locale + +public fun LocalDate.Companion.now(): LocalDate = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + +public val Locale.firstDayOfWeek: DayOfWeek + get() = WeekFields.of(this).firstDayOfWeek diff --git a/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth.kt b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth.kt new file mode 100644 index 000000000..eb2a8e8b9 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/src/main/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonth.kt @@ -0,0 +1,79 @@ +package io.github.boguszpawlowski.composecalendar.kotlinxDateTime + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import java.time.temporal.ChronoField.YEAR + +/** + * Kotlin implementation of `YearMonth` available in java.time, since `KotlinxDateTime` doesn't + * include a substitute for it: https://github.com/Kotlin/kotlinx-datetime/issues/168 + * + * For construction use factory methods 'of' provided in companion object + */ +@Suppress("DataClassPrivateConstructor") +public data class YearMonth private constructor( + val year: Int, + val month: Month, +) { + + /** + * Increment by one month + */ + public operator fun inc(): YearMonth = plus(1, DateTimeUnit.MONTH) + + /** + * Decrement by one month + */ + public operator fun dec(): YearMonth = minus(1, DateTimeUnit.MONTH) + + /** + * Add specified amount of months to current date + */ + public fun plus(value: Int, unit: DateTimeUnit.MonthBased): YearMonth = + plus(value.toLong(), unit) + + /** + * Subtract specified amount of months from current date + */ + public fun minus(value: Int, unit: DateTimeUnit.MonthBased): YearMonth = + plus(-value.toLong(), unit) + + public fun minus(value: Long, unit: DateTimeUnit.MonthBased): YearMonth = + plus(-value, unit) + + public fun plus(value: Long, unit: DateTimeUnit.MonthBased): YearMonth { + val monthsToAdd = value * unit.months + if (monthsToAdd == 0L) { + return this + } + val monthCount = year * 12L + (month.value - 1) + val calcMonths = monthCount + monthsToAdd + + val newYear = YEAR.checkValidIntValue(calcMonths.floorDiv(12)) + val newMonth = calcMonths.floorMod(12) + 1 + return of(newYear, newMonth.toInt()) + } + + override fun toString(): String = toJavaYearMonth().toString() + + public companion object { + public fun of(year: Int, month: Int): YearMonth = YearMonth(year, Month.of(month)) + + public fun of(year: Int, month: Month): YearMonth = YearMonth(year, month) + + public fun parse(value: String): YearMonth = + java.time.YearMonth.parse(value).toKotlinYearMonth() + + public fun now(): YearMonth { + val today = LocalDate.now() + + return of(today.year, today.month.value) + } + } +} + +private fun Long.floorMod(other: Long): Long = when (other) { + 0L -> this + else -> this - floorDiv(other) * other +} diff --git a/ComposeCalendar/kotlinx-datetime/src/test/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonthTest.kt b/ComposeCalendar/kotlinx-datetime/src/test/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonthTest.kt new file mode 100644 index 000000000..6de0bf6b8 --- /dev/null +++ b/ComposeCalendar/kotlinx-datetime/src/test/java/io/github/boguszpawlowski/composecalendar/kotlinxDateTime/YearMonthTest.kt @@ -0,0 +1,128 @@ +package io.github.boguszpawlowski.composecalendar.kotlinxDateTime + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.data.Headers3 +import io.kotest.data.Row3 +import io.kotest.data.forAll +import io.kotest.data.table +import io.kotest.matchers.shouldBe +import kotlinx.datetime.DateTimeUnit +import java.time.DateTimeException + +internal class YearMonthTest : ShouldSpec({ + + context("Year Month") { + should("overflow a year if adding months") { + val yearMonth = YearMonth.of(2002, 11) + val expectedResult = YearMonth.of(2003, 1) + + val result = yearMonth.plus(2, DateTimeUnit.MONTH) + + result shouldBe expectedResult + } + should("add years if adding months") { + val yearMonth = YearMonth.of(2002, 11) + val expectedResult = YearMonth.of(2202, 11) + + val result = yearMonth.plus(2, DateTimeUnit.CENTURY) + + result shouldBe expectedResult + } + should("subtract years if subtracting months") { + val yearMonth = YearMonth.of(2002, 1) + val expectedResult = YearMonth.of(2001, 11) + + val result = yearMonth.minus(2, DateTimeUnit.MONTH) + + result shouldBe expectedResult + } + should("subtract year if subtracting months") { + val yearMonth = YearMonth.of(2002, 11) + val expectedResult = YearMonth.of(2000, 11) + + val result = yearMonth.minus(2, DateTimeUnit.YEAR) + + result shouldBe expectedResult + } + should("throw an error if months greater than 12") { + shouldThrow { + YearMonth.of(1222, 13) + } + } + should("correctly add months") { + forAll( + table( + Headers3("starting month", "months to add", "expected month"), + Row3(1, 2, 3), + Row3(12, 24, 12), + Row3(11, 2, 1), + Row3(1, 14, 3), + Row3(6, 7, 1), + Row3(10, 20, 6), + ) + ) { startingMonth: Int, monthsToAdd: Int, expectedMonth: Int -> + val yearMonth = YearMonth.of(2000, startingMonth) + + val result = yearMonth.plus(monthsToAdd, DateTimeUnit.MONTH).month.value + + result shouldBe expectedMonth + } + } + should("correctly add years") { + forAll( + table( + Headers3("starting month", "months to add", "expected year"), + Row3(1, 2, 2000), + Row3(12, 24, 2002), + Row3(11, 2, 2001), + Row3(1, 14, 2001), + Row3(6, 7, 2001), + Row3(10, 20, 2002), + ) + ) { startingMonth: Int, monthsToAdd: Int, expectedYear: Int -> + val yearMonth = YearMonth.of(2000, startingMonth) + + val result = yearMonth.plus(monthsToAdd, DateTimeUnit.MONTH).year + + result shouldBe expectedYear + } + } + context("when decrementing") { + should("subtract one month") { + val yearMonth = YearMonth.of(2002, 11) + val expectedResult = YearMonth.of(2002, 10) + + val result = yearMonth.dec() + + result shouldBe expectedResult + } + should("overflow the year when january") { + val yearMonth = YearMonth.of(2002, 1) + val expectedResult = YearMonth.of(2001, 12) + + val result = yearMonth.dec() + + result shouldBe expectedResult + } + } + context("when incrementing") { + should("add one month") { + val yearMonth = YearMonth.of(2002, 11) + val expectedResult = YearMonth.of(2002, 12) + + val result = yearMonth.inc() + + result shouldBe expectedResult + } + should("overflow the year when december") { + val yearMonth = YearMonth.of(2002, 12) + val expectedResult = YearMonth.of(2003, 1) + + val result = yearMonth.inc() + + result shouldBe expectedResult + } + } + } +}) diff --git a/ComposeCalendar/library/.gitignore b/ComposeCalendar/library/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/ComposeCalendar/library/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ComposeCalendar/library/api/library.api b/ComposeCalendar/library/api/library.api new file mode 100644 index 000000000..9c12ac395 --- /dev/null +++ b/ComposeCalendar/library/api/library.api @@ -0,0 +1,256 @@ +public final class io/github/boguszpawlowski/composecalendar/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/github/boguszpawlowski/composecalendar/CalendarKt { + public static final fun Calendar (Lio/github/boguszpawlowski/composecalendar/CalendarState;Landroidx/compose/ui/Modifier;Ljava/time/DayOfWeek;Ljava/time/LocalDate;ZZZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun SelectableCalendar (Landroidx/compose/ui/Modifier;Ljava/time/DayOfWeek;Ljava/time/LocalDate;ZZZLio/github/boguszpawlowski/composecalendar/CalendarState;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun StaticCalendar (Landroidx/compose/ui/Modifier;Ljava/time/DayOfWeek;Ljava/time/LocalDate;ZZZLio/github/boguszpawlowski/composecalendar/CalendarState;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun rememberCalendarState (Ljava/time/YearMonth;Ljava/time/YearMonth;Ljava/time/YearMonth;Lio/github/boguszpawlowski/composecalendar/header/MonthState;Landroidx/compose/runtime/Composer;II)Lio/github/boguszpawlowski/composecalendar/CalendarState; + public static final fun rememberSelectableCalendarState (Ljava/time/YearMonth;Ljava/time/YearMonth;Ljava/time/YearMonth;Ljava/util/List;Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;Lkotlin/jvm/functions/Function1;Lio/github/boguszpawlowski/composecalendar/header/MonthState;Lio/github/boguszpawlowski/composecalendar/selection/DynamicSelectionState;Landroidx/compose/runtime/Composer;II)Lio/github/boguszpawlowski/composecalendar/CalendarState; +} + +public final class io/github/boguszpawlowski/composecalendar/CalendarState { + public static final field $stable I + public fun (Lio/github/boguszpawlowski/composecalendar/header/MonthState;Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;)V + public final fun getMonthState ()Lio/github/boguszpawlowski/composecalendar/header/MonthState; + public final fun getSelectionState ()Lio/github/boguszpawlowski/composecalendar/selection/SelectionState; +} + +public final class io/github/boguszpawlowski/composecalendar/ComposableSingletons$CalendarKt { + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/ComposableSingletons$CalendarKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-10 Lkotlin/jvm/functions/Function4; + public static field lambda-11 Lkotlin/jvm/functions/Function4; + public static field lambda-12 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function4; + public static field lambda-4 Lkotlin/jvm/functions/Function3; + public static field lambda-5 Lkotlin/jvm/functions/Function4; + public static field lambda-6 Lkotlin/jvm/functions/Function4; + public static field lambda-7 Lkotlin/jvm/functions/Function4; + public static field lambda-8 Lkotlin/jvm/functions/Function3; + public static field lambda-9 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-10$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-11$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-12$library_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-4$library_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-5$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-6$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-7$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-8$library_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-9$library_release ()Lkotlin/jvm/functions/Function4; +} + +public final class io/github/boguszpawlowski/composecalendar/ComposableSingletons$WeekCalendarKt { + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/ComposableSingletons$WeekCalendarKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function4; + public static field lambda-4 Lkotlin/jvm/functions/Function4; + public static field lambda-5 Lkotlin/jvm/functions/Function4; + public static field lambda-6 Lkotlin/jvm/functions/Function4; + public static field lambda-7 Lkotlin/jvm/functions/Function4; + public static field lambda-8 Lkotlin/jvm/functions/Function4; + public static field lambda-9 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-4$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-5$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-6$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-7$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-8$library_release ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-9$library_release ()Lkotlin/jvm/functions/Function4; +} + +public final class io/github/boguszpawlowski/composecalendar/WeekCalendarKt { + public static final fun SelectableWeekCalendar (Landroidx/compose/ui/Modifier;Ljava/time/DayOfWeek;Ljava/time/LocalDate;ZZLio/github/boguszpawlowski/composecalendar/WeekCalendarState;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun StaticWeekCalendar (Landroidx/compose/ui/Modifier;Ljava/time/DayOfWeek;Ljava/time/LocalDate;ZLio/github/boguszpawlowski/composecalendar/WeekCalendarState;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun WeekCalendar (Lio/github/boguszpawlowski/composecalendar/WeekCalendarState;Landroidx/compose/ui/Modifier;Ljava/time/LocalDate;Ljava/time/DayOfWeek;ZZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun rememberSelectableWeekCalendarState (Ljava/time/DayOfWeek;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;Ljava/util/List;Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;Lkotlin/jvm/functions/Function1;Lio/github/boguszpawlowski/composecalendar/header/WeekState;Lio/github/boguszpawlowski/composecalendar/selection/DynamicSelectionState;Landroidx/compose/runtime/Composer;II)Lio/github/boguszpawlowski/composecalendar/WeekCalendarState; + public static final fun rememberWeekCalendarState (Ljava/time/DayOfWeek;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/header/WeekState;Landroidx/compose/runtime/Composer;II)Lio/github/boguszpawlowski/composecalendar/WeekCalendarState; +} + +public final class io/github/boguszpawlowski/composecalendar/WeekCalendarState { + public static final field $stable I + public fun (Lio/github/boguszpawlowski/composecalendar/header/WeekState;Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;)V + public final fun getSelectionState ()Lio/github/boguszpawlowski/composecalendar/selection/SelectionState; + public final fun getWeekState ()Lio/github/boguszpawlowski/composecalendar/header/WeekState; +} + +public abstract interface class io/github/boguszpawlowski/composecalendar/day/Day { + public abstract fun getDate ()Ljava/time/LocalDate; + public abstract fun isCurrentDay ()Z + public abstract fun isFromCurrentMonth ()Z +} + +public final class io/github/boguszpawlowski/composecalendar/day/DayState : io/github/boguszpawlowski/composecalendar/day/Day { + public static final field $stable I + public fun (Lio/github/boguszpawlowski/composecalendar/day/Day;Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;)V + public final fun component2 ()Lio/github/boguszpawlowski/composecalendar/selection/SelectionState; + public final fun copy (Lio/github/boguszpawlowski/composecalendar/day/Day;Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;)Lio/github/boguszpawlowski/composecalendar/day/DayState; + public static synthetic fun copy$default (Lio/github/boguszpawlowski/composecalendar/day/DayState;Lio/github/boguszpawlowski/composecalendar/day/Day;Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;ILjava/lang/Object;)Lio/github/boguszpawlowski/composecalendar/day/DayState; + public fun equals (Ljava/lang/Object;)Z + public fun getDate ()Ljava/time/LocalDate; + public final fun getSelectionState ()Lio/github/boguszpawlowski/composecalendar/selection/SelectionState; + public fun hashCode ()I + public fun isCurrentDay ()Z + public fun isFromCurrentMonth ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/github/boguszpawlowski/composecalendar/day/DefaultDayKt { + public static final fun DefaultDay-t6yy7ic (Lio/github/boguszpawlowski/composecalendar/day/DayState;Landroidx/compose/ui/Modifier;JJLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/github/boguszpawlowski/composecalendar/header/ComposableSingletons$DefaultMonthHeaderKt { + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/header/ComposableSingletons$DefaultMonthHeaderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$library_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$library_release ()Lkotlin/jvm/functions/Function2; +} + +public final class io/github/boguszpawlowski/composecalendar/header/ComposableSingletons$DefaultWeekHeaderKt { + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/header/ComposableSingletons$DefaultWeekHeaderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public static field lambda-2 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$library_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-2$library_release ()Lkotlin/jvm/functions/Function2; +} + +public final class io/github/boguszpawlowski/composecalendar/header/DefaultMonthHeaderKt { + public static final fun DefaultMonthHeader (Lio/github/boguszpawlowski/composecalendar/header/MonthState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/github/boguszpawlowski/composecalendar/header/DefaultWeekHeaderKt { + public static final fun DefaultWeekHeader (Lio/github/boguszpawlowski/composecalendar/header/WeekState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public abstract interface class io/github/boguszpawlowski/composecalendar/header/MonthState { + public static final field Companion Lio/github/boguszpawlowski/composecalendar/header/MonthState$Companion; + public abstract fun getCurrentMonth ()Ljava/time/YearMonth; + public abstract fun getMaxMonth ()Ljava/time/YearMonth; + public abstract fun getMinMonth ()Ljava/time/YearMonth; + public abstract fun setCurrentMonth (Ljava/time/YearMonth;)V + public abstract fun setMaxMonth (Ljava/time/YearMonth;)V + public abstract fun setMinMonth (Ljava/time/YearMonth;)V +} + +public final class io/github/boguszpawlowski/composecalendar/header/MonthState$Companion { + public final fun Saver ()Landroidx/compose/runtime/saveable/Saver; +} + +public final class io/github/boguszpawlowski/composecalendar/header/MonthStateKt { + public static final fun MonthState (Ljava/time/YearMonth;Ljava/time/YearMonth;Ljava/time/YearMonth;)Lio/github/boguszpawlowski/composecalendar/header/MonthState; +} + +public abstract interface class io/github/boguszpawlowski/composecalendar/header/WeekState { + public static final field Companion Lio/github/boguszpawlowski/composecalendar/header/WeekState$Companion; + public abstract fun getCurrentWeek ()Lio/github/boguszpawlowski/composecalendar/week/Week; + public abstract fun getMaxWeek ()Lio/github/boguszpawlowski/composecalendar/week/Week; + public abstract fun getMinWeek ()Lio/github/boguszpawlowski/composecalendar/week/Week; + public abstract fun setCurrentWeek (Lio/github/boguszpawlowski/composecalendar/week/Week;)V + public abstract fun setMaxWeek (Lio/github/boguszpawlowski/composecalendar/week/Week;)V + public abstract fun setMinWeek (Lio/github/boguszpawlowski/composecalendar/week/Week;)V +} + +public final class io/github/boguszpawlowski/composecalendar/header/WeekState$Companion { + public final fun Saver ()Landroidx/compose/runtime/saveable/Saver; +} + +public final class io/github/boguszpawlowski/composecalendar/header/WeekStateKt { + public static final fun WeekState (Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;)Lio/github/boguszpawlowski/composecalendar/header/WeekState; +} + +public final class io/github/boguszpawlowski/composecalendar/selection/DynamicSelectionHandler { + public static final field $stable I + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/selection/DynamicSelectionHandler; + public final fun calculateNewSelection (Ljava/time/LocalDate;Ljava/util/List;Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;)Ljava/util/List; +} + +public final class io/github/boguszpawlowski/composecalendar/selection/DynamicSelectionState : io/github/boguszpawlowski/composecalendar/selection/SelectionState { + public static final field $stable I + public fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getSelection ()Ljava/util/List; + public final fun getSelectionMode ()Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public fun isDateSelected (Ljava/time/LocalDate;)Z + public fun onDateSelected (Ljava/time/LocalDate;)V + public final fun setSelection (Ljava/util/List;)V + public final fun setSelectionMode (Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode;)V +} + +public final class io/github/boguszpawlowski/composecalendar/selection/EmptySelectionState : io/github/boguszpawlowski/composecalendar/selection/SelectionState { + public static final field $stable I + public static final field INSTANCE Lio/github/boguszpawlowski/composecalendar/selection/EmptySelectionState; + public fun isDateSelected (Ljava/time/LocalDate;)Z + public fun onDateSelected (Ljava/time/LocalDate;)V +} + +public final class io/github/boguszpawlowski/composecalendar/selection/SelectionMode : java/lang/Enum { + public static final field Multiple Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public static final field None Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public static final field Period Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public static final field Single Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; + public static fun values ()[Lio/github/boguszpawlowski/composecalendar/selection/SelectionMode; +} + +public abstract interface class io/github/boguszpawlowski/composecalendar/selection/SelectionState { + public abstract fun isDateSelected (Ljava/time/LocalDate;)Z + public abstract fun onDateSelected (Ljava/time/LocalDate;)V +} + +public final class io/github/boguszpawlowski/composecalendar/selection/SelectionState$DefaultImpls { + public static fun isDateSelected (Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;Ljava/time/LocalDate;)Z + public static fun onDateSelected (Lio/github/boguszpawlowski/composecalendar/selection/SelectionState;Ljava/time/LocalDate;)V +} + +public final class io/github/boguszpawlowski/composecalendar/week/DefaultWeekHeaderKt { + public static final fun DefaultDaysOfWeekHeader (Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun DefaultWeekHeader (Ljava/util/List;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/github/boguszpawlowski/composecalendar/week/Week { + public static final field $stable I + public static final field Companion Lio/github/boguszpawlowski/composecalendar/week/Week$Companion; + public fun (Ljava/util/List;)V + public final fun compareTo (Lio/github/boguszpawlowski/composecalendar/week/Week;)I + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Lio/github/boguszpawlowski/composecalendar/week/Week; + public static synthetic fun copy$default (Lio/github/boguszpawlowski/composecalendar/week/Week;Ljava/util/List;ILjava/lang/Object;)Lio/github/boguszpawlowski/composecalendar/week/Week; + public final fun dec ()Lio/github/boguszpawlowski/composecalendar/week/Week; + public fun equals (Ljava/lang/Object;)Z + public final fun getDays ()Ljava/util/List; + public final fun getEnd ()Ljava/time/LocalDate; + public final fun getStart ()Ljava/time/LocalDate; + public final fun getYearMonth ()Ljava/time/YearMonth; + public fun hashCode ()I + public final fun inc ()Lio/github/boguszpawlowski/composecalendar/week/Week; + public final fun minusWeeks (J)Lio/github/boguszpawlowski/composecalendar/week/Week; + public final fun plusWeeks (J)Lio/github/boguszpawlowski/composecalendar/week/Week; + public fun toString ()Ljava/lang/String; +} + +public final class io/github/boguszpawlowski/composecalendar/week/Week$Companion { + public final fun now (Ljava/time/DayOfWeek;)Lio/github/boguszpawlowski/composecalendar/week/Week; + public static synthetic fun now$default (Lio/github/boguszpawlowski/composecalendar/week/Week$Companion;Ljava/time/DayOfWeek;ILjava/lang/Object;)Lio/github/boguszpawlowski/composecalendar/week/Week; +} + +public final class io/github/boguszpawlowski/composecalendar/week/WeekKt { + public static final fun between (Ljava/time/temporal/ChronoUnit;Lio/github/boguszpawlowski/composecalendar/week/Week;Lio/github/boguszpawlowski/composecalendar/week/Week;)I +} + diff --git a/ComposeCalendar/library/build.gradle.kts b/ComposeCalendar/library/build.gradle.kts new file mode 100644 index 000000000..864e35c15 --- /dev/null +++ b/ComposeCalendar/library/build.gradle.kts @@ -0,0 +1,30 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Android.LibraryPluginId) + kotlin(Kotlin.AndroidPluginId) + id("common-android-plugin") + id(MavenPublish.PluginId) +} + +android { + namespace = "io.github.boguszpawlowski.composecalendar" + tasks.withType { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xexplicit-api=strict" + "-Xcontext-receivers" + } + } +} + +dependencies { + implementation(Compose.Ui) + implementation(Compose.UiTooling) + implementation(Compose.AccompanistPager) + implementation(Compose.Foundation) + implementation(Compose.FoundationLayout) + implementation(Compose.Material) + implementation(Timber.Core) + + testImplementation(Kotest.Assertions) + testImplementation(Kotest.RunnerJunit5) +} diff --git a/ComposeCalendar/library/gradle.properties b/ComposeCalendar/library/gradle.properties new file mode 100644 index 000000000..9a193f6c3 --- /dev/null +++ b/ComposeCalendar/library/gradle.properties @@ -0,0 +1,5 @@ +POM_ARTIFACT_ID=composecalendar +POM_DESCRIPTION=Library for handling the Calendar view in Jetpack Compose. +POM_PACKAGING=aar +SONATYPE_HOST=S01 +RELEASE_SIGNING_ENABLED=true diff --git a/ComposeCalendar/library/src/main/AndroidManifest.xml b/ComposeCalendar/library/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8bdb7e14b --- /dev/null +++ b/ComposeCalendar/library/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/Calendar.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/Calendar.kt new file mode 100644 index 000000000..b8f335b00 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/Calendar.kt @@ -0,0 +1,286 @@ +@file:Suppress("MatchingDeclarationName") + +package io.github.boguszpawlowski.composecalendar + +import android.annotation.SuppressLint +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.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.day.DefaultDay +import io.github.boguszpawlowski.composecalendar.header.DefaultMonthHeader +import io.github.boguszpawlowski.composecalendar.header.MonthState +import io.github.boguszpawlowski.composecalendar.month.MonthContent +import io.github.boguszpawlowski.composecalendar.month.MonthPager +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.EmptySelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import io.github.boguszpawlowski.composecalendar.week.DaysInAWeek +import io.github.boguszpawlowski.composecalendar.week.DefaultDaysOfWeekHeader +import io.github.boguszpawlowski.composecalendar.week.rotateRight +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.WeekFields +import java.util.Locale + +/** + * State of the calendar composable + * + * @property monthState currently showed month + * @property selectionState handler for the calendar's selection + */ +@Stable +public class CalendarState( + public val monthState: MonthState, + public val selectionState: T, +) + +/** + * [Calendar] implementation using a [DynamicSelectionState] as a selection handler. + * + * * Basic usage: + * ``` + * @Composable + * fun MainScreen() { + * SelectableCalendar() + * } + * ``` + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param showAdjacentMonths whenever to show or hide the days from adjacent months + * @param horizontalSwipeEnabled whenever user is able to change the month by horizontal swipe + * @param weekDaysScrollEnabled if the week days should be scrollable + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param monthHeader header for showing the current month and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + * @param monthContainer container composable for all the days in current month + */ +@Composable +@SuppressLint("NewApi") +public fun SelectableCalendar( + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + today: LocalDate = LocalDate.now(), + showAdjacentMonths: Boolean = true, + horizontalSwipeEnabled: Boolean = true, + weekDaysScrollEnabled: Boolean = true, + calendarState: CalendarState = rememberSelectableCalendarState(), + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + monthHeader: @Composable ColumnScope.(MonthState) -> Unit = { DefaultMonthHeader(it) }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, + monthContainer: @Composable (content: @Composable (PaddingValues) -> Unit) -> Unit = { content -> + Box { content(PaddingValues()) } + }, +) { + Calendar( + modifier = modifier, + firstDayOfWeek = firstDayOfWeek, + today = today, + showAdjacentMonths = showAdjacentMonths, + horizontalSwipeEnabled = horizontalSwipeEnabled, + weekDaysScrollEnabled = weekDaysScrollEnabled, + calendarState = calendarState, + dayContent = dayContent, + monthHeader = monthHeader, + daysOfWeekHeader = daysOfWeekHeader, + monthContainer = monthContainer + ) +} + +/** + * [Calendar] implementation without any mechanism for the selection. + * + * Basic usage: + * ``` + * @Composable + * fun MainScreen() { + * StaticCalendar() + * } + * ``` + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param showAdjacentMonths whenever to show or hide the days from adjacent months + * @param horizontalSwipeEnabled whenever user is able to change the month by horizontal swipe + * @param weekDaysScrollEnabled if the week days should be scrollable + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param monthHeader header for showing the current month and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + * @param monthContainer container composable for all the days in current month + */ +@Composable +@SuppressLint("NewApi") +public fun StaticCalendar( + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + today: LocalDate = LocalDate.now(), + showAdjacentMonths: Boolean = true, + horizontalSwipeEnabled: Boolean = true, + weekDaysScrollEnabled: Boolean = true, + calendarState: CalendarState = rememberCalendarState(), + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + monthHeader: @Composable ColumnScope.(MonthState) -> Unit = { DefaultMonthHeader(it) }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, + monthContainer: @Composable (content: @Composable (PaddingValues) -> Unit) -> Unit = { content -> + Box { content(PaddingValues()) } + }, +) { + Calendar( + modifier = modifier, + firstDayOfWeek = firstDayOfWeek, + today = today, + showAdjacentMonths = showAdjacentMonths, + horizontalSwipeEnabled = horizontalSwipeEnabled, + weekDaysScrollEnabled = weekDaysScrollEnabled, + calendarState = calendarState, + dayContent = dayContent, + monthHeader = monthHeader, + daysOfWeekHeader = daysOfWeekHeader, + monthContainer = monthContainer + ) +} + +/** + * Composable for showing a calendar. The calendar state has to be provided by the call site. If you + * want to use built-in implementation, check out: + * [SelectableCalendar] - calendar composable handling selection that can be changed at runtime + * [StaticCalendar] - calendar without any mechanism for selection + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param showAdjacentMonths whenever to show or hide the days from adjacent months + * @param horizontalSwipeEnabled whenever user is able to change the month by horizontal swipe + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param monthHeader header for showing the current month and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + * @param monthContainer container composable for all the days in current month + */ +@Composable +@SuppressLint("NewApi") +public fun Calendar( + calendarState: CalendarState, + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + today: LocalDate = LocalDate.now(), + showAdjacentMonths: Boolean = true, + horizontalSwipeEnabled: Boolean = true, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + monthHeader: @Composable ColumnScope.(MonthState) -> Unit = { DefaultMonthHeader(it) }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, + monthContainer: @Composable (content: @Composable (PaddingValues) -> Unit) -> Unit = { content -> + Box { content(PaddingValues()) } + }, +) { + val initialMonth = remember { calendarState.monthState.currentMonth } + val daysOfWeek = remember(firstDayOfWeek) { + DayOfWeek.values().rotateRight(DaysInAWeek - firstDayOfWeek.ordinal) + } + + Column( + modifier = modifier, + ) { + monthHeader(calendarState.monthState) + if (horizontalSwipeEnabled) { + MonthPager( + initialMonth = initialMonth, + showAdjacentMonths = showAdjacentMonths, + monthState = calendarState.monthState, + selectionState = calendarState.selectionState, + today = today, + weekDaysScrollEnabled = weekDaysScrollEnabled, + daysOfWeek = daysOfWeek, + dayContent = dayContent, + weekHeader = daysOfWeekHeader, + monthContainer = monthContainer, + ) + } else { + MonthContent( + modifier = Modifier.fillMaxWidth(), + currentMonth = calendarState.monthState.currentMonth, + showAdjacentMonths = showAdjacentMonths, + selectionState = calendarState.selectionState, + today = today, + weekDaysScrollEnabled = weekDaysScrollEnabled, + daysOfWeek = daysOfWeek, + dayContent = dayContent, + weekHeader = daysOfWeekHeader, + monthContainer = monthContainer, + ) + } + } +} + +/** + * Helper function for providing a [CalendarState] implementation with selection mechanism. + * + * @param initialMonth initially rendered month + * @param initialSelection initial selection of the composable + * @param initialSelectionMode initial mode of the selection + * @param confirmSelectionChange callback for optional side-effects handling and vetoing the state change + * @param minMonth first month that can be shown + * @param maxMonth last month that can be shown + */ +@Composable +@SuppressLint("NewApi") +public fun rememberSelectableCalendarState( + initialMonth: YearMonth = YearMonth.now(), + minMonth: YearMonth = initialMonth.minusMonths(DefaultCalendarPagerRange), + maxMonth: YearMonth = initialMonth.plusMonths(DefaultCalendarPagerRange), + initialSelection: List = emptyList(), + initialSelectionMode: SelectionMode = SelectionMode.Single, + confirmSelectionChange: (newValue: List) -> Boolean = { true }, + monthState: MonthState = rememberSaveable(saver = MonthState.Saver()) { + MonthState( + initialMonth = initialMonth, + minMonth = minMonth, + maxMonth = maxMonth + ) + }, + selectionState: DynamicSelectionState = rememberSaveable( + saver = DynamicSelectionState.Saver(confirmSelectionChange), + ) { + DynamicSelectionState(confirmSelectionChange, initialSelection, initialSelectionMode) + }, +): CalendarState = remember { CalendarState(monthState, selectionState) } + +/** + * Helper function for providing a [CalendarState] implementation without a selection mechanism. + * + * @param initialMonth initially rendered month + * @param minMonth first month that can be shown + * @param maxMonth last month that can be shown + */ +@Composable +@SuppressLint("NewApi") +public fun rememberCalendarState( + initialMonth: YearMonth = YearMonth.now(), + minMonth: YearMonth = initialMonth.minusMonths(DefaultCalendarPagerRange), + maxMonth: YearMonth = initialMonth.plusMonths(DefaultCalendarPagerRange), + monthState: MonthState = rememberSaveable(saver = MonthState.Saver()) { + MonthState( + initialMonth = initialMonth, + minMonth = minMonth, + maxMonth = maxMonth + ) + }, +): CalendarState = remember { CalendarState(monthState, EmptySelectionState) } + +internal const val DefaultCalendarPagerRange = 10_000L diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/WeekCalendar.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/WeekCalendar.kt new file mode 100644 index 000000000..28da35a51 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/WeekCalendar.kt @@ -0,0 +1,264 @@ +@file:Suppress("MatchingDeclarationName", "LongParameterList") + +package io.github.boguszpawlowski.composecalendar + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.day.DefaultDay +import io.github.boguszpawlowski.composecalendar.header.WeekState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.EmptySelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import io.github.boguszpawlowski.composecalendar.week.DaysInAWeek +import io.github.boguszpawlowski.composecalendar.week.DefaultDaysOfWeekHeader +import io.github.boguszpawlowski.composecalendar.week.Week +import io.github.boguszpawlowski.composecalendar.week.WeekContent +import io.github.boguszpawlowski.composecalendar.week.WeekPager +import io.github.boguszpawlowski.composecalendar.week.getWeekDays +import io.github.boguszpawlowski.composecalendar.week.rotateRight +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.WeekFields +import java.util.Locale +import io.github.boguszpawlowski.composecalendar.header.DefaultWeekHeader as DefaultProperWeekHeader + +/** + * State of the week calendar composable + * + * @property weekState currently showed week + * @property selectionState handler for the calendar's selection + */ +@Stable +public class WeekCalendarState( + public val weekState: WeekState, + public val selectionState: T, +) + +/** + * [WeekCalendar] implementation using a [DynamicSelectionState] as a selection handler. + * + * * Basic usage: + * ``` + * @Composable + * fun MainScreen() { + * SelectableWeekCalendar() + * } + * ``` + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param horizontalSwipeEnabled whenever user is able to change the week by horizontal swipe + * @param weekDaysScrollEnabled if the week days should be scrollable + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param weekHeader header for showing the current week and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + */ +@Composable +public fun SelectableWeekCalendar( + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + today: LocalDate = LocalDate.now(), + horizontalSwipeEnabled: Boolean = true, + weekDaysScrollEnabled: Boolean = true, + calendarState: WeekCalendarState = rememberSelectableWeekCalendarState(), + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + weekHeader: @Composable ColumnScope.(WeekState) -> Unit = { + DefaultProperWeekHeader(it) + }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, +) { + WeekCalendar( + modifier = modifier, + calendarState = calendarState, + today = today, + horizontalSwipeEnabled = horizontalSwipeEnabled, + firstDayOfWeek = firstDayOfWeek, + weekDaysScrollEnabled = weekDaysScrollEnabled, + dayContent = dayContent, + weekHeader = weekHeader, + daysOfWeekHeader = daysOfWeekHeader, + ) +} + +/** + * [WeekCalendar] implementation without any mechanism for the selection. + * + * * Basic usage: + * ``` + * @Composable + * fun MainScreen() { + * StaticWeekCalendar() + * } + * ``` + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param horizontalSwipeEnabled whenever user is able to change the week by horizontal swipe + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param weekHeader header for showing the current week and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + */ +@Composable +@SuppressLint("NewApi") +public fun StaticWeekCalendar( + modifier: Modifier = Modifier, + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + today: LocalDate = LocalDate.now(), + horizontalSwipeEnabled: Boolean = true, + calendarState: WeekCalendarState = rememberWeekCalendarState(), + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + weekHeader: @Composable ColumnScope.(WeekState) -> Unit = { + DefaultProperWeekHeader(it) + }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, +) { + WeekCalendar( + modifier = modifier, + calendarState = calendarState, + today = today, + horizontalSwipeEnabled = horizontalSwipeEnabled, + firstDayOfWeek = firstDayOfWeek, + weekDaysScrollEnabled = weekDaysScrollEnabled, + dayContent = dayContent, + weekHeader = weekHeader, + daysOfWeekHeader = daysOfWeekHeader, + ) +} + +/** + * Composable for showing a week calendar. The calendar state has to be provided by the call site. If you + * want to use built-in implementation, check out: + * [SelectableWeekCalendar] - calendar composable handling selection that can be changed at runtime + * [StaticWeekCalendar] - calendar without any mechanism for selection + * + * @param modifier + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param today current day, defaults to [LocalDate.now] + * @param horizontalSwipeEnabled whenever user is able to change the week by horizontal swipe + * @param calendarState state of the composable + * @param dayContent composable rendering the current day + * @param weekHeader header for showing the current week and controls for changing it + * @param daysOfWeekHeader header for showing captions for each day of week + */ +@Composable +@SuppressLint("NewApi") +public fun WeekCalendar( + calendarState: WeekCalendarState, + modifier: Modifier = Modifier, + today: LocalDate = LocalDate.now(), + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + horizontalSwipeEnabled: Boolean = true, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit = { DefaultDay(it) }, + weekHeader: @Composable ColumnScope.(WeekState) -> Unit = { + DefaultProperWeekHeader(it) + }, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit = { DefaultDaysOfWeekHeader(it) }, +) { + val initialWeek = remember { calendarState.weekState.currentWeek } + val daysOfWeek = remember(firstDayOfWeek) { + DayOfWeek.values().rotateRight(DaysInAWeek - firstDayOfWeek.ordinal) + } + + Column( + modifier = modifier, + ) { + weekHeader(calendarState.weekState) + if (horizontalSwipeEnabled) { + WeekPager( + initialWeek = initialWeek, + daysOfWeek = daysOfWeek, + weekState = calendarState.weekState, + selectionState = calendarState.selectionState, + today = today, + weekDaysScrollEnabled = weekDaysScrollEnabled, + dayContent = dayContent, + daysOfWeekHeader = daysOfWeekHeader, + ) + } else { + WeekContent( + daysOfWeek = daysOfWeek, + weekDays = calendarState.weekState.currentWeek.getWeekDays(today), + selectionState = calendarState.selectionState, + weekDaysScrollEnabled = weekDaysScrollEnabled, + dayContent = dayContent, + daysOfWeekHeader = daysOfWeekHeader, + ) + } + } +} + +/** + * Helper function for providing a [WeekCalendarState] implementation with selection mechanism. + * + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param initialWeek initially rendered month + * @param initialSelection initial selection of the composable + * @param initialSelectionMode initial mode of the selection + * @param confirmSelectionChange callback for optional side-effects handling and vetoing the state change + * @param minWeek first week that can be shown + * @param maxWeek last week that can be shown + */ +@Composable +@SuppressLint("NewApi") +public fun rememberSelectableWeekCalendarState( + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + initialWeek: Week = Week.now(firstDayOfWeek), + minWeek: Week = initialWeek.minusWeeks(DefaultCalendarPagerRange), + maxWeek: Week = initialWeek.plusWeeks(DefaultCalendarPagerRange), + initialSelection: List = emptyList(), + initialSelectionMode: SelectionMode = SelectionMode.Single, + confirmSelectionChange: (newValue: List) -> Boolean = { true }, + weekState: WeekState = rememberSaveable(saver = WeekState.Saver()) { + WeekState( + initialWeek = initialWeek, + minWeek = minWeek, + maxWeek = maxWeek, + ) + }, + selectionState: DynamicSelectionState = rememberSaveable( + saver = DynamicSelectionState.Saver(confirmSelectionChange), + ) { + DynamicSelectionState(confirmSelectionChange, initialSelection, initialSelectionMode) + }, +): WeekCalendarState = + remember { WeekCalendarState(weekState, selectionState) } + +/** + * Helper function for providing a [WeekCalendarState] implementation without a selection mechanism. + * + * @param firstDayOfWeek first day of a week, defaults to current locale's + * @param initialWeek initially rendered week + * @param minWeek first week that can be shown + * @param maxWeek last week that can be shown + */ +@Composable +@SuppressLint("NewApi") +public fun rememberWeekCalendarState( + firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek, + initialWeek: Week = Week.now(firstDayOfWeek), + minWeek: Week = initialWeek.minusWeeks(DefaultCalendarPagerRange), + maxWeek: Week = initialWeek.plusWeeks(DefaultCalendarPagerRange), + weekState: WeekState = rememberSaveable(saver = WeekState.Saver()) { + WeekState( + initialWeek = initialWeek, + minWeek = minWeek, + maxWeek = maxWeek, + ) + }, +): WeekCalendarState = + remember { WeekCalendarState(weekState, EmptySelectionState) } diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/Day.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/Day.kt new file mode 100644 index 000000000..e476ce8b7 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/Day.kt @@ -0,0 +1,16 @@ +package io.github.boguszpawlowski.composecalendar.day + +import java.time.LocalDate + +/** + * Container for basic info about the displayed day + * + * @param date local date of the day + * @param isCurrentDay whenever the day is the today's date + * @param isFromCurrentMonth whenever the day is from currently rendered month + */ +public interface Day { + public val date: LocalDate + public val isCurrentDay: Boolean + public val isFromCurrentMonth: Boolean +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DayState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DayState.kt new file mode 100644 index 000000000..a10a29967 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DayState.kt @@ -0,0 +1,16 @@ +package io.github.boguszpawlowski.composecalendar.day + +import androidx.compose.runtime.Stable +import io.github.boguszpawlowski.composecalendar.selection.EmptySelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionState + +public typealias NonSelectableDayState = DayState + +/** + * Contains information about current selection as well as date of rendered day + */ +@Stable +public data class DayState( + private val day: Day, + val selectionState: T, +) : Day by day diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DefaultDay.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DefaultDay.kt new file mode 100644 index 000000000..2afd0d4ba --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/DefaultDay.kt @@ -0,0 +1,63 @@ +package io.github.boguszpawlowski.composecalendar.day + +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import java.time.LocalDate + +/** + * Default implementation for day content. It supports different appearance for days from + * current and adjacent month, as well as current day and selected day + * + * @param selectionColor color of the border, when day is selected + * @param currentDayColor color of content for the current date + * @param onClick callback for interacting with day clicks + */ +@Composable +@SuppressLint("NewApi") +public fun DefaultDay( + state: DayState, + modifier: Modifier = Modifier, + selectionColor: Color = MaterialTheme.colors.secondary, + currentDayColor: Color = MaterialTheme.colors.primary, + onClick: (LocalDate) -> Unit = {}, +) { + val date = state.date + val selectionState = state.selectionState + + val isSelected = selectionState.isDateSelected(date) + + Card( + modifier = modifier + .aspectRatio(1f) + .padding(2.dp), + elevation = if (state.isFromCurrentMonth) 4.dp else 0.dp, + border = if (state.isCurrentDay) BorderStroke(1.dp, currentDayColor) else null, + contentColor = if (isSelected) selectionColor else contentColorFor( + backgroundColor = MaterialTheme.colors.surface + ) + ) { + Box( + modifier = Modifier.clickable { + onClick(date) + selectionState.onDateSelected(date) + }, + contentAlignment = Alignment.Center, + ) { + Text(text = date.dayOfMonth.toString()) + } + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/WeekDay.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/WeekDay.kt new file mode 100644 index 000000000..7ac0002e9 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/day/WeekDay.kt @@ -0,0 +1,11 @@ +package io.github.boguszpawlowski.composecalendar.day + +import androidx.compose.runtime.Immutable +import java.time.LocalDate + +@Immutable +internal class WeekDay( + override val date: LocalDate, + override val isCurrentDay: Boolean, + override val isFromCurrentMonth: Boolean +) : Day diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/debug/RecomposeHighlighter.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/debug/RecomposeHighlighter.kt new file mode 100644 index 000000000..61b27449c --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/debug/RecomposeHighlighter.kt @@ -0,0 +1,97 @@ +package io.github.boguszpawlowski.composecalendar.debug + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.min + +/** + * A [Modifier] that draws a border around elements that are recomposing. The border increases in + * size and interpolates from red to green as more recompositions occur before a timeout. + */ +@Stable +internal fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) + +// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations +// Modifier.composed will still remember unique data per call site. +private val recomposeModifier = + Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) { + // The total number of compositions that have occurred. We're not using a State<> here be + // able to read/write the value without invalidating (which would cause infinite + // recomposition). + val totalCompositions = remember { arrayOf(0L) } + totalCompositions[0]++ + + // The value of totalCompositions at the last timeout. + val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) } + + // Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions + // as the key is really just to cause the timer to restart every composition). + LaunchedEffect(totalCompositions[0]) { + delay(3000) + totalCompositionsAtLastTimeout.value = totalCompositions[0] + } + + Modifier.drawWithCache { + onDrawWithContent { + // Draw actual content. + drawContent() + + // Below is to draw the highlight, if necessary. A lot of the logic is copied from + // Modifier.border + val numCompositionsSinceTimeout = + totalCompositions[0] - totalCompositionsAtLastTimeout.value + + val hasValidBorderParams = size.minDimension > 0f + if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) { + return@onDrawWithContent + } + + val (color, strokeWidthPx) = + when (numCompositionsSinceTimeout) { + // We need at least one composition to draw, so draw the smallest border + // color in blue. + 1L -> Color.Blue to 1f + // 2 compositions is _probably_ okay. + 2L -> Color.Green to 2.dp.toPx() + // 3 or more compositions before timeout may indicate an issue. lerp the + // color from yellow to red, and continually increase the border size. + else -> lerp( + Color.Yellow.copy(alpha = 0.8f), + Color.Red.copy(alpha = 0.5f), + min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f) + ) to numCompositionsSinceTimeout.toInt().dp.toPx() + } + + val halfStroke = strokeWidthPx / 2 + val topLeft = Offset(halfStroke, halfStroke) + val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) + + val fillArea = strokeWidthPx * 2 > size.minDimension + val rectTopLeft = if (fillArea) Offset.Zero else topLeft + val size = if (fillArea) size else borderSize + val style = if (fillArea) Fill else Stroke(strokeWidthPx) + + drawRect( + brush = SolidColor(color), + topLeft = rectTopLeft, + size = size, + style = style + ) + } + } + } diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultMonthHeader.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultMonthHeader.kt new file mode 100644 index 000000000..653587cee --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultMonthHeader.kt @@ -0,0 +1,87 @@ +package io.github.boguszpawlowski.composecalendar.header + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import java.time.format.TextStyle.FULL +import java.util.Locale + +/** + * Default implementation of month header, shows current month and year, as well as + * 2 arrows for changing currently showed month + */ +@Composable +@SuppressLint("NewApi") +public fun DefaultMonthHeader( + monthState: MonthState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + DecrementButton(monthState = monthState) + Text( + modifier = Modifier.testTag("MonthLabel"), + text = monthState.currentMonth.month + .getDisplayName(FULL, Locale.getDefault()) + .lowercase() + .replaceFirstChar { it.titlecase() }, + style = MaterialTheme.typography.h4, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = monthState.currentMonth.year.toString(), style = MaterialTheme.typography.h4) + IncrementButton(monthState = monthState) + } +} + +@Composable +private fun DecrementButton( + monthState: MonthState, +) { + IconButton( + modifier = Modifier.testTag("Decrement"), + enabled = monthState.currentMonth > monthState.minMonth, + onClick = { monthState.currentMonth = monthState.currentMonth.minusMonths(1) } + ) { + Image( + imageVector = Icons.Default.KeyboardArrowLeft, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = "Previous", + ) + } +} + +@Composable +private fun IncrementButton( + monthState: MonthState, +) { + IconButton( + modifier = Modifier.testTag("Increment"), + enabled = monthState.currentMonth < monthState.maxMonth, + onClick = { monthState.currentMonth = monthState.currentMonth.plusMonths(1) } + ) { + Image( + imageVector = Icons.Default.KeyboardArrowRight, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = "Next", + ) + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultWeekHeader.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultWeekHeader.kt new file mode 100644 index 000000000..cb99137b2 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/DefaultWeekHeader.kt @@ -0,0 +1,91 @@ +package io.github.boguszpawlowski.composecalendar.header + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import java.time.format.TextStyle.FULL +import java.util.Locale + +/** + * Default implementation of week header, shows current month and year, as well as + * 2 arrows for changing currently showed month + */ +@Composable +@Suppress("LongMethod") +@SuppressLint("NewApi") +public fun DefaultWeekHeader( + weekState: WeekState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + DecrementButton(weekState = weekState) + Text( + modifier = Modifier.testTag("WeekLabel"), + text = weekState.currentWeek.yearMonth.month + .getDisplayName(FULL, Locale.getDefault()) + .lowercase() + .replaceFirstChar { it.titlecase() }, + style = MaterialTheme.typography.h4, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = weekState.currentWeek.yearMonth.year.toString(), + style = MaterialTheme.typography.h4 + ) + IncrementButton(monthState = weekState) + } +} + +@Composable +private fun DecrementButton( + weekState: WeekState, +) { + IconButton( + modifier = Modifier.testTag("Decrement"), + enabled = weekState.currentWeek > weekState.minWeek, + onClick = { weekState.currentWeek = weekState.currentWeek.dec() } + ) { + Image( + imageVector = Icons.Default.KeyboardArrowLeft, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = "Previous", + ) + } +} + +@Composable +private fun IncrementButton( + monthState: WeekState, +) { + IconButton( + modifier = Modifier.testTag("Increment"), + enabled = monthState.currentWeek < monthState.maxWeek, + onClick = { monthState.currentWeek = monthState.currentWeek.inc() } + ) { + Image( + imageVector = Icons.Default.KeyboardArrowRight, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = "Next", + ) + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/MonthState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/MonthState.kt new file mode 100644 index 000000000..293479896 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/MonthState.kt @@ -0,0 +1,76 @@ +package io.github.boguszpawlowski.composecalendar.header + +import android.annotation.SuppressLint +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.setValue +import java.time.YearMonth + +@Suppress("FunctionName") // Factory function +public fun MonthState( + initialMonth: YearMonth, + minMonth: YearMonth, + maxMonth: YearMonth, +): MonthState = MonthStateImpl(initialMonth, minMonth, maxMonth) + +@Stable +public interface MonthState { + public var currentMonth: YearMonth + public var minMonth: YearMonth + public var maxMonth: YearMonth + + public companion object { + public fun Saver(): Saver = mapSaver( + save = { monthState -> + mapOf( + "currentMonth" to monthState.currentMonth.toString(), + "minMonth" to monthState.minMonth.toString(), + "maxMonth" to monthState.maxMonth.toString(), + ) + }, + restore = { restoreMap -> + MonthState( + YearMonth.parse(restoreMap["currentMonth"] as String), + YearMonth.parse(restoreMap["minMonth"] as String), + YearMonth.parse(restoreMap["maxMonth"] as String), + ) + } + ) + } +} + +@Stable +@SuppressLint("NewApi") +private class MonthStateImpl( + initialMonth: YearMonth, + minMonth: YearMonth, + maxMonth: YearMonth, +) : MonthState { + + private var _currentMonth by mutableStateOf(initialMonth) + private var _minMonth by mutableStateOf(minMonth) + private var _maxMonth by mutableStateOf(maxMonth) + + override var currentMonth: YearMonth + get() = _currentMonth + set(value) { + _currentMonth = value + } + + override var minMonth: YearMonth + get() = _minMonth + set(value) { + if (value > _maxMonth) return + _minMonth = value + } + + override var maxMonth: YearMonth + get() = _maxMonth + set(value) { + if (value < _minMonth) return + _maxMonth = value + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/WeekState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/WeekState.kt new file mode 100644 index 000000000..f64182a4d --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/header/WeekState.kt @@ -0,0 +1,86 @@ +package io.github.boguszpawlowski.composecalendar.header + +import android.annotation.SuppressLint +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.setValue +import io.github.boguszpawlowski.composecalendar.week.Week +import java.time.LocalDate + +@Suppress("FunctionName") // Factory function +public fun WeekState( + initialWeek: Week, + minWeek: Week, + maxWeek: Week, +): WeekState = WeekStateImpl( + initialWeek = initialWeek, + minWeek = minWeek, + maxWeek = maxWeek, +) + +@Stable +@SuppressLint("NewApi") +public interface WeekState { + public var currentWeek: Week + public var minWeek: Week + public var maxWeek: Week + + public companion object { + @Suppress("FunctionName") // Factory function + public fun Saver(): Saver = mapSaver( + save = { weekState -> + mapOf( + CurrentWeekKey to weekState.currentWeek.days[0].toString(), + MinWeekKey to weekState.minWeek.days[0].toString(), + MaxWeekKey to weekState.maxWeek.days[0].toString(), + ) + }, + restore = { restoreMap -> + WeekState( + initialWeek = Week(firstDay = LocalDate.parse(restoreMap[CurrentWeekKey] as String)), + minWeek = Week(firstDay = LocalDate.parse(restoreMap[MinWeekKey] as String)), + maxWeek = Week(firstDay = LocalDate.parse(restoreMap[MaxWeekKey] as String)), + ) + } + ) + + private const val CurrentWeekKey = "CurrentWeek" + private const val MinWeekKey = "MinWeek" + private const val MaxWeekKey = "MaxWeek" + } +} + +@Stable +private class WeekStateImpl( + initialWeek: Week, + minWeek: Week, + maxWeek: Week, +) : WeekState { + + private var _currentWeek by mutableStateOf(initialWeek) + private var _minWeek by mutableStateOf(minWeek) + private var _maxWeek by mutableStateOf(maxWeek) + + override var currentWeek: Week + get() = _currentWeek + set(value) { + _currentWeek = value + } + + override var minWeek: Week + get() = _minWeek + set(value) { + if (value > _maxWeek) return + _minWeek = value + } + + override var maxWeek: Week + get() = _maxWeek + set(value) { + if (value < _minWeek) return + _maxWeek = value + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthContent.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthContent.kt new file mode 100644 index 000000000..2d02ed18c --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthContent.kt @@ -0,0 +1,162 @@ +package io.github.boguszpawlowski.composecalendar.month + +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import dev.chrisbanes.snapper.ExperimentalSnapperApi +import dev.chrisbanes.snapper.SnapOffsets +import dev.chrisbanes.snapper.SnapperFlingBehaviorDefaults +import dev.chrisbanes.snapper.SnapperLayoutInfo +import dev.chrisbanes.snapper.rememberSnapperFlingBehavior +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.header.MonthState +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import io.github.boguszpawlowski.composecalendar.week.WeekRow +import io.github.boguszpawlowski.composecalendar.week.getWeeks +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.ChronoUnit + +@OptIn(ExperimentalSnapperApi::class) +@Composable +@Suppress("LongMethod", "NewApi") +internal fun MonthPager( + initialMonth: YearMonth, + showAdjacentMonths: Boolean, + selectionState: T, + monthState: MonthState, + daysOfWeek: List, + today: LocalDate, + modifier: Modifier = Modifier, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit, + weekHeader: @Composable BoxScope.(List) -> Unit, + monthContainer: @Composable (content: @Composable (PaddingValues) -> Unit) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + val initialFirstVisibleItemIndex = remember(initialMonth, monthState.minMonth) { + ChronoUnit.MONTHS.between(monthState.minMonth, initialMonth).toInt() + } + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = initialFirstVisibleItemIndex, + ) + val flingBehavior = rememberSnapperFlingBehavior( + lazyListState = listState, + snapOffsetForItem = SnapOffsets.Start, + springAnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + decayAnimationSpec = rememberSplineBasedDecay(), + snapIndex = coerceSnapIndex, + ) + + val monthListState = remember { + MonthListState( + coroutineScope = coroutineScope, + monthState = monthState, + listState = listState, + ) + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (weekDaysScrollEnabled.not()) { + Box( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + content = { weekHeader(daysOfWeek) }, + ) + } + val pagerCount = remember(monthState.minMonth, monthState.maxMonth) { + ChronoUnit.MONTHS.between(monthState.minMonth, monthState.maxMonth).toInt() + 1 + } + LazyRow( + modifier = modifier.testTag("MonthPager"), + state = listState, + flingBehavior = flingBehavior, + verticalAlignment = Alignment.Top, + ) { + items( + count = pagerCount, + key = { index -> + monthListState.getMonthForPage(index).let { "${it.year}-${it.monthValue}" } + } + ) { index -> + MonthContent( + modifier = Modifier.fillParentMaxWidth(), + showAdjacentMonths = showAdjacentMonths, + selectionState = selectionState, + currentMonth = monthListState.getMonthForPage(index), + today = today, + weekDaysScrollEnabled = weekDaysScrollEnabled, + daysOfWeek = daysOfWeek, + dayContent = dayContent, + weekHeader = weekHeader, + monthContainer = monthContainer + ) + } + } + } +} + +@Composable +internal fun MonthContent( + showAdjacentMonths: Boolean, + selectionState: T, + currentMonth: YearMonth, + daysOfWeek: List, + today: LocalDate, + modifier: Modifier = Modifier, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit, + weekHeader: @Composable BoxScope.(List) -> Unit, + monthContainer: @Composable (content: @Composable (PaddingValues) -> Unit) -> Unit, +) { + Column { + if (weekDaysScrollEnabled) { + Box( + modifier = modifier + .wrapContentHeight(), + content = { weekHeader(daysOfWeek) }, + ) + } + monthContainer { paddingValues -> + Column( + modifier = modifier + .padding(paddingValues) + ) { + currentMonth.getWeeks( + includeAdjacentMonths = showAdjacentMonths, + firstDayOfTheWeek = daysOfWeek.first(), + today = today, + ).forEach { week -> + WeekRow( + weekDays = week, + selectionState = selectionState, + dayContent = dayContent, + ) + } + } + } + } +} + +@OptIn(ExperimentalSnapperApi::class) +internal val coerceSnapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int = + { _, startIndex, targetIndex -> + targetIndex + .coerceIn(startIndex - 1, startIndex + 1) + } diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthListState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthListState.kt new file mode 100644 index 000000000..d85fe2448 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/month/MonthListState.kt @@ -0,0 +1,74 @@ +package io.github.boguszpawlowski.composecalendar.month + +import android.annotation.SuppressLint +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import io.github.boguszpawlowski.composecalendar.header.MonthState +import io.github.boguszpawlowski.composecalendar.util.throttleOnOffset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.YearMonth +import java.time.temporal.ChronoUnit + +@Stable +@SuppressLint("NewApi") +internal class MonthListState( + private val coroutineScope: CoroutineScope, + private val monthState: MonthState, + private val listState: LazyListState, +) { + + private val currentFirstVisibleMonth by derivedStateOf { + getMonthForPage(listState.firstVisibleItemIndex) + } + + init { + snapshotFlow { monthState.currentMonth }.onEach { month -> + moveToMonth(month) + }.launchIn(coroutineScope) + + with(listState) { + snapshotFlow { currentFirstVisibleMonth } + .throttleOnOffset() + .onEach { newMonth -> + monthState.currentMonth = newMonth + }.launchIn(coroutineScope) + } + } + + fun getMonthForPage(index: Int): YearMonth = + monthState.minMonth.plusMonths(index.toLong()) + + private fun moveToMonth(month: YearMonth) { + if (month == currentFirstVisibleMonth) return + coroutineScope.launch { + listState.animateScrollToItem(ChronoUnit.MONTHS.between(monthState.minMonth, month).toInt()) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MonthListState + + if (monthState != other.monthState) return false + if (listState != other.listState) return false + + return true + } + + override fun hashCode(): Int { + var result = monthState.hashCode() + result = 31 * result + listState.hashCode() + return result + } +} + +private operator fun YearMonth.minus(other: YearMonth) = + ChronoUnit.MONTHS.between(other, this) diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/DynamicSelectionHandler.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/DynamicSelectionHandler.kt new file mode 100644 index 000000000..d3f244e36 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/DynamicSelectionHandler.kt @@ -0,0 +1,35 @@ +package io.github.boguszpawlowski.composecalendar.selection + +import android.annotation.SuppressLint +import io.github.boguszpawlowski.composecalendar.util.addOrRemoveIfExists +import java.time.LocalDate + +/** + * Helper class for calculating new selection, when using a [DynamicSelectionState] approach. + * @param date clicked on date + * @param selection current selection + * @param selectionMode current selection mode + * @returns new selection in a form of a list of local dates. + */ +@SuppressLint("NewApi") +public object DynamicSelectionHandler { + public fun calculateNewSelection( + date: LocalDate, + selection: List, + selectionMode: SelectionMode, + ): List = when (selectionMode) { + SelectionMode.None -> emptyList() + SelectionMode.Single -> if (date == selection.firstOrNull()) { + emptyList() + } else { + listOf(date) + } + SelectionMode.Multiple -> selection.addOrRemoveIfExists(date) + SelectionMode.Period -> when { + date.isBefore(selection.startOrMax()) -> listOf(date) + date.isAfter(selection.startOrMax()) -> selection.fillUpTo(date) + date == selection.startOrMax() -> emptyList() + else -> selection + } + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionExtensions.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionExtensions.kt new file mode 100644 index 000000000..0fdb7060d --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionExtensions.kt @@ -0,0 +1,15 @@ +package io.github.boguszpawlowski.composecalendar.selection + +import android.annotation.SuppressLint +import java.time.LocalDate + +@SuppressLint("NewApi") +internal fun Collection.startOrMax() = firstOrNull() ?: LocalDate.MAX + +internal fun Collection.endOrNull() = drop(1).lastOrNull() + +@SuppressLint("NewApi") +internal fun Collection.fillUpTo(date: LocalDate) = + (0..date.toEpochDay() - first().toEpochDay()).map { + first().plusDays(it) + } diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionMode.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionMode.kt new file mode 100644 index 000000000..7a1aeee3b --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionMode.kt @@ -0,0 +1,15 @@ +package io.github.boguszpawlowski.composecalendar.selection + +/** + * Selection modes for the [DynamicSelectionState] + * None - no selection allowed + * Single - only one date selected + * Multiple - multiple dates can be selected + * Period - period can be selected + */ +public enum class SelectionMode { + None, + Single, + Multiple, + Period, +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionState.kt new file mode 100644 index 000000000..79448f5a7 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/selection/SelectionState.kt @@ -0,0 +1,83 @@ +package io.github.boguszpawlowski.composecalendar.selection + +import android.annotation.SuppressLint +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +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.setValue +import java.time.LocalDate + +@Stable +public interface SelectionState { + public fun isDateSelected(date: LocalDate): Boolean = false + public fun onDateSelected(date: LocalDate) { } +} + +/** + * Class that enables for dynamically changing selection modes in the runtime. Depending on the mode, selection changes differently. + * Mode can be varied by setting desired [SelectionMode] in the [selectionMode] mutable property. + * @param confirmSelectionChange return false from this callback to veto the selection change + */ +@Stable +@SuppressLint("NewApi") +public class DynamicSelectionState( + private val confirmSelectionChange: (newValue: List) -> Boolean = { true }, + selection: List, + selectionMode: SelectionMode, +) : SelectionState { + + private var _selection by mutableStateOf(selection) + private var _selectionMode by mutableStateOf(selectionMode) + + public var selection: List + get() = _selection + set(value) { + if (value != selection && confirmSelectionChange(value)) { + _selection = value + } + } + + public var selectionMode: SelectionMode + get() = _selectionMode + set(value) { + if (value != selectionMode) { + _selection = emptyList() + _selectionMode = value + } + } + + override fun isDateSelected(date: LocalDate): Boolean = selection.contains(date) + + override fun onDateSelected(date: LocalDate) { + selection = DynamicSelectionHandler.calculateNewSelection(date, selection, selectionMode) + } + + internal companion object { + @Suppress("FunctionName", "UNCHECKED_CAST") // Factory function + fun Saver( + confirmSelectionChange: (newValue: List) -> Boolean, + ): Saver = + listSaver( + save = { raw -> + listOf(raw.selectionMode, raw.selection.map { it.toString() }) + }, + restore = { restored -> + DynamicSelectionState( + confirmSelectionChange = confirmSelectionChange, + selectionMode = restored[0] as SelectionMode, + selection = (restored[1] as? List)?.map { LocalDate.parse(it) }.orEmpty(), + ) + } + ) + } +} + +@Immutable +public object EmptySelectionState : SelectionState { + override fun isDateSelected(date: LocalDate): Boolean = false + + override fun onDateSelected(date: LocalDate): Unit = Unit +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/DateUtil.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/DateUtil.kt new file mode 100644 index 000000000..76154cf8d --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/DateUtil.kt @@ -0,0 +1,13 @@ +package io.github.boguszpawlowski.composecalendar.util + +import java.time.DayOfWeek +import java.time.LocalDate + +internal fun Collection.addOrRemoveIfExists(date: LocalDate) = + if (contains(date)) { + this - date + } else { + this + date + } + +internal infix fun DayOfWeek.daysUntil(other: DayOfWeek) = (7 + (value - other.value)) % 7 diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/ListStateExtensions.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/ListStateExtensions.kt new file mode 100644 index 000000000..07c576d95 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/ListStateExtensions.kt @@ -0,0 +1,19 @@ +package io.github.boguszpawlowski.composecalendar.util + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +context(LazyListState) internal fun Flow.throttleOnOffset() = + combine( + snapshotFlow { firstVisibleItemScrollOffset } + ) { newMonth, offset -> + newMonth to (offset <= MinimalOffsetForEmit) + }.filter { (_, shouldUpdate) -> + shouldUpdate + }.map { (newValue, _) -> newValue } + +private const val MinimalOffsetForEmit = 10 diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/YearMonthExtensions.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/YearMonthExtensions.kt new file mode 100644 index 000000000..f3479b044 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/util/YearMonthExtensions.kt @@ -0,0 +1,7 @@ +package io.github.boguszpawlowski.composecalendar.util + +import java.time.YearMonth + +internal operator fun YearMonth.dec() = this.minusMonths(1) + +internal operator fun YearMonth.inc() = this.plusMonths(1) diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/DefaultWeekHeader.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/DefaultWeekHeader.kt new file mode 100644 index 000000000..b5d7cdb8f --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/DefaultWeekHeader.kt @@ -0,0 +1,61 @@ +package io.github.boguszpawlowski.composecalendar.week + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import java.time.DayOfWeek +import java.time.format.TextStyle.SHORT +import java.util.Locale +import kotlin.DeprecationLevel.WARNING + +@Composable +@Deprecated( + level = WARNING, + replaceWith = ReplaceWith( + "DefaultDaysOfWeekHeader(daysOfWeek, modifier)", + "io.github.boguszpawlowski.composecalendar.week.DefaultDaysOfWeekHeader", + ), + message = "Replace with DefaultDaysOfWeekHeader, DefaultWeekHeader will be removed in future versions" +) +@SuppressLint("NewApi") +public fun DefaultWeekHeader( + daysOfWeek: List, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + daysOfWeek.forEach { dayOfWeek -> + Text( + textAlign = TextAlign.Center, + text = dayOfWeek.getDisplayName(SHORT, Locale.getDefault()), + modifier = modifier + .weight(1f) + .wrapContentHeight() + ) + } + } +} + +@Composable +@SuppressLint("NewApi") +public fun DefaultDaysOfWeekHeader( + daysOfWeek: List, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + daysOfWeek.forEach { dayOfWeek -> + Text( + textAlign = TextAlign.Center, + text = dayOfWeek.getDisplayName(SHORT, Locale.getDefault()), + modifier = modifier + .weight(1f) + .wrapContentHeight() + ) + } + } +} + +internal fun Array.rotateRight(n: Int): List = takeLast(n) + dropLast(n) diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/Week.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/Week.kt new file mode 100644 index 000000000..815cedc26 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/Week.kt @@ -0,0 +1,55 @@ +package io.github.boguszpawlowski.composecalendar.week + +import android.annotation.SuppressLint +import io.github.boguszpawlowski.composecalendar.selection.fillUpTo +import io.github.boguszpawlowski.composecalendar.util.daysUntil +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.ChronoUnit +import java.time.temporal.WeekFields +import java.util.Locale + +@SuppressLint("NewApi") +public data class Week( + val days: List, +) { + + init { + require(days.size == DaysInAWeek) + } + + internal constructor(firstDay: LocalDate) : this( + listOf(firstDay).fillUpTo(firstDay.plusDays((DaysInAWeek - 1).toLong())) + ) + + public val start: LocalDate get() = days.first() + + public val end: LocalDate get() = days.last() + + public val yearMonth: YearMonth = YearMonth.of(start.year, start.month) + + public operator fun inc(): Week = plusWeeks(1) + + public operator fun dec(): Week = plusWeeks(-1) + + public operator fun compareTo(other: Week): Int = start.compareTo(other.start) + + public fun minusWeeks(value: Long): Week = plusWeeks(-value) + + public fun plusWeeks(value: Long): Week = copy(days = days.map { it.plusWeeks(value) }) + + public companion object { + public fun now(firstDayOfWeek: DayOfWeek = WeekFields.of(Locale.getDefault()).firstDayOfWeek): Week { + val today = LocalDate.now() + val offset = today.dayOfWeek.daysUntil(firstDayOfWeek) + val firstDay = today.minusDays(offset.toLong()) + + return Week(firstDay) + } + } +} + +@SuppressLint("NewApi") +public fun ChronoUnit.between(first: Week, other: Week): Int = + between(first.start, other.start).toInt() diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekDays.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekDays.kt new file mode 100644 index 000000000..374e175d5 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekDays.kt @@ -0,0 +1,10 @@ +package io.github.boguszpawlowski.composecalendar.week + +import androidx.compose.runtime.Immutable +import io.github.boguszpawlowski.composecalendar.day.Day + +@Immutable +internal data class WeekDays( + val isFirstWeekOfTheMonth: Boolean = false, + val days: List, +) diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekListState.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekListState.kt new file mode 100644 index 000000000..3dbc66da2 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekListState.kt @@ -0,0 +1,70 @@ +package io.github.boguszpawlowski.composecalendar.week + +import android.annotation.SuppressLint +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import io.github.boguszpawlowski.composecalendar.header.WeekState +import io.github.boguszpawlowski.composecalendar.util.throttleOnOffset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.temporal.ChronoUnit + +@Stable +@SuppressLint("NewApi") +internal class WeekListState( + private val coroutineScope: CoroutineScope, + private val weekState: WeekState, + private val listState: LazyListState, +) { + + private val currentlyFirstVisibleMonth by derivedStateOf { + getWeekForPage(listState.firstVisibleItemIndex) + } + + init { + snapshotFlow { weekState.currentWeek }.onEach { week -> + moveToWeek(week) + }.launchIn(coroutineScope) + + with(listState) { + snapshotFlow { currentlyFirstVisibleMonth } + .throttleOnOffset() + .onEach { newMonth -> + weekState.currentWeek = newMonth + }.launchIn(coroutineScope) + } + } + + fun getWeekForPage(index: Int): Week = + weekState.minWeek.plusWeeks(index.toLong()) + + private fun moveToWeek(week: Week) { + if (week == currentlyFirstVisibleMonth) return + coroutineScope.launch { + listState.animateScrollToItem(ChronoUnit.WEEKS.between(weekState.minWeek, week)) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WeekListState + + if (weekState != other.weekState) return false + if (listState != other.listState) return false + + return true + } + + override fun hashCode(): Int { + var result = weekState.hashCode() + result = 31 * result + listState.hashCode() + return result + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekPager.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekPager.kt new file mode 100644 index 000000000..5354d0e63 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekPager.kt @@ -0,0 +1,150 @@ +package io.github.boguszpawlowski.composecalendar.week + +import android.annotation.SuppressLint +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import dev.chrisbanes.snapper.ExperimentalSnapperApi +import dev.chrisbanes.snapper.SnapOffsets +import dev.chrisbanes.snapper.SnapperFlingBehaviorDefaults +import dev.chrisbanes.snapper.rememberSnapperFlingBehavior +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.day.WeekDay +import io.github.boguszpawlowski.composecalendar.header.WeekState +import io.github.boguszpawlowski.composecalendar.month.coerceSnapIndex +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +@OptIn(ExperimentalSnapperApi::class) +@Composable +@Suppress("LongMethod") +@SuppressLint("NewApi") +internal fun WeekPager( + initialWeek: Week, + selectionState: T, + weekState: WeekState, + daysOfWeek: List, + today: LocalDate, + modifier: Modifier = Modifier, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + val initialFirstVisibleItemIndex = remember(initialWeek, weekState.minWeek) { + ChronoUnit.WEEKS.between(weekState.minWeek, initialWeek) + } + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = initialFirstVisibleItemIndex, + ) + val flingBehavior = rememberSnapperFlingBehavior( + lazyListState = listState, + snapOffsetForItem = SnapOffsets.Start, + springAnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + decayAnimationSpec = rememberSplineBasedDecay(), + snapIndex = coerceSnapIndex, + ) + + val weekListState = remember { + WeekListState( + coroutineScope = coroutineScope, + weekState = weekState, + listState = listState, + ) + } + Column( + modifier = modifier, + ) { + if (weekDaysScrollEnabled.not()) { + Box( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + content = { daysOfWeekHeader(daysOfWeek) }, + ) + } + val pagerCount = remember(weekState.minWeek, weekState.maxWeek) { + ChronoUnit.WEEKS.between(weekState.minWeek, weekState.maxWeek) + 1 + } + LazyRow( + modifier = modifier.testTag("WeekPager"), + state = listState, + flingBehavior = flingBehavior, + verticalAlignment = Alignment.Top, + ) { + items( + count = pagerCount, + key = { index -> weekListState.getWeekForPage(index).start.let { "${it.month}-${it.dayOfMonth}" } }, + ) { index -> + WeekContent( + modifier = Modifier.fillParentMaxWidth(), + daysOfWeek = daysOfWeek, + weekDays = weekListState.getWeekForPage(index).getWeekDays(today = today), + selectionState = selectionState, + weekDaysScrollEnabled = weekDaysScrollEnabled, + dayContent = dayContent, + daysOfWeekHeader = daysOfWeekHeader, + ) + } + } + } +} + +@Composable +internal fun WeekContent( + selectionState: T, + weekDays: WeekDays, + daysOfWeek: List, + modifier: Modifier = Modifier, + weekDaysScrollEnabled: Boolean = true, + dayContent: @Composable BoxScope.(DayState) -> Unit, + daysOfWeekHeader: @Composable BoxScope.(List) -> Unit, +) { + Column( + modifier = modifier, + ) { + if (weekDaysScrollEnabled) { + Box( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth(), + content = { daysOfWeekHeader(daysOfWeek) }, + ) + } + WeekRow( + weekDays = weekDays, + modifier = Modifier.fillMaxWidth(), + selectionState = selectionState, + dayContent = dayContent, + ) + } +} + +internal fun Week.getWeekDays(today: LocalDate): WeekDays { + val weekDays = days.map { + WeekDay( + date = it, + isCurrentDay = it == today, + isFromCurrentMonth = true, + ) + } + + return WeekDays( + isFirstWeekOfTheMonth = false, + days = weekDays, + ) +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekRow.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekRow.kt new file mode 100644 index 000000000..61881e178 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekRow.kt @@ -0,0 +1,35 @@ +package io.github.boguszpawlowski.composecalendar.week + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.selection.SelectionState + +@Composable +internal fun WeekRow( + weekDays: WeekDays, + selectionState: T, + modifier: Modifier = Modifier, + dayContent: @Composable BoxScope.(DayState) -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalArrangement = if (weekDays.isFirstWeekOfTheMonth) Arrangement.End else Arrangement.Start + ) { + weekDays.days.forEachIndexed { index, day -> + Box( + modifier = Modifier.fillMaxWidth(1f / (7 - index)) + ) { + dayContent(DayState(day, selectionState)) + } + } + } +} diff --git a/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekUtil.kt b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekUtil.kt new file mode 100644 index 000000000..e926e14e8 --- /dev/null +++ b/ComposeCalendar/library/src/main/java/io/github/boguszpawlowski/composecalendar/week/WeekUtil.kt @@ -0,0 +1,52 @@ +package io.github.boguszpawlowski.composecalendar.week + +import android.annotation.SuppressLint +import io.github.boguszpawlowski.composecalendar.day.WeekDay +import io.github.boguszpawlowski.composecalendar.util.daysUntil +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.YearMonth + +internal const val DaysInAWeek = 7 + +@SuppressLint("NewApi") +internal fun YearMonth.getWeeks( + includeAdjacentMonths: Boolean, + firstDayOfTheWeek: DayOfWeek, + today: LocalDate, +): List { + val daysLength = lengthOfMonth() + + val starOffset = atDay(1).dayOfWeek daysUntil firstDayOfTheWeek + val endOffset = + DaysInAWeek - (atDay(daysLength).dayOfWeek daysUntil firstDayOfTheWeek) - 1 + + return (1 - starOffset..daysLength + endOffset).chunked(DaysInAWeek).mapIndexed { index, days -> + WeekDays( + isFirstWeekOfTheMonth = index == 0, + days = days.mapNotNull { dayOfMonth -> + val (date, isFromCurrentMonth) = when (dayOfMonth) { + in Int.MIN_VALUE..0 -> if (includeAdjacentMonths) { + val previousMonth = this.minusMonths(1) + previousMonth.atDay(previousMonth.lengthOfMonth() + dayOfMonth) to false + } else { + return@mapNotNull null + } + in 1..daysLength -> atDay(dayOfMonth) to true + else -> if (includeAdjacentMonths) { + val previousMonth = this.plusMonths(1) + previousMonth.atDay(dayOfMonth - daysLength) to false + } else { + return@mapNotNull null + } + } + + WeekDay( + date = date, + isFromCurrentMonth = isFromCurrentMonth, + isCurrentDay = date.equals(today), + ) + } + ) + } +} diff --git a/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/selection/SelectionStateTest.kt b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/selection/SelectionStateTest.kt new file mode 100644 index 000000000..75ca49c09 --- /dev/null +++ b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/selection/SelectionStateTest.kt @@ -0,0 +1,177 @@ +@file:Suppress("UnderscoresInNumericLiterals") + +package io.github.boguszpawlowski.composecalendar.selection + +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode.Multiple +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import java.time.LocalDate +import java.time.Month.APRIL + +internal class SelectionStateTest : ShouldSpec({ + + val yesterday = LocalDate.of(2020, APRIL, 9) + val today = LocalDate.of(2020, APRIL, 10) + val tomorrow = LocalDate.of(2020, APRIL, 11) + + context("Selection state with SelectionMode.None") { + should("not change selection after new value arrives") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.None) + + state.onDateSelected(LocalDate.now()) + + state.selection shouldBe emptyList() + } + + should("be able to change if mode has been changed") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.None) + + state.selectionMode = SelectionMode.Single + state.onDateSelected(today) + + state.selection shouldBe listOf(today) + } + } + + context("Selection state with SelectionMode.Single") { + should("change state to single after day is selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Single) + + state.onDateSelected(today) + + state.selection shouldBe listOf(today) + } + + should("change state to none when same day is selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Single) + + state.onDateSelected(today) + state.onDateSelected(today) + + state.selection shouldBe emptyList() + } + + should("change to other day when selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Single) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + + state.selection shouldBe listOf(tomorrow) + } + + should("not be mutable after selection mode is changed to None") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Single) + + state.selectionMode = SelectionMode.None + state.onDateSelected(today) + + state.selection shouldBe emptyList() + } + } + + context("Selection state with SelectionMode.Multiple") { + should("allow for multiple days selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Multiple) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + + state.selection shouldContainExactly listOf( + today, + tomorrow + ) + } + + should("switch selection off once day is selected second time") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Multiple) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + state.onDateSelected(today) + + state.selection shouldContainExactly listOf(tomorrow) + } + } + + context("Selection state with SelectionMode.Period") { + should("allow for period of days selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Period) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + + state.selection.first() shouldBe today + state.selection.last() shouldBe tomorrow + } + + should("switch selection off once start day is selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Period) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + state.onDateSelected(today) + + state.selection shouldBe emptyList() + } + should("change end date once the date selected is between start and the end") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Period) + + state.onDateSelected(yesterday) + state.onDateSelected(tomorrow) + state.onDateSelected(today) + + state.selection.first() shouldBe yesterday + state.selection.last() shouldBe today + } + should("change start day once day before start is selected") { + val state = DynamicSelectionState({ true }, emptyList(), SelectionMode.Period) + + state.onDateSelected(today) + state.onDateSelected(tomorrow) + state.onDateSelected(yesterday) + + state.selection.first() shouldBe yesterday + state.selection.endOrNull() shouldBe null + } + } + + context("Selection State interface default values") { + val myInterfaceWithNoMethods = object : SelectionState {} + + should("Default isDateSelected to false") { + myInterfaceWithNoMethods.isDateSelected(today) shouldBe false + } + + should("Have a default implementation that doesn't throw an exception for onDateSelected") { + shouldNotThrowAny { + myInterfaceWithNoMethods.onDateSelected(today) + } + } + } + context("Selection State with confirm state change callback") { + var nextVetoResult = false + val initialSelection = LocalDate.of(1999, 10, 12) + val newSelection = initialSelection.plusDays(1) + + val selectionState = DynamicSelectionState( + confirmSelectionChange = { nextVetoResult }, + selection = listOf(initialSelection), + selectionMode = Multiple, + ) + should("Not change the selection when change is vetoed") { + selectionState.onDateSelected(newSelection) + + selectionState.selection shouldBe listOf(initialSelection) + } + should("Change the selection when change is not vetoed") { + nextVetoResult = true + + selectionState.onDateSelected(newSelection) + + selectionState.selection shouldBe listOf(initialSelection, newSelection) + } + } +}) diff --git a/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekTest.kt b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekTest.kt new file mode 100644 index 000000000..c59c4fcf6 --- /dev/null +++ b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekTest.kt @@ -0,0 +1,68 @@ +package io.github.boguszpawlowski.composecalendar.week + +import io.kotest.assertions.asClue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +internal class WeekTest : ShouldSpec({ + + val today = LocalDate.of(2000, 10, 2) + + context("Secondary constructor") { + val week = Week(firstDay = today) + + should("properly create an instance of week") { + week.asClue { + it.days.first() shouldBe today + it.days.last() shouldBe today.plusDays(6) + it.days.size shouldBe 7 + } + } + } + + context("Incrementing") { + val week = Week(firstDay = today).inc() + + should("shift the days by 7") { + week.asClue { + it.days.first() shouldBe today.plusWeeks(1) + it.days.last() shouldBe today.plusDays(6).plusWeeks(1) + it.days.size shouldBe 7 + } + } + } + + context("Decrementing") { + val week = Week(firstDay = today).dec() + + should("shift the days by -7") { + week.asClue { + it.days.first() shouldBe today.plusWeeks(-1) + it.days.last() shouldBe today.plusDays(6).plusWeeks(-1) + it.days.size shouldBe 7 + } + } + } + + context("Adding a week") { + val week = Week(firstDay = today).plusWeeks(7) + + should("add correct number of days") { + week.asClue { + it.days.first() shouldBe today.plusWeeks(7) + it.days.last() shouldBe today.plusDays(6).plusWeeks(7) + it.days.size shouldBe 7 + } + } + } + + context("Constructing a week with invalid number of days") { + should("throw") { + shouldThrow { + Week(List(18) { LocalDate.now() }) + } + } + } +}) diff --git a/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekUtilTest.kt b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekUtilTest.kt new file mode 100644 index 000000000..17ce731f0 --- /dev/null +++ b/ComposeCalendar/library/src/test/java/io/github/boguszpawlowski/composecalendar/week/WeekUtilTest.kt @@ -0,0 +1,166 @@ +@file:Suppress("UnderscoresInNumericLiterals") + +package io.github.boguszpawlowski.composecalendar.week + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import java.time.DayOfWeek.MONDAY +import java.time.DayOfWeek.SATURDAY +import java.time.DayOfWeek.SUNDAY +import java.time.LocalDate +import java.time.YearMonth + +internal class WeekUtilTest : ShouldSpec({ + + val today = LocalDate.of(2020, 9, 15) + val month = YearMonth.of(2020, 9) + val previousMonth = YearMonth.of(2020, 8) + val nextMonth = YearMonth.of(2020, 10) + + context("Extracting weeks without adjacent months") { + val includeAdjacentMonths = false + should("return days only from current month") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + val days = weeks.flatMap { it.days } + + days.map { it.date }.forEach { + it.isFromMonth(month) shouldBe true + it.isFromMonth(previousMonth) shouldBe false + it.isFromMonth(nextMonth) shouldBe false + } + } + + should("return all days from the month") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + val days = weeks.flatMap { it.days }.map { it.date } + + val daysLength = month.lengthOfMonth() + val daysFromCurrentMonth = (1..daysLength).map { month.atDay(it) } + + days shouldContainExactly daysFromCurrentMonth + } + + should("return days properly split to weeks") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + + weeks[0].days shouldHaveSize 6 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 3 + + weeks shouldHaveSize 5 + } + + should("return days properly split to weeks when first day is Saturday") { + val weeks = month.getWeeks(includeAdjacentMonths, SATURDAY, today) + + weeks[0].days shouldHaveSize 4 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 5 + + weeks shouldHaveSize 5 + } + + should("return days properly split to weeks when first day is Sunday ") { + val weeks = month.getWeeks(includeAdjacentMonths, SUNDAY, today) + + weeks[0].days shouldHaveSize 5 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 4 + + weeks shouldHaveSize 5 + } + } + + context("Extracting weeks with adjacent months") { + val includeAdjacentMonths = true + should("return days from current and previous months") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + val days = weeks.flatMap { it.days } + + days.map { it.date }.forEachIndexed { index, day -> + when (index) { + 0 -> { + day.isFromMonth(month) shouldBe false + day.isFromMonth(previousMonth) shouldBe true + day.isFromMonth(nextMonth) shouldBe false + } + in 1..30 -> { + day.isFromMonth(month) shouldBe true + day.isFromMonth(previousMonth) shouldBe false + day.isFromMonth(nextMonth) shouldBe false + } + else -> { + day.isFromMonth(month) shouldBe false + day.isFromMonth(previousMonth) shouldBe false + day.isFromMonth(nextMonth) shouldBe true + } + } + } + } + + should("return all days from the month") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + val days = weeks.flatMap { it.days }.map { it.date } + + val daysLength = month.lengthOfMonth() + val endOffset = 6 - month.atEndOfMonth().dayOfWeek.ordinal + val startOffset = month.atDay(1).dayOfWeek.ordinal - 1 + val daysFromCurrentMonth = (1..daysLength).map { month.atDay(it) } + val daysFromNextMonth = (1..endOffset).map { nextMonth.atDay(it) } + val daysFromPreviousMonth = + (previousMonth.lengthOfMonth() - startOffset..previousMonth.lengthOfMonth()).map { + previousMonth.atDay(it) + } + + days shouldContainAll daysFromCurrentMonth + daysFromNextMonth + daysFromPreviousMonth + } + + should("return days properly split to weeks") { + val weeks = month.getWeeks(includeAdjacentMonths, MONDAY, today) + + weeks[0].days shouldHaveSize 7 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 7 + + weeks shouldHaveSize 5 + } + + should("return days properly split to weeks when first day is Sunday") { + val weeks = month.getWeeks(includeAdjacentMonths, SUNDAY, today) + + weeks[0].days shouldHaveSize 7 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 7 + + weeks shouldHaveSize 5 + } + + should("return days properly split to weeks when first day is Saturday") { + val weeks = month.getWeeks(includeAdjacentMonths, SATURDAY, today) + + weeks[0].days shouldHaveSize 7 + weeks[1].days shouldHaveSize 7 + weeks[2].days shouldHaveSize 7 + weeks[3].days shouldHaveSize 7 + weeks[4].days shouldHaveSize 7 + + weeks shouldHaveSize 5 + } + } +}) + +private fun LocalDate.isFromMonth(yearMonth: YearMonth) = + month == yearMonth.month && year == yearMonth.year diff --git a/ComposeCalendar/sample/.gitignore b/ComposeCalendar/sample/.gitignore new file mode 100644 index 000000000..a2def884f --- /dev/null +++ b/ComposeCalendar/sample/.gitignore @@ -0,0 +1,2 @@ +/build +google-services.json diff --git a/ComposeCalendar/sample/build.gradle.kts b/ComposeCalendar/sample/build.gradle.kts new file mode 100644 index 000000000..7734761e4 --- /dev/null +++ b/ComposeCalendar/sample/build.gradle.kts @@ -0,0 +1,60 @@ +import org.ajoberstar.grgit.Grgit + +plugins { + id(Android.ApplicationPluginId) + kotlin(Kotlin.AndroidPluginId) + id("common-android-plugin") +} + +val commitsCount = Grgit.open(mapOf("dir" to rootDir)).log().size + +android { + namespace = "io.github.boguszpawlowski.composecalendar.sample" + defaultConfig { + versionCode = commitsCount + versionName = "0.0.1" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + packaging { + resources { + excludes.addAll(listOf("META-INF/AL2.0", "META-INF/LGPL2.1")) + } + } + +// compileOptions { +// isCoreLibraryDesugaringEnabled = true +// } +} + +dependencies { +// coreLibraryDesugaring(Kotlin.DesugarJdkLibs) + +// implementation(project(":library")) + implementation(project(":kotlinx-datetime")) + + implementation("io.github.boguszpawlowski.composecalendar:composecalendar:1.3.0") + + implementation(Material.Core) + + implementation(AndroidX.AppCompat) + implementation(AndroidX.ComposeActivity) + + implementation(Compose.Runtime) + implementation(Compose.Navigation) + implementation(Compose.Ui) + implementation(Compose.UiTooling) + implementation(Compose.Foundation) + implementation(Compose.FoundationLayout) + implementation(Compose.Material) + implementation(Timber.Core) + + debugImplementation(Debug.LeakCanary) + debugImplementation(Hyperion.Core) + debugImplementation(Hyperion.Crash) + debugImplementation(Hyperion.GeigerCounter) + debugImplementation(Hyperion.Measurement) + debugImplementation(ComposeTest.Manifest) + + androidTestImplementation(ComposeTest.Core) +} diff --git a/ComposeCalendar/sample/proguard-rules.pro b/ComposeCalendar/sample/proguard-rules.pro new file mode 100644 index 000000000..2f9dc5a47 --- /dev/null +++ b/ComposeCalendar/sample/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/ComposeCalendar/sample/src/androidTest/java/io/github/boguszpawlowski/composecalendar/sample/ScrollBehaviorTest.kt b/ComposeCalendar/sample/src/androidTest/java/io/github/boguszpawlowski/composecalendar/sample/ScrollBehaviorTest.kt new file mode 100644 index 000000000..b468e3fea --- /dev/null +++ b/ComposeCalendar/sample/src/androidTest/java/io/github/boguszpawlowski/composecalendar/sample/ScrollBehaviorTest.kt @@ -0,0 +1,50 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import io.github.boguszpawlowski.composecalendar.StaticCalendar +import io.github.boguszpawlowski.composecalendar.rememberCalendarState +import org.junit.Rule +import org.junit.Test +import java.time.YearMonth + +internal class ScrollBehaviorTest { + + private val initialMonth = YearMonth.of(2012, 5) + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun monthChangeTest() { + composeTestRule.setContent { + StaticCalendar( + calendarState = rememberCalendarState(initialMonth = initialMonth) + ) + } + + composeTestRule.onNodeWithTag("Decrement").performClick() + composeTestRule.onNodeWithTag("MonthLabel", true).assertTextEquals("April") + composeTestRule.onNodeWithTag("Increment").performClick() + composeTestRule.onNodeWithTag("MonthLabel", true).assertTextEquals("May") + } + + @Test + fun scrollMonthChangeTest() { + composeTestRule.setContent { + StaticCalendar( + calendarState = rememberCalendarState(initialMonth = initialMonth) + ) + } + + composeTestRule.onNodeWithTag("MonthPager").performTouchInput { swipeLeft() } + composeTestRule.onNodeWithTag("MonthLabel", true).assertTextEquals("June") + composeTestRule.onNodeWithTag("MonthPager").performTouchInput { swipeRight() } + composeTestRule.onNodeWithTag("MonthLabel", true).assertTextEquals("May") + } +} diff --git a/ComposeCalendar/sample/src/main/AndroidManifest.xml b/ComposeCalendar/sample/src/main/AndroidManifest.xml new file mode 100644 index 000000000..67df51341 --- /dev/null +++ b/ComposeCalendar/sample/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomComponentsSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomComponentsSample.kt new file mode 100644 index 000000000..4a77f4907 --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomComponentsSample.kt @@ -0,0 +1,104 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.StaticCalendar +import io.github.boguszpawlowski.composecalendar.day.NonSelectableDayState +import io.github.boguszpawlowski.composecalendar.header.MonthState +import java.time.DayOfWeek +import java.time.DayOfWeek.SUNDAY +import java.time.format.TextStyle.NARROW +import java.util.Locale + +@Composable +fun CustomComponentsSample() { + StaticCalendar( + modifier = Modifier.animateContentSize(), + showAdjacentMonths = false, + firstDayOfWeek = SUNDAY, + monthContainer = { MonthContainer(it) }, + dayContent = { DayContent(dayState = it) }, + daysOfWeekHeader = { WeekHeader(daysOfWeek = it) }, + monthHeader = { MonthHeader(monthState = it) }, + ) +} + +@Composable +private fun DayContent(dayState: NonSelectableDayState) { + Text( + text = dayState.date.dayOfMonth.toString(), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h6, + ) +} + +@Composable +private fun WeekHeader(daysOfWeek: List) { + Row { + daysOfWeek.forEach { dayOfWeek -> + Text( + textAlign = TextAlign.Center, + text = dayOfWeek.getDisplayName(NARROW, Locale.ROOT), + modifier = Modifier + .weight(1f) + .wrapContentHeight() + ) + } + } +} + +@Composable +private fun MonthHeader(monthState: MonthState) { + Row { + Text(monthState.currentMonth.year.toString(), style = MaterialTheme.typography.h3) + Text(monthState.currentMonth.month.name, style = MaterialTheme.typography.h3) + IconButton(onClick = { monthState.currentMonth = monthState.currentMonth.plusMonths(1) }) { + Image( + imageVector = Icons.Default.Star, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = "Next", + ) + } + } +} + +@Composable +private fun MonthContainer(content: @Composable (PaddingValues) -> Unit) { + Card( + elevation = 0.dp, + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, Color.LightGray), + content = { content(PaddingValues(4.dp)) }, + ) +} + +@Preview +@Composable +private fun CustomComponentsPreview() { + MaterialTheme { + Surface { + CustomComponentsSample() + } + } +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomSelectionSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomSelectionSample.kt new file mode 100644 index 000000000..47bbe09a9 --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/CustomSelectionSample.kt @@ -0,0 +1,66 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.github.boguszpawlowski.composecalendar.Calendar +import io.github.boguszpawlowski.composecalendar.CalendarState +import io.github.boguszpawlowski.composecalendar.header.MonthState +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import java.time.LocalDate +import java.time.YearMonth + +@Composable +fun CustomSelectionSample() { + Calendar(calendarState = rememberMonthSelectionState()) +} + +private class MonthSelectionState( + initialSelection: YearMonth? = null, +) : SelectionState { + + private var selection by mutableStateOf(initialSelection) + + override fun isDateSelected(date: LocalDate): Boolean = + date.yearMonth == selection + + override fun onDateSelected(date: LocalDate) { + selection = if (date.yearMonth == selection) null else date.yearMonth + } + + companion object { + @Suppress("FunctionName") // Factory function + fun Saver(): Saver = Saver( + save = { it.selection?.toString() }, + restore = { restored -> + val selection = (restored as? String)?.let { YearMonth.parse(it) } + MonthSelectionState(selection) + } + ) + } +} + +@Composable +private fun rememberMonthSelectionState( + initialMonth: YearMonth = YearMonth.now(), + minMonth: YearMonth = initialMonth.minusMonths(10000), + maxMonth: YearMonth = initialMonth.plusMonths(10000), + initialSelection: YearMonth? = null, + monthState: MonthState = rememberSaveable(saver = MonthState.Saver()) { + MonthState( + initialMonth = initialMonth, + minMonth = minMonth, + maxMonth = maxMonth + ) + }, + selectionState: MonthSelectionState = rememberSaveable(saver = MonthSelectionState.Saver()) { + MonthSelectionState(initialSelection = initialSelection) + } +): CalendarState = remember { CalendarState(monthState, selectionState) } + +private val LocalDate.yearMonth: YearMonth + get() = YearMonth.of(year, month) diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/KotlinDateTimeSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/KotlinDateTimeSample.kt new file mode 100644 index 000000000..bdbc3bef1 --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/KotlinDateTimeSample.kt @@ -0,0 +1,103 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.SelectableCalendar +import io.github.boguszpawlowski.composecalendar.kotlinxDateTime.now +import io.github.boguszpawlowski.composecalendar.rememberSelectableCalendarState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode.Multiple +import io.github.boguszpawlowski.composecalendar.selection.SelectionState +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toKotlinLocalDate + +@Composable +fun KotlinXDateTimeSample() { + var selection by remember { mutableStateOf(emptyList()) } + + Column { + DateTimeCalendar( + today = LocalDate.now(), + onSelectionChanged = { selection = it }, + dayContent = { DayContent(dayState = it) }, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Selection: ${selection.joinToString { it.toString() }}", + style = MaterialTheme.typography.h6, + ) + } +} + +@Composable +fun BoxScope.DayContent( + dayState: KotlinDayState, +) { + val isSelected = dayState.selectionState.isDateSelected(dayState.date) + + Text( + text = dayState.date.dayOfMonth.toString(), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .aspectRatio(1f) + .clickable { + dayState.selectionState.onDateSelected(dayState.date) + }, + color = if (isSelected) Color.Red else Color.Unspecified, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.h6, + ) +} + +@Composable +fun DateTimeCalendar( + today: LocalDate, + onSelectionChanged: (List) -> Unit, + dayContent: @Composable BoxScope.(KotlinDayState) -> Unit, +) { + SelectableCalendar( + calendarState = rememberSelectableCalendarState( + confirmSelectionChange = { selection -> onSelectionChanged(selection.map { it.toKotlinLocalDate() }); true }, + initialSelectionMode = Multiple, + ), + today = today.toJavaLocalDate(), + showAdjacentMonths = false, + dayContent = { dayState -> + dayContent( + KotlinDayState( + date = dayState.date.toKotlinLocalDate(), + isCurrentDay = dayState.isCurrentDay, + selectionState = dayState.selectionState, + ) + ) + } + ) +} + +data class KotlinDayState( + val date: LocalDate, + val isCurrentDay: Boolean, + val selectionState: T, +) + +private fun SelectionState.isDateSelected(date: LocalDate) = isDateSelected(date.toJavaLocalDate()) +private fun SelectionState.onDateSelected(date: LocalDate) = onDateSelected(date.toJavaLocalDate()) diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MainActivity.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MainActivity.kt new file mode 100644 index 000000000..2a883777b --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MainActivity.kt @@ -0,0 +1,113 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import io.github.boguszpawlowski.composecalendar.BuildConfig +import timber.log.Timber +import timber.log.Timber.DebugTree + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (BuildConfig.DEBUG) { + Timber.plant(DebugTree()) + } + setContent { + MainScreen() + } + } +} + +@Composable +fun MainScreen() { + MaterialTheme( + colors = if (isSystemInDarkTheme()) darkColors() else lightColors() + ) { + Surface( + modifier = Modifier.fillMaxSize(), + ) { + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = "main") { + composable("main") { MainMenu(navController = navController) } + composable("static") { StaticCalendarSample() } + composable("week") { WeekCalendarSample() } + composable("selection") { SelectableCalendarSample() } + composable("components") { CustomComponentsSample() } + composable("custom_selection") { CustomSelectionSample() } + composable("viewmodel") { ViewModelSample() } + composable("kotlinx_datetime") { KotlinXDateTimeSample() } + composable("min_max_month") { MinMaxCalendarSample() } + } + } + } +} + +@Composable +fun MainMenu(navController: NavController) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Button(onClick = { navController.navigate("static") }) { + Text(text = "Static Calendar") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("selection") }) { + Text(text = "Selectable Calendar") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("week") }) { + Text(text = "Week Calendar") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("components") }) { + Text(text = "Custom Components") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("custom_selection") }) { + Text(text = "Custom Selection") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("viewmodel") }) { + Text(text = "ViewModel") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("kotlinx_datetime") }) { + Text(text = "Kotlinx DateTime") + } + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { navController.navigate("min_max_month") }) { + Text(text = "Min Max Month") + } + } +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MinMaxCalendarSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MinMaxCalendarSample.kt new file mode 100644 index 000000000..03128ea4d --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/MinMaxCalendarSample.kt @@ -0,0 +1,268 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.CalendarState +import io.github.boguszpawlowski.composecalendar.StaticCalendar +import io.github.boguszpawlowski.composecalendar.StaticWeekCalendar +import io.github.boguszpawlowski.composecalendar.WeekCalendarState +import io.github.boguszpawlowski.composecalendar.rememberCalendarState +import io.github.boguszpawlowski.composecalendar.rememberWeekCalendarState +import io.github.boguszpawlowski.composecalendar.selection.EmptySelectionState +import io.github.boguszpawlowski.composecalendar.week.Week +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@Composable +fun MinMaxCalendarSample() { + val calendarState = rememberCalendarState( + initialMonth = YearMonth.now(), + minMonth = YearMonth.now(), + maxMonth = YearMonth.now() + ) + + val weekCalendarState = rememberWeekCalendarState( + initialWeek = Week.now(), + minWeek = Week.now(), + maxWeek = Week.now(), + ) + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + StaticCalendar(calendarState = calendarState) + + Spacer(modifier = Modifier.height(20.dp)) + + MinMaxControls(calendarState = calendarState) + + Spacer(modifier = Modifier.height(40.dp)) + + StaticWeekCalendar(calendarState = weekCalendarState) + + Spacer(modifier = Modifier.height(20.dp)) + + WeekMinMaxControls(calendarState = weekCalendarState) + } +} + +@Composable +private fun MinMaxControls( + calendarState: CalendarState, +) { + val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM") } + Text( + text = "Calendar Min Month", + style = MaterialTheme.typography.h5, + ) + MinControls( + calendarState = calendarState, + dateFormatter = dateFormatter + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Calendar Max Month", + style = MaterialTheme.typography.h5, + ) + MaxControls( + calendarState = calendarState, + dateFormatter = dateFormatter + ) +} + +@Composable +private fun MinControls( + calendarState: CalendarState, + dateFormatter: DateTimeFormatter, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .clickable { + calendarState.monthState.minMonth = calendarState.monthState.minMonth.minusMonths(1L) + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "-", + textAlign = TextAlign.Center + ) + val minYearMonthText = remember(calendarState.monthState.minMonth) { + dateFormatter.format(calendarState.monthState.minMonth) + } + Text(text = minYearMonthText) + Text( + modifier = Modifier + .clickable { + calendarState.monthState.minMonth = calendarState.monthState.minMonth.plusMonths(1L) + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "+", + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun MaxControls( + calendarState: CalendarState, + dateFormatter: DateTimeFormatter, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .clickable { + calendarState.monthState.maxMonth = calendarState.monthState.maxMonth.minusMonths(1L) + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "-", + textAlign = TextAlign.Center + ) + val maxYearMonthText = remember(calendarState.monthState.maxMonth) { + dateFormatter.format(calendarState.monthState.maxMonth) + } + Text(text = maxYearMonthText) + Text( + modifier = Modifier + .clickable { + calendarState.monthState.maxMonth = calendarState.monthState.maxMonth.plusMonths(1L) + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "+", + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun WeekMinMaxControls( + calendarState: WeekCalendarState, +) { + val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM-dd") } + Text( + text = "Calendar Min Week", + style = MaterialTheme.typography.h5, + ) + WeekMinControls( + calendarState = calendarState, + dateFormatter = dateFormatter + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Calendar Max Week", + style = MaterialTheme.typography.h5, + ) + WeekMaxControls( + calendarState = calendarState, + dateFormatter = dateFormatter + ) +} + +@Composable +private fun WeekMinControls( + calendarState: WeekCalendarState, + dateFormatter: DateTimeFormatter, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .clickable { + calendarState.weekState.minWeek = --calendarState.weekState.minWeek + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "-", + textAlign = TextAlign.Center + ) + val minYearMonthText = remember(calendarState.weekState.minWeek) { + val start = dateFormatter.format(calendarState.weekState.minWeek.start) + val end = dateFormatter.format(calendarState.weekState.minWeek.end) + "$start - $end" + } + Text(text = minYearMonthText) + Text( + modifier = Modifier + .clickable { + calendarState.weekState.minWeek = ++calendarState.weekState.minWeek + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "+", + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun WeekMaxControls( + calendarState: WeekCalendarState, + dateFormatter: DateTimeFormatter, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .clickable { + calendarState.weekState.maxWeek = --calendarState.weekState.maxWeek + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "-", + textAlign = TextAlign.Center + ) + val maxYearMonthText = remember(calendarState.weekState.maxWeek) { + val start = dateFormatter.format(calendarState.weekState.maxWeek.start) + val end = dateFormatter.format(calendarState.weekState.maxWeek.end) + "$start - $end" + } + Text(text = maxYearMonthText) + Text( + modifier = Modifier + .clickable { + calendarState.weekState.maxWeek = ++calendarState.weekState.maxWeek + } + .size(25.dp) + .border(1.dp, Color.Black), + text = "+", + textAlign = TextAlign.Center + ) + } +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/SelectableCalendarSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/SelectableCalendarSample.kt new file mode 100644 index 000000000..254707dbb --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/SelectableCalendarSample.kt @@ -0,0 +1,57 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.SelectableCalendar +import io.github.boguszpawlowski.composecalendar.rememberSelectableCalendarState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode + +@Composable +fun SelectableCalendarSample() { + val calendarState = rememberSelectableCalendarState() + + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + SelectableCalendar(calendarState = calendarState) + + SelectionControls(selectionState = calendarState.selectionState) + } +} + +@Composable +private fun SelectionControls( + selectionState: DynamicSelectionState, +) { + Text( + text = "Calendar Selection Mode", + style = MaterialTheme.typography.h5, + ) + SelectionMode.values().forEach { selectionMode -> + Row(modifier = Modifier.fillMaxWidth()) { + RadioButton( + selected = selectionState.selectionMode == selectionMode, + onClick = { selectionState.selectionMode = selectionMode } + ) + Text(text = selectionMode.name) + Spacer(modifier = Modifier.height(4.dp)) + } + } + + Text( + text = "Selection: ${selectionState.selection.joinToString { it.toString() }}", + style = MaterialTheme.typography.h6, + ) +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/StaticCalendarSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/StaticCalendarSample.kt new file mode 100644 index 000000000..1fe884242 --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/StaticCalendarSample.kt @@ -0,0 +1,17 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.StaticCalendar + +@Composable +fun StaticCalendarSample() { + StaticCalendar( + modifier = Modifier + .padding(8.dp) + .animateContentSize(), + ) +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/ViewModelSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/ViewModelSample.kt new file mode 100644 index 000000000..99dfcd17d --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/ViewModelSample.kt @@ -0,0 +1,199 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.boguszpawlowski.composecalendar.SelectableCalendar +import io.github.boguszpawlowski.composecalendar.day.DayState +import io.github.boguszpawlowski.composecalendar.header.MonthState +import io.github.boguszpawlowski.composecalendar.rememberSelectableCalendarState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode.Period +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.YearMonth +import kotlin.random.Random +import kotlin.random.nextLong + +/** + * In this sample, calendar composable is wired with an ViewModel. It's purpose is to show how to use + * the composable in real world use-case, by an example implementation of a calendar + * which can display planned recipes along with their prices. The recipes will also be re-fetched from + * fake API every time month changes. + */ +@Composable +fun ViewModelSample() { + val viewModel = remember { RecipeViewModel() } + val recipes by viewModel.recipesFlow.collectAsState() + val selectedPrice by viewModel.selectedRecipesPriceFlow.collectAsState(0) + val monthState = rememberMonthStateDefault() + LaunchedEffect(monthState) { + snapshotFlow { monthState.currentMonth } + .onEach { viewModel.onMonthChanged(it) } + .launchIn(this) + } + val state = rememberSelectableCalendarState( + confirmSelectionChange = { viewModel.onSelectionChanged(it); true }, + monthState = monthState, + initialSelectionMode = Period, + ) + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + SelectableCalendar( + calendarState = state, + dayContent = { dayState -> + RecipeDay( + state = dayState, + plannedRecipe = recipes.firstOrNull { it.date == dayState.date }, + ) + } + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Selected recipes price: $selectedPrice", + style = MaterialTheme.typography.h6, + ) + Spacer(modifier = Modifier.height(20.dp)) + } +} + +@Composable +private fun rememberMonthStateDefault() = rememberSaveable(saver = MonthState.Saver()) { + MonthState( + initialMonth = YearMonth.now(), + minMonth = YearMonth.now().minusMonths(10000), + maxMonth = YearMonth.now().plusMonths(10000), + ) +} + +/** + * Custom implementation of DayContent, which shows a dot if there is an recipe planned for this day. + */ +@Composable +fun RecipeDay( + state: DayState, + plannedRecipe: PlannedRecipe?, + modifier: Modifier = Modifier, +) { + val date = state.date + val selectionState = state.selectionState + + val isSelected = selectionState.isDateSelected(date) + + Card( + modifier = modifier + .aspectRatio(1f) + .padding(2.dp), + elevation = if (state.isFromCurrentMonth) 4.dp else 0.dp, + border = if (state.isCurrentDay) BorderStroke(1.dp, MaterialTheme.colors.primary) else null, + contentColor = if (isSelected) MaterialTheme.colors.secondary else contentColorFor( + backgroundColor = MaterialTheme.colors.surface + ) + ) { + Column( + modifier = Modifier.clickable { + selectionState.onDateSelected(date) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = date.dayOfMonth.toString()) + if (plannedRecipe != null) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colors.primary) + ) + Text( + text = plannedRecipe.price.toString(), + fontSize = 8.sp, + ) + } + } + } +} + +data class PlannedRecipe( + val date: LocalDate, + val price: Double, +) + +/** + * ViewModel exposing list of our recipes + */ +class RecipeViewModel : ViewModel() { + + private val selectionFlow = MutableStateFlow(emptyList()) + val recipesFlow = MutableStateFlow(getRecipes()) + val selectedRecipesPriceFlow = recipesFlow.combine(selectionFlow) { recipes, selection -> + recipes.filter { it.date in selection }.sumOf { it.price } + } + + fun onSelectionChanged(selection: List) { + selectionFlow.value = selection + } + + fun onMonthChanged(newMonth: YearMonth) = viewModelScope.launch { + recipesFlow.value = getRecipesFromApi(startingDay = newMonth.atDay(15)) + } + + private fun getRecipes(startingDay: LocalDate = LocalDate.now()) = listOf( + PlannedRecipe(startingDay.plusDays(Random.nextLong(1L..3L)), 20.0), + PlannedRecipe(startingDay.plusDays(Random.nextLong(4L..7L)), 20.0), + PlannedRecipe(startingDay.plusDays(Random.nextLong(8L..11L)), 10.0), + PlannedRecipe(startingDay.plusDays(Random.nextLong(-5L..-1L)), 25.0), + ) + + /** + * Simulated API call for new recipes + */ + private suspend fun getRecipesFromApi(startingDay: LocalDate) = withContext(Dispatchers.Default) { + delay(100) + getRecipes(startingDay) + } +} + +@Preview +@Composable +fun ViewModelSamplePreview() { + MaterialTheme { + ViewModelSample() + } +} diff --git a/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/WeekCalendarSample.kt b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/WeekCalendarSample.kt new file mode 100644 index 000000000..9420be0cd --- /dev/null +++ b/ComposeCalendar/sample/src/main/java/io/github/boguszpawlowski/composecalendar/sample/WeekCalendarSample.kt @@ -0,0 +1,57 @@ +package io.github.boguszpawlowski.composecalendar.sample + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.github.boguszpawlowski.composecalendar.SelectableWeekCalendar +import io.github.boguszpawlowski.composecalendar.rememberSelectableWeekCalendarState +import io.github.boguszpawlowski.composecalendar.selection.DynamicSelectionState +import io.github.boguszpawlowski.composecalendar.selection.SelectionMode + +@Composable +fun WeekCalendarSample() { + val calendarState = rememberSelectableWeekCalendarState() + + Column( + Modifier.verticalScroll(rememberScrollState()) + ) { + SelectableWeekCalendar(calendarState = calendarState) + + SelectionControls(selectionState = calendarState.selectionState) + } +} + +@Composable +private fun SelectionControls( + selectionState: DynamicSelectionState, +) { + Text( + text = "Calendar Selection Mode", + style = MaterialTheme.typography.h5, + ) + SelectionMode.values().forEach { selectionMode -> + Row(modifier = Modifier.fillMaxWidth()) { + RadioButton( + selected = selectionState.selectionMode == selectionMode, + onClick = { selectionState.selectionMode = selectionMode } + ) + Text(text = selectionMode.name) + Spacer(modifier = Modifier.height(4.dp)) + } + } + + Text( + text = "Selection: ${selectionState.selection.joinToString { it.toString() }}", + style = MaterialTheme.typography.h6, + ) +} diff --git a/ComposeCalendar/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/ComposeCalendar/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..7706ab9e6 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/ComposeCalendar/sample/src/main/res/drawable/ic_launcher_background.xml b/ComposeCalendar/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ComposeCalendar/sample/src/main/res/layout/activity_main.xml b/ComposeCalendar/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..5f7f67a68 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6b78462d6 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a571e6009 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..61da551c5 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c41dd2853 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..db5080a75 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..6dba46dab Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..da31a871c Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..15ac68172 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..b216f2d31 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..f25a41974 Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e96783ccc Binary files /dev/null and b/ComposeCalendar/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/ComposeCalendar/sample/src/main/res/values-night/themes.xml b/ComposeCalendar/sample/src/main/res/values-night/themes.xml new file mode 100644 index 000000000..699a78df2 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ComposeCalendar/sample/src/main/res/values/colors.xml b/ComposeCalendar/sample/src/main/res/values/colors.xml new file mode 100644 index 000000000..ca1931bca --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/ComposeCalendar/sample/src/main/res/values/strings.xml b/ComposeCalendar/sample/src/main/res/values/strings.xml new file mode 100644 index 000000000..5a8769f51 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Compose Calendar + diff --git a/ComposeCalendar/sample/src/main/res/values/themes.xml b/ComposeCalendar/sample/src/main/res/values/themes.xml new file mode 100644 index 000000000..7e3bdf6b0 --- /dev/null +++ b/ComposeCalendar/sample/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ComposeCalendar/settings.gradle.kts b/ComposeCalendar/settings.gradle.kts new file mode 100644 index 000000000..6f5d6459f --- /dev/null +++ b/ComposeCalendar/settings.gradle.kts @@ -0,0 +1,4 @@ +rootProject.name = "composecalendar" +include(":sample") +include(":library") +include(":kotlinx-datetime")