This commit is contained in:
coco
2026-07-16 09:43:06 +08:00
parent 95aa018b8b
commit 63bb06ff63
95 changed files with 6004 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/build
google-services.json
+60
View File
@@ -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)
}
+21
View File
@@ -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
@@ -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")
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ComposeCalendar">
<activity
android:name="io.github.boguszpawlowski.composecalendar.sample.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<DayOfWeek>) {
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()
}
}
}
@@ -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<MonthSelectionState, Any> = 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<MonthSelectionState> = remember { CalendarState(monthState, selectionState) }
private val LocalDate.yearMonth: YearMonth
get() = YearMonth.of(year, month)
@@ -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<LocalDate>()) }
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<DynamicSelectionState>,
) {
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<LocalDate>) -> Unit,
dayContent: @Composable BoxScope.(KotlinDayState<DynamicSelectionState>) -> 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<T : SelectionState>(
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())
@@ -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")
}
}
}
@@ -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<EmptySelectionState>,
) {
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<EmptySelectionState>,
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<EmptySelectionState>,
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<EmptySelectionState>,
) {
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<EmptySelectionState>,
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<EmptySelectionState>,
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
)
}
}
@@ -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,
)
}
@@ -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(),
)
}
@@ -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<DynamicSelectionState>,
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<LocalDate>())
val recipesFlow = MutableStateFlow(getRecipes())
val selectedRecipesPriceFlow = recipesFlow.combine(selectionFlow) { recipes, selection ->
recipes.filter { it.date in selection }.sumOf { it.price }
}
fun onSelectionChanged(selection: List<LocalDate>) {
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()
}
}
@@ -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,
)
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.boguszpawlowski.composecalendar.MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ComposeCalendar" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Compose Calendar</string>
</resources>
@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ComposeCalendar" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>