Last active
March 2, 2026 23:39
-
-
Save Binghammer/3c00dcf5c534410662672b76f49e2fcc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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