Skip to content

Instantly share code, notes, and snippets.

@Binghammer
Last active March 2, 2026 23:39
Show Gist options
  • Select an option

  • Save Binghammer/3c00dcf5c534410662672b76f49e2fcc to your computer and use it in GitHub Desktop.

Select an option

Save Binghammer/3c00dcf5c534410662672b76f49e2fcc to your computer and use it in GitHub Desktop.
@Composable
fun CalendarRoute(
viewModel: CalendarViewModel = CalendarViewModel(),
modifier: Modifier
) {
val state by viewModel.state.collectAsState()
// One-shot load tied to composition lifecycle
LaunchedEffect(Unit) {
viewModel.handleEvent(CalendarEvent.OnScreenShown)
}
//DisposableEffect example: hook a lifycycle observer and cleanup
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
viewModel.handleEvent(CalendarEvent.OnAppBackgrounded)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
CalendarScreen(
state = state,
onEvent = viewModel::handleEvent
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarScreen(
state: CalendarState,
onEvent: (CalendarEvent) -> Unit
) {
val monthTitleFormatter = remember {
// only create this once
DateTimeFormatter.ofPattern("MMMM yyyy", Locale.US)
}
val headerText by remember(state.selectedDate) {
derivedStateOf {
state.selectedDate?.let { "Selected: $it" } ?: "Select a date"
}
}
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Calendar") }
)
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = headerText,
modifier = Modifier.weight(1f)
)
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
}
}
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(
items = state.months,
key = { it.yearMonth.toString() }
) { month ->
MonthSection(
month = month,
monthTitleFormatter = monthTitleFormatter,
specialDates = state.specialDates,
onDayClicked = { clicked ->
onEvent(CalendarEvent.OnDateClicked(clicked))
})
}
}
}
}
@Composable
private fun MonthSection(
month: MonthModel,
monthTitleFormatter: DateTimeFormatter,
specialDates: Set<LocalDate>,
onDayClicked: (LocalDate) -> Unit,
) {
val yearMonth = month.yearMonth
Text(
text = yearMonth.atDay(1).format(monthTitleFormatter),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
Column(Modifier.padding(horizontal = 12.dp)) {
val rows = month.dayCell.chunked(7)
rows.forEach { week ->
Row(Modifier.fillMaxWidth()) {
week.forEach { cell ->
DayCell(
cell = cell,
isSpecial = cell.date != null && specialDates.contains(cell.date),
onClick = {
cell.date?.let(onDayClicked)
},
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
fun DayCell(
cell: DayCellModel,
isSpecial: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val clickableModifier = remember(cell.date) {
if (cell.date == null) Modifier else Modifier.clickable(onClick = onClick)
}
Box(
modifier = modifier
.padding(2.dp)
.aspectRatio(1f)
.then(clickableModifier)
.background(
when {
cell.date == null -> MaterialTheme.colorScheme.surface
isSpecial -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
), contentAlignment = Alignment.Center
) {
Text(
text = cell.label,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
}
}
class CalendarViewModel : ViewModel() {
private val _state = MutableStateFlow(CalendarState())
val state: StateFlow<CalendarState> = _state
@RequiresApi(Build.VERSION_CODES.O)
fun handleEvent(event: CalendarEvent) {
when (event) {
CalendarEvent.OnAppBackgrounded -> {
//todo persist selection
}
is CalendarEvent.OnDateClicked -> {
_state.update { it.copy(selectedDate = event.date) }
}
CalendarEvent.OnScreenShown -> {
loadCalendarData()
}
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun loadCalendarData() {
//fast check
if (_state.value.months.isNotEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
// Fake loading
delay(300)
val start = YearMonth.now()
val months = buildMonths(start = start, count = 24)
// Made up holidays
val holidays = setOf(
LocalDate.now().plusDays(3),
LocalDate.now().plusDays(14),
LocalDate.now().plusMonths(1).withDayOfMonth(5)
)
Log.d("CalendarViewModel", "months: ${months.size}")
_state.update {
it.copy(
isLoading = false,
months = months,
specialDates = holidays
)
}
}
}
private fun buildMonths(start: YearMonth, count: Int): List<MonthModel> {
return (0 until count).map { offset ->
val ym = start.plusMonths(offset.toLong())
MonthModel(
yearMonth = ym,
dayCell = buildMonthGrid(ym)
)
}
}
private fun buildMonthGrid(ym: YearMonth): List<DayCellModel> {
val firstDay = ym.atDay(1)
val length = ym.lengthOfMonth()
val firstDayOfWeekIndex = firstDay.dayOfWeek.value % 7
val cells = ArrayList<DayCellModel>(42)
repeat(firstDayOfWeekIndex) {
cells.add(DayCellModel(date = null, label = "")) //null creates a padded cell
}
for (day in 1..length) {
val date = ym.atDay(day)
cells.add(DayCellModel(date = date, label = day.toString()))
}
while (cells.size < 42) {
cells.add(DayCellModel(date = null, label = ""))
}
Log.d("CalendarViewModel", "cells: ${cells.size}")
return cells
}
}
sealed interface CalendarEvent {
data object OnScreenShown : CalendarEvent
data object OnAppBackgrounded : CalendarEvent
data class OnDateClicked(val date: LocalDate) : CalendarEvent
}
data class CalendarState(
val isLoading: Boolean = false,
val months: List<MonthModel> = emptyList(),
val specialDates: Set<LocalDate> = emptySet(),
val selectedDate: LocalDate? = null,
)
data class MonthModel(
val yearMonth: YearMonth, val dayCell: List<DayCellModel>
)
data class DayCellModel(
val date: LocalDate?, val label: String
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment