diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 3bdb647..74a998a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen @@ -26,8 +27,12 @@ fun CalendarHost(modifier: Modifier = Modifier) { onSelectView = onSelectView, modifier = modifier, ) - // Month, plus Day as a fallback until the day view lands (v0.5). - else -> MonthScreen( + CalendarView.Day -> DayScreen( + selectedView = view, + onSelectView = onSelectView, + modifier = modifier, + ) + CalendarView.Month -> MonthScreen( selectedView = view, onSelectView = onSelectView, modifier = modifier, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 0df1adc..c0cf9d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -12,9 +12,10 @@ enum class CalendarView { /** * Views that actually have a screen today. The view-switcher pill cycles - * through these in order; Day joins once its screen lands. + * through these in order. */ -val IMPLEMENTED_VIEWS: List = listOf(CalendarView.Month, CalendarView.Week) +val IMPLEMENTED_VIEWS: List = + listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day) /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt new file mode 100644 index 0000000..e37fefa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -0,0 +1,552 @@ +package de.jeanlucmakiola.calendula.ui.day + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale +import kotlin.math.roundToInt + +private val HOUR_HEIGHT = 56.dp +private val GUTTER_WIDTH = 48.dp +private val MIN_EVENT_HEIGHT = 24.dp +private val ALL_DAY_ROW_HEIGHT = 24.dp +private val ALL_DAY_VERTICAL_PADDING = 6.dp + +/** Total all-day strip height for the day (0 when there are no all-day events). */ +private fun DayUiState.Success.allDayStripHeight(): Dp { + if (allDay.isEmpty()) return 0.dp + val lanes = allDay.maxOf { it.lane } + 1 + return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DayScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> Unit, + modifier: Modifier = Modifier, + viewModel: DayViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val date by viewModel.date.collectAsStateWithLifecycle() + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + // The all-day strip shares the app bar's scrolled colour so the whole top + // region elevates together once the timeline scrolls under it. + val topSectionColor by animateColorAsState( + targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) { + MaterialTheme.colorScheme.surfaceContainer + } else { + MaterialTheme.colorScheme.surface + }, + label = "day-top-section-color", + ) + + val isOnToday = when (val s = state) { + is DayUiState.Success -> s.date == s.today + else -> true + } + + // Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump. + var slideDir by remember { mutableIntStateOf(0) } + val goNext = { slideDir = 1; viewModel.goToNext() } + val goPrev = { slideDir = -1; viewModel.goToPrev() } + val jumpToToday = { slideDir = 0; viewModel.goToToday() } + + ModalNavigationDrawer( + drawerState = drawerState, + // Open only via the menu button — edge-swipe would fight the day swipe. + gesturesEnabled = drawerState.isOpen, + drawerContent = { + CalendarDrawer( + onToday = { jumpToToday(); scope.launch { drawerState.close() } }, + onJumpToDate = { scope.launch { drawerState.close() } /* TODO: date picker */ }, + onFilter = { scope.launch { drawerState.close() } /* TODO: filter sheet */ }, + onSettings = { scope.launch { drawerState.close() } /* TODO: settings */ }, + ) + }, + ) { + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + DayTopBar( + date = date, + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next()) }, + onOpenDrawer = { scope.launch { drawerState.open() } }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = !isOnToday, + enter = scaleIn(), + exit = scaleOut(), + ) { + ExtendedFloatingActionButton( + onClick = jumpToToday, + icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + text = { Text(stringResource(R.string.day_today_action)) }, + ) + } + }, + ) { innerPadding -> + DayContent( + state = state, + slideDir = slideDir, + topSectionColor = topSectionColor, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } +} + +@Composable +private fun DayContent( + state: DayUiState, + slideDir: Int, + topSectionColor: Color, + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val threshold = with(density) { 24.dp.toPx() } + var dragAccum by remember { mutableFloatStateOf(0f) } + val slideSpec = rememberCalendarSlideSpec() + + // Hoisted above the per-day AnimatedContent so the vertical scroll position + // survives day-to-day swipes. We only centre on noon once, on first entry + // into the day view (i.e. when arriving from the month/week view). + val scrollState = rememberScrollState() + LaunchedEffect(Unit) { + snapshotFlow { scrollState.maxValue }.first { it > 0 } + val maxV = scrollState.maxValue + val target = with(density) { + (HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt() + }.coerceIn(0, maxV) + scrollState.scrollTo(target) + } + + // Single, hoisted all-day strip height — shared by the outgoing and incoming + // day during a swipe, so the strip slides along but never jumps in height. + val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp + val allDayHeight by animateDpAsState( + targetValue = targetAllDayHeight, + label = "day-all-day-strip-height", + ) + + // Whole-page horizontal swipe, one level above the timeline's vertical + // scroll: a horizontal drag crosses this detector's slop, while a vertical + // drag is consumed by the inner scroll first — the two gestures coexist. + val swipeModifier = Modifier.pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { dragAccum = 0f }, + onDragEnd = { + when { + dragAccum < -threshold -> onSwipeNext() + dragAccum > threshold -> onSwipePrev() + } + dragAccum = 0f + }, + onDragCancel = { dragAccum = 0f }, + onHorizontalDrag = { _, drag -> dragAccum += drag }, + ) + } + + AnimatedContent( + targetState = state, + modifier = modifier.then(swipeModifier), + contentKey = { s -> + when (s) { + is DayUiState.Success -> "success-${s.date}" + is DayUiState.Failure -> "failure-${s.reason}" + DayUiState.Loading -> "loading" + } + }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + label = "day-transition", + ) { s -> + when (s) { + DayUiState.Loading -> DayLoading() + is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is DayUiState.Success -> DaySuccess( + state = s, + topSectionColor = topSectionColor, + scrollState = scrollState, + allDayHeight = allDayHeight, + ) + } + } +} + +@Composable +private fun DaySuccess( + state: DayUiState.Success, + topSectionColor: Color, + scrollState: ScrollState, + allDayHeight: Dp, +) { + Column(modifier = Modifier.fillMaxSize()) { + // All-day strip collapses to nothing when the day has no all-day events, + // so the timeline sits directly under the app bar. + AllDayStrip( + state = state, + height = allDayHeight, + modifier = Modifier + .fillMaxWidth() + .background(topSectionColor), + ) + // Breathing room between the (colour-shifting) top section and the + // scrolling timeline below. + Spacer(Modifier.height(8.dp)) + Timeline(state = state, scrollState = scrollState) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DayTopBar( + date: LocalDate, + selectedView: CalendarView, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, +) { + TopAppBar( + title = { + Text( + text = formatDayTitle(date), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + ViewSwitcherPill( + current = selectedView, + onCycle = onCycleView, + modifier = Modifier.padding(end = 8.dp), + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = scrollBehavior, + ) +} + +@Composable +private fun AllDayStrip( + state: DayUiState.Success, + height: Dp, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + + Row( + modifier = modifier + // Height is hoisted + animated so it resizes smoothly; padding sits + // inside it so the content area is lanes * row height. + .height(height) + .padding(vertical = ALL_DAY_VERTICAL_PADDING), + ) { + // Keep the gutter-width offset so the bars line up with the day column. + Spacer(Modifier.width(GUTTER_WIDTH)) + // Bars are positioned absolutely by lane (vertical stacking); each spans + // the full day-column width. clipToBounds keeps bars from spilling out + // while the height animates. + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clipToBounds(), + ) { + val barWidth = maxWidth + state.allDay.forEach { span -> + AllDayBar( + event = span.event, + dark = dark, + modifier = Modifier + .offset(y = ALL_DAY_ROW_HEIGHT * span.lane) + .width(barWidth) + .height(ALL_DAY_ROW_HEIGHT) + .padding(horizontal = 1.dp, vertical = 1.dp), + ) + } + } + } +} + +@Composable +private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) { + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + Box( + modifier = modifier + .background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + .semantics { contentDescription = title }, + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.Black.copy(alpha = 0.8f), + ) + } +} + +@Composable +private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) { + val totalHeight = HOUR_HEIGHT * 24 + val dark = isSystemInDarkTheme() + + Box(modifier = Modifier.fillMaxSize()) { + // Gutter and day column are two scroll viewports that SHARE one scroll + // state, so they stay perfectly aligned. The day-column viewport is a + // static, rounded-clipped window — the content scrolls inside it, so the + // soft corners are permanent at any scroll position. + Row(modifier = Modifier.fillMaxSize()) { + // Hour gutter (scrolls in sync with the day column) + Column( + modifier = Modifier + .width(GUTTER_WIDTH) + .fillMaxHeight() + .verticalScroll(scrollState), + ) { + (0 until 24).forEach { h -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(HOUR_HEIGHT), + ) { + if (h > 0) { + Text( + text = "%02d".format(h), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-6).dp), + ) + } + } + } + } + // Day column: rounded, clipped scroll viewport (permanent corners). + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(16.dp)) + .verticalScroll(scrollState), + ) { + DayColumnCard( + blocks = state.timed, + dark = dark, + modifier = Modifier + .fillMaxWidth() + .height(totalHeight), + ) + } + } + } +} + +@Composable +private fun DayColumnCard( + blocks: List, + dark: Boolean, + modifier: Modifier = Modifier, +) { + Card( + // Plain rectangular column — the soft corners come from the outer + // rounded scroll viewport, so inner rounding would look odd at the edges. + shape = RectangleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + modifier = modifier, + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val colWidth = maxWidth + blocks.forEach { block -> + val laneWidth = colWidth / block.laneCount + val top = HOUR_HEIGHT * (block.startMin / 60f) + val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f) + val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight + EventBlock( + block = block, + dark = dark, + modifier = Modifier + .offset(x = laneWidth * block.lane, y = top) + .width(laneWidth) + .height(height) + .padding(horizontal = 1.dp), + ) + } + } + } +} + +@Composable +private fun EventBlock( + block: TimedBlock, + dark: Boolean, + modifier: Modifier = Modifier, +) { + val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } + val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}" + val showTime = block.endMin - block.startMin >= 45 + Box( + modifier = modifier + .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .semantics { contentDescription = "$title, $timeLabel" }, + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = if (showTime) 1 else 2, + overflow = TextOverflow.Ellipsis, + color = Color.Black.copy(alpha = 0.85f), + ) + if (showTime) { + Text( + text = timeLabel, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.Black.copy(alpha = 0.6f), + ) + } + } + } +} + +@Composable +private fun DayLoading() { + val totalHeight = HOUR_HEIGHT * 24 + val scrollState = rememberScrollState() + Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + Box( + modifier = Modifier + .weight(1f) + .height(totalHeight) + .padding(horizontal = 2.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } +} + +private fun minToHm(min: Int): String = + if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) + +private fun formatDayTitle(date: LocalDate): String { + val locale = Locale.getDefault() + val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) + val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) + val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) + return "$weekday, ${date.day}. $monthName ${date.year}" +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt new file mode 100644 index 0000000..2883366 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayUiState.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.calendula.ui.day + +import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.ui.week.AllDaySpan +import de.jeanlucmakiola.calendula.ui.week.TimedBlock +import kotlinx.datetime.LocalDate + +/** + * The day view is a single-column slice of the week view (spec S3). It reuses the + * week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day + * spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol] + * == 0) and only their [AllDaySpan.lane] (vertical stacking) matters. + */ +sealed interface DayUiState { + data object Loading : DayUiState + data class Failure(val reason: FailureReason) : DayUiState + data class Success( + val date: LocalDate, + val today: LocalDate, + /** All-day/multi-day events covering this day, stacked by lane. */ + val allDay: List, + /** Timed events clipped to this day with overlap lanes resolved. */ + val timed: List, + ) : DayUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt new file mode 100644 index 0000000..85bc817 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt @@ -0,0 +1,106 @@ +package de.jeanlucmakiola.calendula.ui.day + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.FailureReason +import de.jeanlucmakiola.calendula.ui.week.layoutAllDay +import de.jeanlucmakiola.calendula.ui.week.layoutDay +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Instant +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class DayViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val zone = TimeZone.currentSystemDefault() + + private val todayDate: LocalDate + get() = Clock.System.now().toLocalDateTime(zone).date + + private val _date = MutableStateFlow(todayDate) + val date: StateFlow = _date + + val state: StateFlow = _date + .flatMapLatest { day -> + val range = dayRange(day, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(day, calendars, instances) + } + } + .catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = DayUiState.Loading, + ) + + fun goToPrev() { + _date.value = _date.value.minus(1, DateTimeUnit.DAY) + } + + fun goToNext() { + _date.value = _date.value.plus(1, DateTimeUnit.DAY) + } + + fun goToToday() { + _date.value = todayDate + } + + private fun buildState( + day: LocalDate, + calendars: List, + instances: List, + ): DayUiState { + if (calendars.isEmpty()) { + return DayUiState.Failure(FailureReason.NoCalendarsConfigured) + } + val days = listOf(day) + val allDay = instances.filter { it.isAllDay } + val timed = instances.filterNot { it.isAllDay } + return DayUiState.Success( + date = day, + today = todayDate, + allDay = layoutAllDay(allDay, days, zone), + timed = layoutDay(timed, day, zone), + ) + } +} + +/** Half-open instant range covering the single calendar [date]. */ +internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange { + val from = date.atStartOfDayIn(zone) + val to = date.atTime(23, 59, 59).toInstant(zone) + return from..to +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0967d40..2b41883 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -35,6 +35,9 @@ Diese Woche KW + + Heute + (Ohne Titel) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec9d088..6fe14e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,9 @@ This week Wk + + Today + (No title)