diff --git a/README.MD b/README.MD index 85f8484..be31a06 100644 --- a/README.MD +++ b/README.MD @@ -11,7 +11,7 @@ calendar picker for any platform you want: Android, iOS, Desktop or even Web! Features: -| Platform | Supported | +| Feature | Supported | |:--------------------------:|:---------:| | Single month calendar view | ✅ | | Week calendar | ✅ | @@ -24,7 +24,7 @@ Features: | Month/Year picker | ✅ | | Scroll to date animation | ✅ | | Vertical calendar | ✅ | -| Range selection | 🔜 | +| Range selection | ✅ | --- diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/modifiers/RangeModifier.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/modifiers/RangeModifier.kt new file mode 100644 index 0000000..fbcd7a3 --- /dev/null +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/modifiers/RangeModifier.kt @@ -0,0 +1,37 @@ +package io.wojciechosak.calendar.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import io.wojciechosak.calendar.range.RangeConfig +import kotlinx.datetime.LocalDate + +internal fun Modifier.drawRange( + date: LocalDate, + selectedDates: List, + config: RangeConfig? = null +) = composed { + if (config == null) return@composed this + + drawBehind { + with(config) { + val range = if (selectedDates.size == 2) { + if (selectedDates.first() >= selectedDates.last()) { + Pair(selectedDates.last(), selectedDates.first()) + } else { + Pair(selectedDates.first(), selectedDates.last()) + } + } else { + null + } + + if (range != null && date == range.second) { + rangeIllustrator.drawEnd(this@drawBehind) + } else if (range != null && date == range.first) { + rangeIllustrator.drawStart(this@drawBehind) + } else if (range != null && date in (range.first..range.second)) { + rangeIllustrator.drawMiddle(this@drawBehind) + } + } + } +} \ No newline at end of file diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeConfig.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeConfig.kt new file mode 100644 index 0000000..8e32429 --- /dev/null +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeConfig.kt @@ -0,0 +1,9 @@ +package io.wojciechosak.calendar.range + +import androidx.compose.ui.graphics.Color +import io.wojciechosak.calendar.utils.Pallete.LightBlue + +data class RangeConfig( + val color: Color = LightBlue, + val rangeIllustrator: RangeIllustrator = RoundedRangeIllustrator(color) +) \ No newline at end of file diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeIllustrator.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeIllustrator.kt new file mode 100644 index 0000000..a8ce53b --- /dev/null +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RangeIllustrator.kt @@ -0,0 +1,10 @@ +package io.wojciechosak.calendar.range + +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp + +interface RangeIllustrator { + fun drawEnd(drawScope: DrawScope) + fun drawStart(drawScope: DrawScope) + fun drawMiddle(drawScope: DrawScope) +} \ No newline at end of file diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RoundedRangeIllustrator.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RoundedRangeIllustrator.kt new file mode 100644 index 0000000..8c041c1 --- /dev/null +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/RoundedRangeIllustrator.kt @@ -0,0 +1,47 @@ +package io.wojciechosak.calendar.range + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope + +class RoundedRangeIllustrator( + private val color: Color, +) : RangeIllustrator { + + override fun drawEnd(drawScope: DrawScope) { + drawScope.apply { + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 180f, + useCenter = true, + ) + drawRect( + color = color, + size = size.copy(width = size.width * 0.5f) + ) + } + } + + override fun drawStart(drawScope: DrawScope) { + drawScope.apply { + drawArc( + color = color, + startAngle = 180f, + sweepAngle = 360f, + useCenter = true, + ) + drawRect( + color = color, + size = size.copy(width = size.width * 0.5f), + topLeft = Offset(size.width * 0.5f, 0f) + ) + } + } + + override fun drawMiddle(drawScope: DrawScope) { + drawScope.apply { + drawRect(color = color) + } + } +} \ No newline at end of file diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/UnderlineIllustrator.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/UnderlineIllustrator.kt new file mode 100644 index 0000000..9a54a50 --- /dev/null +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/range/UnderlineIllustrator.kt @@ -0,0 +1,46 @@ +package io.wojciechosak.calendar.range + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope + +class UnderlineIllustrator( + private val color: Color, + private val y: (Size) -> Float = { it.height }, + private val strokeWidth: Float = 10f +) : RangeIllustrator { + + override fun drawEnd(drawScope: DrawScope) { + drawScope.apply { + drawLine( + color = color, + strokeWidth = strokeWidth, + start = Offset(0f, y(size)), + end = Offset(size.width, y(size)) + ) + } + } + + override fun drawStart(drawScope: DrawScope) { + drawScope.apply { + drawLine( + color = color, + strokeWidth = strokeWidth, + start = Offset(0f, y(size)), + end = Offset(size.width, y(size)) + ) + } + } + + override fun drawMiddle(drawScope: DrawScope) { + drawScope.apply { + drawLine( + color = color, + strokeWidth = strokeWidth, + start = Offset(0f, y(size)), + end = Offset(size.width, y(size)) + ) + } + } +} \ No newline at end of file diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/CalendarView.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/CalendarView.kt index 1423635..7b11cd7 100644 --- a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/CalendarView.kt +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/CalendarView.kt @@ -17,7 +17,9 @@ import io.wojciechosak.calendar.config.CalendarConfig import io.wojciechosak.calendar.config.DayState import io.wojciechosak.calendar.config.MonthYear import io.wojciechosak.calendar.config.SelectionMode +import io.wojciechosak.calendar.modifiers.drawRange import io.wojciechosak.calendar.modifiers.passTouchGesture +import io.wojciechosak.calendar.range.RangeConfig import io.wojciechosak.calendar.utils.monthLength import io.wojciechosak.calendar.utils.today import kotlinx.datetime.DateTimeUnit @@ -45,8 +47,9 @@ fun CalendarView( textAlign = TextAlign.Center, ) }, - selectionMode: SelectionMode = SelectionMode.Multiply(5), + selectionMode: SelectionMode = SelectionMode.Multiply(3), onDateSelected: (List) -> Unit = {}, + rangeConfig: RangeConfig? = null, modifier: Modifier = Modifier, ) { val yearMonth by remember { mutableStateOf(config.value.monthYear) } @@ -86,62 +89,105 @@ fun CalendarView( val weekDaysCount = if (state.showWeekdays) 7 else 0 items(previousMonthDays + daysInCurrentMonth + nextMonthDays + weekDaysCount) { iteration -> - val isWeekdayLabel = state.showWeekdays && iteration < weekDaysCount - val previousMonthDay = - iteration >= weekDaysCount && iteration < weekDaysCount + previousMonthDays - val nextMonthDay = - iteration >= weekDaysCount + previousMonthDays + daysInCurrentMonth - var newDate = LocalDate(year = yearMonth.year, month = yearMonth.month, dayOfMonth = 1) + Item( + iteration = iteration, + config = config, + weekDaysCount = weekDaysCount, + previousMonthDays = previousMonthDays, + daysInCurrentMonth = daysInCurrentMonth, + dayOfWeekLabel = dayOfWeekLabel, + yearMonth = yearMonth, + state = state, + selectionMode = selectionMode, + onDateSelected = onDateSelected, + isActiveDay = isActiveDay, + rangeConfig = rangeConfig, + day = day, + ) + } + } +} - if (previousMonthDay && config.value.showPreviousMonthDays) { - newDate = - newDate.plus(iteration - weekDaysCount - previousMonthDays, DateTimeUnit.DAY) - } else if (nextMonthDay && config.value.showNextMonthDays) { - newDate = - newDate - .plus(1, DateTimeUnit.MONTH) - .plus( - iteration - previousMonthDays - weekDaysCount - daysInCurrentMonth, - DateTimeUnit.DAY, - ) - } else if (!isWeekdayLabel) { - newDate = - newDate.plus(iteration - previousMonthDays - weekDaysCount, DateTimeUnit.DAY) - } - newDate = newDate.plus(state.dayOfWeekOffset, DateTimeUnit.DAY) +@Composable +private fun Item( + iteration: Int, + config: MutableState, + weekDaysCount: Int, + previousMonthDays: Int, + daysInCurrentMonth: Int, + dayOfWeekLabel: @Composable (DayOfWeek) -> Unit, + yearMonth: MonthYear, + state: CalendarConfig, + selectionMode: SelectionMode, + onDateSelected: (List) -> Unit, + isActiveDay: (LocalDate) -> Boolean, + rangeConfig: RangeConfig?, + day: @Composable (DayState) -> Unit, +) { + val isWeekdayLabel = state.showWeekdays && iteration < weekDaysCount + val previousMonthDay = + iteration >= weekDaysCount && iteration < weekDaysCount + previousMonthDays + val nextMonthDay = + iteration >= weekDaysCount + previousMonthDays + daysInCurrentMonth + var newDate = LocalDate(year = yearMonth.year, month = yearMonth.month, dayOfMonth = 1) - if (state.showWeekdays && iteration + state.dayOfWeekOffset < 7 + state.dayOfWeekOffset) { - val dayOfWeekIndex = - if (iteration + state.dayOfWeekOffset >= DayOfWeek.entries.size) { - iteration + state.dayOfWeekOffset - DayOfWeek.entries.size - } else if (iteration + state.dayOfWeekOffset < 0) { - DayOfWeek.entries.size + iteration + state.dayOfWeekOffset - } else { - iteration + state.dayOfWeekOffset - } - dayOfWeekLabel(DayOfWeek.entries[dayOfWeekIndex]) - } else if ((!state.showPreviousMonthDays && previousMonthDay) || (!state.showNextMonthDays && nextMonthDay)) { - Text("") + if (previousMonthDay && config.value.showPreviousMonthDays) { + newDate = + newDate.plus(iteration - weekDaysCount - previousMonthDays, DateTimeUnit.DAY) + } else if (nextMonthDay && config.value.showNextMonthDays) { + newDate = + newDate + .plus(1, DateTimeUnit.MONTH) + .plus( + iteration - previousMonthDays - weekDaysCount - daysInCurrentMonth, + DateTimeUnit.DAY, + ) + } else if (!isWeekdayLabel) { + newDate = + newDate.plus(iteration - previousMonthDays - weekDaysCount, DateTimeUnit.DAY) + } + newDate = newDate.plus(state.dayOfWeekOffset, DateTimeUnit.DAY) + + if (state.showWeekdays && iteration + state.dayOfWeekOffset < 7 + state.dayOfWeekOffset) { + val dayOfWeekIndex = + if (iteration + state.dayOfWeekOffset >= DayOfWeek.entries.size) { + iteration + state.dayOfWeekOffset - DayOfWeek.entries.size + } else if (iteration + state.dayOfWeekOffset < 0) { + DayOfWeek.entries.size + iteration + state.dayOfWeekOffset } else { - Box( - modifier = - Modifier.passTouchGesture { - val selectionList = selectDate(date = newDate, mode = selectionMode, list = config.value.selectedDates) - config.value = config.value.copy(selectedDates = selectionList) - onDateSelected(config.value.selectedDates) - }, - ) { - day( - DayState( - date = newDate, - isActiveDay = isActiveDay(newDate), - isForPreviousMonth = previousMonthDay, - isForNextMonth = nextMonthDay, - enabled = newDate >= state.minDate && newDate <= state.maxDate, - ), + iteration + state.dayOfWeekOffset + } + dayOfWeekLabel(DayOfWeek.entries[dayOfWeekIndex]) + } else if ((!state.showPreviousMonthDays && previousMonthDay) || (!state.showNextMonthDays && nextMonthDay)) { + Text("") + } else { + val selectedDates = config.value.selectedDates + Box( + modifier = Modifier + .passTouchGesture { + val selectionList = selectDate( + date = newDate, + mode = selectionMode, + list = selectedDates ) + config.value = config.value.copy(selectedDates = selectionList) + onDateSelected(selectionList) } - } + .drawRange( + selectedDates = selectedDates, + date = newDate, + config = rangeConfig + ) + ) { + day( + DayState( + date = newDate, + isActiveDay = isActiveDay(newDate), + isForPreviousMonth = previousMonthDay, + isForNextMonth = nextMonthDay, + enabled = newDate >= state.minDate && newDate <= state.maxDate, + ), + ) } } } @@ -165,8 +211,9 @@ private fun selectDate( SelectionMode.Range -> { result.add(0, date) - if (result.size >= 2) { - result.removeLast() + if (result.size > 2) { + result.clear() + result.add(0, date) } } diff --git a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/MonthPicker.kt b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/MonthPicker.kt index 582c043..686a72d 100644 --- a/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/MonthPicker.kt +++ b/calendar/src/commonMain/kotlin/io/wojciechosak/calendar/view/MonthPicker.kt @@ -37,9 +37,13 @@ fun MonthPicker( items(monthCount) { index -> val selectedMonth = Month.entries.getOrNull(index) Box( - modifier = - Modifier - .passTouchGesture { selectedMonth?.let { month -> onMonthSelected(month) } }, + modifier = Modifier.passTouchGesture { + selectedMonth?.let { month -> + onMonthSelected( + month + ) + } + }, contentAlignment = Alignment.Center, ) { selectedMonth?.let { month -> monthView(month) } diff --git a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/MenuScreen.kt b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/MenuScreen.kt index e32bd47..343f8e6 100644 --- a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/MenuScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/MenuScreen.kt @@ -31,8 +31,8 @@ class MenuScreen : Screen { item { ScreenButton(WeekViewScreen(), "Week view") } item { ScreenButton(SingleSelectionScreen(), "Single selection") } item { ScreenButton(MultipleSelectionScreen(), "Multiple selection") } - item { ScreenButton(AnimationScreen(), "Scroll animation") } - item { ScreenButton(RangeSelectionScreen(), "Range selection (\uD83D\uDD1C)") } + item { ScreenButton(AnimationScreen(), "Animations") } + item { ScreenButton(RangeSelectionScreen(), "Range selection") } item { ScreenButton(FullDateScreen(), "Full date selector (day/month/year)") } item { Text("Lib version: 0.0.5") } } diff --git a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/MultipleSelectionScreen.kt b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/MultipleSelectionScreen.kt index 7410e82..7e7f43e 100644 --- a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/MultipleSelectionScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/MultipleSelectionScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import io.wojciechosak.calendar.config.SelectionMode import io.wojciechosak.calendar.config.rememberCalendarState import io.wojciechosak.calendar.utils.today import io.wojciechosak.calendar.view.CalendarView @@ -38,6 +39,7 @@ class MultipleSelectionScreen : Screen { selectedDates.clear() selectedDates.addAll(it) }, + selectionMode = SelectionMode.Multiply(3) ) } Spacer(modifier = Modifier.height(20.dp)) diff --git a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/RangeSelectionScreen.kt b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/RangeSelectionScreen.kt index cd30035..81d40d6 100644 --- a/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/RangeSelectionScreen.kt +++ b/sample/composeApp/src/commonMain/kotlin/io/wojciechosak/calendar/calendar/screens/RangeSelectionScreen.kt @@ -1,11 +1,57 @@ package io.wojciechosak.calendar.calendar.screens +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import io.wojciechosak.calendar.config.SelectionMode +import io.wojciechosak.calendar.config.rememberCalendarState +import io.wojciechosak.calendar.range.RangeConfig +import io.wojciechosak.calendar.range.RoundedRangeIllustrator +import io.wojciechosak.calendar.range.UnderlineIllustrator +import io.wojciechosak.calendar.utils.today +import io.wojciechosak.calendar.view.CalendarView +import io.wojciechosak.calendar.view.HorizontalCalendarView +import kotlinx.datetime.LocalDate class RangeSelectionScreen : Screen { + @OptIn(ExperimentalFoundationApi::class) @Composable override fun Content() { - TODO("Not yet implemented") + Column { + val startDate = LocalDate.today() + val selectedDates = remember { mutableStateListOf() } + + HorizontalCalendarView(startDate = startDate) { monthOffset -> + CalendarView( + config = + rememberCalendarState( + startDate = startDate, + monthOffset = monthOffset, + selectedDates = selectedDates, + ), + selectionMode = SelectionMode.Range, + onDateSelected = { + selectedDates.clear() + selectedDates.addAll(it) + }, + rangeConfig = RangeConfig( + rangeIllustrator = UnderlineIllustrator(Color.Green) + ) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + if (selectedDates.size == 2) { + Text("From:\n${selectedDates.last()} to ${selectedDates.first()}") + } + } } }