diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 032acea..5422d7f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -6,8 +6,8 @@ |---|---|---| | v0.1 | Foundation & CI | complete | | v0.2 | Data Layer & Permission Flow | complete | -| v0.3 | Month view | pending | -| v0.4 | Week view | pending | +| v0.3 | Month view | in progress | +| v0.4 | Week view | in progress | | v0.5 | Day view | pending | | v0.6 | Event Detail Sheet | pending | | v0.7 | Filter & Settings | pending | diff --git a/.planning/STATE.md b/.planning/STATE.md index ce09fef..7159fbf 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,11 +1,12 @@ # Calendula — Current State -*Last updated: 2026-06-08* +*Last updated: 2026-06-10* ## Status -**Milestone:** v0.2 — Data Layer & Permission Flow -**Phase:** Plan 02 complete; UI-design iteration pending before Plan 03 +**Milestone:** v0.4 — Week view (in progress) +**Phase:** Month + Week views implemented; cross-cutting wiring (detail sheet, +filter, settings, jump-to-date) still stubbed ## Progress @@ -13,11 +14,15 @@ - [x] V1 design decisions resolved (App name "Calendula", icon, seed color) - [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green) - [x] Plan 02 written and executed — data layer + permission flow + debug screen -- [ ] UI-design iteration (mockups for Month/Week/Day/Detail/Filter/Settings, all three states) -- [ ] Plan 03 (Month view) +- [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen) +- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states +- [x] View-switcher (M1) wired — cycles Month ↔ Week (Day joins once S3 lands) +- [ ] Day view (S3) +- [ ] Event-detail sheet (S4) — week/month event taps are currently no-ops +- [ ] Filter sheet (M3), Settings (M4), Jump-to-date (M2) — drawer entries stubbed ## Next -1. Iterate on UI design (mockups per screen, all three states) -2. Write Plan 03: Month view -3. Execute Plan 03 — Debug screen gets replaced by month view +1. Day view (S3) — slot it into the view-switcher cycle +2. Event-detail sheet (S4) — wire month-day and week-event taps to it +3. Revisit month/week UI polish + shared anchor-date continuity across views diff --git a/CHANGELOG.md b/CHANGELOG.md index 4860b0e..d7f4fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Month view (S1): Material 3 Expressive card-per-day grid (only the current + month's weeks; neighbouring days left blank), per-day event dots with "+N" + overflow, today emphasised via `primaryContainer`, spring-based press + feedback from the active motion scheme, swipe + drawer navigation, + Loading/Failure/Success states +- Week view (S2): vertical time schedule with overlap-resolved lanes, + separate all-day strip, midnight-spanning events clipped per day, swipe + navigation, Loading/Failure/Success states +- Functional view-switcher (M1) cycling Month ↔ Week (Day joins with S3) +- Shared calendar UI building blocks in `ui/common/` (navigation drawer, + failure screen, view-switcher pill, color pastelizer, observable locale) + +### Removed +- Throwaway debug screen — superseded by the month view + ## [0.2.1] — 2026-06-09 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20b43fe..5168a47 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material.icons.core) implementation(libs.hilt.android) implementation(libs.androidx.hilt.navigation.compose) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt new file mode 100644 index 0000000..3bdb647 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.calendula.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.month.MonthScreen +import de.jeanlucmakiola.calendula.ui.week.WeekScreen + +/** + * Holds the active top-level view (spec M1) and swaps between the calendar + * screens. Each screen owns its own ViewModel and date anchor; the view-switcher + * pill in their top bars writes back here via [onSelectView]. + */ +@Composable +fun CalendarHost(modifier: Modifier = Modifier) { + var view by rememberSaveable { mutableStateOf(CalendarView.Month) } + val onSelectView: (CalendarView) -> Unit = { view = it } + + when (view) { + CalendarView.Week -> WeekScreen( + selectedView = view, + onSelectView = onSelectView, + modifier = modifier, + ) + // Month, plus Day as a fallback until the day view lands (v0.5). + else -> MonthScreen( + selectedView = view, + onSelectView = onSelectView, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 2f171b5..d0a07e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -2,21 +2,18 @@ package de.jeanlucmakiola.calendula.ui import android.Manifest import android.content.pm.PackageManager -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import de.jeanlucmakiola.calendula.ui.debug.DebugScreen import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen @Composable @@ -42,14 +39,12 @@ fun RootScreen(modifier: Modifier = Modifier) { onDispose { lifecycle.removeObserver(obs) } } - Scaffold(modifier = modifier) { innerPadding -> - if (hasPermission) { - DebugScreen(modifier = Modifier.padding(innerPadding)) - } else { - PermissionScreen( - onGranted = { hasPermission = true }, - modifier = Modifier.padding(innerPadding), - ) - } + if (hasPermission) { + CalendarHost(modifier = modifier) + } else { + PermissionScreen( + onGranted = { hasPermission = true }, + modifier = modifier, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt new file mode 100644 index 0000000..14b7deb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt @@ -0,0 +1,17 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.graphics.Color + +/** + * Soften a raw calendar color toward a pastel that fits the active theme. + * - Keeps the hue (so users still recognise their calendars) + * - Caps saturation so harsh provider colors stop screaming + * - Pins value/brightness to a band that reads on both light and dark surfaces + */ +fun pastelize(rawArgb: Int, dark: Boolean): Color { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(rawArgb, hsv) + hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f) + hsv[2] = if (dark) 0.82f else 0.72f + return Color(android.graphics.Color.HSVToColor(hsv)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt new file mode 100644 index 0000000..bc1d498 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * Navigation drawer shared by every top-level calendar screen (M2/M3/M4 + * entry points). Stateless — the host screen owns the drawer state and wires + * the callbacks. + */ +@Composable +fun CalendarDrawer( + onToday: () -> Unit, + onJumpToDate: () -> Unit, + onFilter: () -> Unit, + onSettings: () -> Unit, +) { + ModalDrawerSheet { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), + ) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + NavigationDrawerItem( + label = { Text(stringResource(R.string.month_today_action)) }, + selected = false, + onClick = onToday, + modifier = Modifier.padding(horizontal = 12.dp), + ) + NavigationDrawerItem( + label = { Text(stringResource(R.string.month_action_jump_to_date)) }, + selected = false, + onClick = onJumpToDate, + modifier = Modifier.padding(horizontal = 12.dp), + ) + NavigationDrawerItem( + label = { Text(stringResource(R.string.month_action_filter)) }, + selected = false, + onClick = onFilter, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + NavigationDrawerItem( + label = { Text(stringResource(R.string.month_action_settings)) }, + selected = false, + onClick = onSettings, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt new file mode 100644 index 0000000..09a761e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFailure.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.calendula.ui.common + +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.foundation.layout.padding +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.FailureReason + +/** + * Full-screen failure state shared by every calendar screen (spec §7). + * One explanation line + one recovery action, never a toast. + */ +@Composable +fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { + val titleRes = when (reason) { + FailureReason.PermissionRevoked -> R.string.state_failure_permission + FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars + FailureReason.ProviderUnavailable -> R.string.state_failure_provider + FailureReason.Unknown, + FailureReason.EventNotFound -> R.string.state_failure_unknown + } + val actionRes = when (reason) { + FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action + FailureReason.PermissionRevoked -> R.string.state_failure_permission_action + else -> R.string.state_retry + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + FilledTonalButton(onClick = onRetry) { + Text(stringResource(actionRes)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt new file mode 100644 index 0000000..34e60b3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarTransitions.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.IntOffset + +/** + * The M3 Expressive spatial spring used for the month/week slide: the *fast* + * spring-physics spec from the active motion scheme — snappy with a subtle + * springy settle, rather than a fixed easing curve. + * + * Read it in a composable scope (this helper) so it can be captured by the + * non-composable `AnimatedContent` transitionSpec lambda. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun rememberCalendarSlideSpec(): FiniteAnimationSpec = + MaterialTheme.motionScheme.fastSpatialSpec() + +/** + * Horizontal slide for navigating between adjacent months/weeks. + * + * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump + * (e.g. "today"); a jump reuses the forward direction. + * @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. + */ +fun calendarSlideTransition( + slideDir: Int, + spec: FiniteAnimationSpec, +): ContentTransform { + val dir = if (slideDir == 0) 1 else slideDir + return slideInHorizontally(spec) { w -> dir * w } + .togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) +} 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 new file mode 100644 index 0000000..0df1adc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -0,0 +1,24 @@ +package de.jeanlucmakiola.calendula.ui.common + +/** + * The top-level calendar views the user can switch between (spec M1). + * Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS]. + */ +enum class CalendarView { + Month, + Week, + Day, +} + +/** + * Views that actually have a screen today. The view-switcher pill cycles + * through these in order; Day joins once its screen lands. + */ +val IMPLEMENTED_VIEWS: List = listOf(CalendarView.Month, CalendarView.Week) + +/** Next view in [available], wrapping around. Falls back to Month if absent. */ +fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { + val i = available.indexOf(this) + if (i < 0) return available.firstOrNull() ?: CalendarView.Month + return available[(i + 1) % available.size] +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt new file mode 100644 index 0000000..c3a72a4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/LocaleSupport.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.core.os.ConfigurationCompat +import java.util.Locale + +/** + * Current display [Locale], read observably from [LocalConfiguration] so the UI + * recomposes after a locale change (lint: NonObservableLocale). Used for + * weekday/month name formatting. + */ +@Composable +fun currentLocale(): Locale { + val configuration = LocalConfiguration.current + return remember(configuration) { + ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt new file mode 100644 index 0000000..5895e3d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ViewSwitcherPill.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R + +/** + * Top-bar pill that shows the current view and cycles to the next one on tap + * (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]). + */ +@Composable +fun ViewSwitcherPill( + current: CalendarView, + onCycle: () -> Unit, + modifier: Modifier = Modifier, +) { + val labelRes = when (current) { + CalendarView.Month -> R.string.view_month + CalendarView.Week -> R.string.view_week + CalendarView.Day -> R.string.view_day + } + FilledTonalButton( + onClick = onCycle, + shape = MaterialTheme.shapes.large, + modifier = modifier, + ) { + Text(stringResource(labelRes)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt deleted file mode 100644 index 7adbffa..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugScreen.kt +++ /dev/null @@ -1,164 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.debug - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -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.CalendarSource -import de.jeanlucmakiola.calendula.domain.EventInstance -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime - -@Composable -fun DebugScreen( - modifier: Modifier = Modifier, - viewModel: DebugViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - Column(modifier = modifier.fillMaxSize()) { - DebugBanner() - when (val s = state) { - DebugUiState.Loading -> LoadingContent() - is DebugUiState.Failure -> FailureContent() - is DebugUiState.Success -> SuccessContent(s) - } - } -} - -@Composable -private fun DebugBanner() { - Box( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.tertiaryContainer) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = stringResource(R.string.debug_banner), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } -} - -@Composable -private fun LoadingContent() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - -@Composable -private fun FailureContent() { - Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { - Text( - text = stringResource(R.string.state_failure_provider), - style = MaterialTheme.typography.bodyLarge, - ) - } -} - -@Composable -private fun SuccessContent(state: DebugUiState.Success) { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - item { SectionHeader(stringResource(R.string.debug_calendars_header)) } - if (state.calendars.isEmpty()) { - item { - Text( - text = stringResource(R.string.debug_no_calendars), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items(state.calendars, key = { "cal-${it.id}" }) { CalendarRow(it) } - } - - item { Spacer(Modifier.height(16.dp)) } - item { SectionHeader(stringResource(R.string.debug_events_header)) } - - if (state.nextEvents.isEmpty()) { - item { - Text( - text = stringResource(R.string.debug_no_events), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - items( - state.nextEvents, - // Recurring events share Instances._ID across occurrences, so - // include the start instant to keep the LazyColumn key unique. - key = { "evt-${it.instanceId}-${it.start.toEpochMilliseconds()}" }, - ) { EventRow(it) } - } - } -} - -@Composable -private fun SectionHeader(text: String) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - Text(text = text, style = MaterialTheme.typography.titleMedium) - HorizontalDivider() - } -} - -@Composable -private fun CalendarRow(cal: CalendarSource) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(12.dp) - .background(Color(cal.color), CircleShape), - ) - Text( - text = " ${cal.displayName} (${cal.accountName})", - style = MaterialTheme.typography.bodyMedium, - ) - } -} - -@Composable -private fun EventRow(event: EventInstance) { - val zone = TimeZone.currentSystemDefault() - val start = event.start.toLocalDateTime(zone) - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text(text = event.title, style = MaterialTheme.typography.bodyMedium) - val date = "%04d-%02d-%02d".format(start.year, start.month.ordinal + 1, start.day) - val time = "%02d:%02d".format(start.hour, start.minute) - Text( - text = "$date $time", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt deleted file mode 100644 index a05ee0a..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugUiState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.debug - -import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.FailureReason - -sealed interface DebugUiState { - data object Loading : DebugUiState - data class Failure(val reason: FailureReason) : DebugUiState - data class Success( - val calendars: List, - val nextEvents: List, - ) : DebugUiState -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt deleted file mode 100644 index ccd691b..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModel.kt +++ /dev/null @@ -1,49 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.debug - -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.FailureReason -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.stateIn -import kotlin.time.Duration.Companion.days -import kotlin.time.Instant -import javax.inject.Inject - -private const val MAX_DEBUG_EVENTS = 50 -private val DEBUG_WINDOW = 30.days - -@HiltViewModel -class DebugViewModel @Inject constructor( - private val repository: CalendarRepository, - @IoDispatcher private val io: CoroutineDispatcher, -) : ViewModel() { - - val state: StateFlow = run { - val now = Instant.fromEpochMilliseconds(System.currentTimeMillis()) - val range = now..(now + DEBUG_WINDOW) - combine( - repository.calendars(), - repository.instances(range), - ) { calendars, instances -> - DebugUiState.Success( - calendars = calendars, - nextEvents = instances.take(MAX_DEBUG_EVENTS), - ) as DebugUiState - } - .catch { emit(DebugUiState.Failure(FailureReason.ProviderUnavailable)) } - .flowOn(io) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = DebugUiState.Loading, - ) - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt new file mode 100644 index 0000000..d1d3d44 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -0,0 +1,464 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +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.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.ExperimentalMaterial3ExpressiveApi +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.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +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.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.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.currentLocale +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.common.pastelize +import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth +import kotlinx.datetime.plus +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MonthScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> Unit, + modifier: Modifier = Modifier, + viewModel: MonthViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val month by viewModel.month.collectAsStateWithLifecycle() + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + val isOnCurrentMonth = when (val s = state) { + is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month) + else -> true + } + + // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). + 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 month swipe. + gesturesEnabled = drawerState.isOpen, + drawerContent = { + CalendarDrawer( + onToday = { + jumpToToday() + scope.launch { drawerState.close() } + }, + onJumpToDate = { scope.launch { drawerState.close() } /* TODO: open date picker */ }, + onFilter = { scope.launch { drawerState.close() } /* TODO: open filter sheet */ }, + onSettings = { scope.launch { drawerState.close() } /* TODO: navigate to settings */ }, + ) + }, + ) { + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MonthTopBar( + month = month, + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next()) }, + onOpenDrawer = { scope.launch { drawerState.open() } }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = !isOnCurrentMonth, + enter = scaleIn(), + exit = scaleOut(), + ) { + ExtendedFloatingActionButton( + onClick = jumpToToday, + icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + text = { Text(stringResource(R.string.month_today_action)) }, + ) + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + WeekdayHeader(weekStart = DayOfWeek.MONDAY) + MonthContent( + state = state, + slideDir = slideDir, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + ) + } + } + } +} + +@Composable +private fun MonthContent( + state: MonthUiState, + slideDir: Int, + onSwipeNext: () -> Unit, + onSwipePrev: () -> Unit, + onRetry: () -> Unit, +) { + val density = LocalDensity.current + val threshold = with(density) { 6.dp.toPx() } + var dragAccum by remember { mutableFloatStateOf(0f) } + val slideSpec = rememberCalendarSlideSpec() + + 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.fillMaxSize().then(swipeModifier), + contentKey = { s -> + when (s) { + is MonthUiState.Success -> "success-${s.month}" + is MonthUiState.Failure -> "failure-${s.reason}" + MonthUiState.Loading -> "loading" + } + }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + label = "month-transition", + ) { s -> + when (s) { + MonthUiState.Loading -> MonthGridLoading() + is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is MonthUiState.Success -> MonthGrid(state = s, weekStart = DayOfWeek.MONDAY) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MonthTopBar( + month: YearMonth, + selectedView: CalendarView, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, +) { + TopAppBar( + title = { + Text( + text = formatMonthYear(month), + 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), + ) + }, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +private fun WeekdayHeader(weekStart: DayOfWeek) { + val locale = currentLocale() + val days = remember(weekStart, locale) { + (0 until 7).map { offset -> + DayOfWeek.entries[((weekStart.ordinal + offset) % 7)] + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + days.forEach { dow -> + val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY + val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) + Text( + text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), + style = MaterialTheme.typography.labelMedium, + color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun MonthGrid( + state: MonthUiState.Success, + weekStart: DayOfWeek, +) { + val firstOfMonth = LocalDate(state.month.year, state.month.month, 1) + val gridStart = firstOfMonth.startOfGridWeek(weekStart) + + // Show only the weeks the current month actually touches; leading/trailing + // days of neighbouring months are left blank rather than rendered. + val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + val daysInMonth = + java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth() + val weeks = (leadOffset + daysInMonth + 6) / 7 + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(weeks) { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(7) { col -> + val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY) + val inMonth = + date.month == state.month.month && date.year == state.month.year + if (inMonth) { + DayCard( + date = date, + isToday = date == state.today, + data = state.cells[date], + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DayCard( + date: LocalDate, + isToday: Boolean, + data: DayCellData?, + modifier: Modifier = Modifier, +) { + val todayPrefix = stringResource(R.string.month_a11y_today_prefix) + val cellLabel = buildString { + if (isToday) append(todayPrefix).append(", ") + append(date.year).append('-') + append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-') + append(date.day.toString().padStart(2, '0')) + data?.let { append(", ").append(it.count).append(" Events") } + } + + // M3 Expressive press feedback: a spatial spring from the active motion + // scheme drives a subtle scale, instead of a fixed easing curve. + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.94f else 1f, + animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + label = "day-card-press", + ) + + Card( + onClick = { /* TODO: open the day view (S3) for this date */ }, + interactionSource = interactionSource, + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .semantics { contentDescription = cellLabel }, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 4.dp, bottom = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + ) + Spacer(Modifier.height(2.dp)) + EventDotRow(data) + } + } +} + +@Composable +private fun EventDotRow(data: DayCellData?) { + if (data == null || data.swatches.isEmpty()) { + Spacer(Modifier.height(6.dp)) + return + } + val dark = isSystemInDarkTheme() + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + data.swatches.forEach { argb -> + Box( + modifier = Modifier + .size(6.dp) + .background(pastelize(argb, dark), CircleShape), + ) + } + if (data.count > data.swatches.size) { + Text( + text = "+${data.count - data.swatches.size}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun MonthGridLoading() { + val shape = MaterialTheme.shapes.medium + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(6) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = shape, + ), + ) + } + } + } + } +} + +private fun formatMonthYear(ym: YearMonth): String { + val locale = Locale.getDefault() + val name = java.time.Month.of(ym.month.ordinal + 1) + .getDisplayName(JavaTextStyle.FULL, locale) + return "$name ${ym.year}" +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt new file mode 100644 index 0000000..5cc13c3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.calendula.ui.month + +import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.LocalDate +import kotlinx.datetime.YearMonth + +/** + * Per-day aggregation surfaced to the month grid. We only need + * - the total event count (drives the optional "+N" indicator), and + * - up to three calendar colors for the dot row. + * + * The day cell never holds full event objects — the detail sheet pulls those + * lazily. + */ +data class DayCellData( + val count: Int, + val swatches: List, +) + +sealed interface MonthUiState { + data object Loading : MonthUiState + data class Failure(val reason: FailureReason) : MonthUiState + data class Success( + val month: YearMonth, + val today: LocalDate, + val cells: Map, + ) : MonthUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt new file mode 100644 index 0000000..9fbaa2d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -0,0 +1,128 @@ +package de.jeanlucmakiola.calendula.ui.month + +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 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.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +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 MonthViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val zone = TimeZone.currentSystemDefault() + + // V1: week starts Monday. DataStore-driven preference comes with Settings. + private val weekStart: DayOfWeek = DayOfWeek.MONDAY + + private val todayDate: LocalDate + get() = Clock.System.now().toLocalDateTime(zone).date + + private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month)) + val month: StateFlow = _month + + val state: StateFlow = _month + .flatMapLatest { ym -> + val range = monthGridRange(ym, weekStart, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(ym, calendars, instances) + } + } + .catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = MonthUiState.Loading, + ) + + fun goToPrev() { + _month.value = _month.value.minus(1, DateTimeUnit.MONTH) + } + + fun goToNext() { + _month.value = _month.value.plus(1, DateTimeUnit.MONTH) + } + + fun goToToday() { + _month.value = YearMonth(todayDate.year, todayDate.month) + } + + private fun buildState( + ym: YearMonth, + calendars: List, + instances: List, + ): MonthUiState { + if (calendars.isEmpty()) { + return MonthUiState.Failure(FailureReason.NoCalendarsConfigured) + } + val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date } + .mapValues { (_, evs) -> + DayCellData( + count = evs.size, + swatches = evs.map { it.color }.distinct().take(3), + ) + } + return MonthUiState.Success( + month = ym, + today = todayDate, + cells = byDay, + ) + } +} + +/** + * The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the + * trailing days of the previous month and the leading days of the next month. + */ +internal fun monthGridRange( + ym: YearMonth, + weekStart: DayOfWeek, + zone: TimeZone, +): ClosedRange { + val firstOfMonth = LocalDate(ym.year, ym.month, 1) + val gridStart = firstOfMonth.startOfGridWeek(weekStart) + val gridEnd = gridStart.plus(41, DateTimeUnit.DAY) + val start = gridStart.atStartOfDayIn(zone) + val end = gridEnd.atTime(23, 59, 59).toInstant(zone) + return start..end +} + +internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate { + // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. + val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + return minus(offset, DateTimeUnit.DAY) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt new file mode 100644 index 0000000..d0770fe --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -0,0 +1,631 @@ +package de.jeanlucmakiola.calendula.ui.week + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +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.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.Surface +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.graphics.Color +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.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.currentLocale +import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.common.pastelize +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WeekScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> Unit, + modifier: Modifier = Modifier, + viewModel: WeekViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle() + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + // The static header + all-day strip share 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 = "week-top-section-color", + ) + + val isOnCurrentWeek = when (val s = state) { + is WeekUiState.Success -> s.weekStart == s.today.startOfWeek(DayOfWeek.MONDAY) + else -> true + } + + // Slide direction for the week 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 week 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 = { + WeekTopBar( + weekStart = weekStart, + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next()) }, + onOpenDrawer = { scope.launch { drawerState.open() } }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = !isOnCurrentWeek, + enter = scaleIn(), + exit = scaleOut(), + ) { + ExtendedFloatingActionButton( + onClick = jumpToToday, + icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + text = { Text(stringResource(R.string.week_today_action)) }, + ) + } + }, + ) { innerPadding -> + WeekContent( + state = state, + slideDir = slideDir, + topSectionColor = topSectionColor, + onSwipeNext = goNext, + onSwipePrev = goPrev, + onRetry = jumpToToday, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } +} + +@Composable +private fun WeekContent( + state: WeekUiState, + 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() + + // Whole-page horizontal swipe. It sits one level above the timeline's + // vertical scroll: a horizontal drag only crosses *this* detector's slop, + // while a vertical drag is consumed by the inner scroll first — so the two + // gestures coexist without fighting. + 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 WeekUiState.Success -> "success-${s.weekStart}" + is WeekUiState.Failure -> "failure-${s.reason}" + WeekUiState.Loading -> "loading" + } + }, + transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, + label = "week-transition", + ) { s -> + when (s) { + WeekUiState.Loading -> WeekLoading() + is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) + is WeekUiState.Success -> WeekSuccess(state = s, topSectionColor = topSectionColor) + } + } +} + +@Composable +private fun WeekSuccess(state: WeekUiState.Success, topSectionColor: Color) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(topSectionColor) + .animateContentSize(), + ) { + WeekDayHeader(days = state.days, today = state.today) + AllDayStrip(state = state) + } + // Breathing room between the (colour-shifting) top section and the + // scrolling timeline below. + Spacer(Modifier.height(8.dp)) + Timeline(state = state) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WeekTopBar( + weekStart: LocalDate, + selectedView: CalendarView, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, +) { + TopAppBar( + title = { + Text( + text = formatWeekRange(weekStart), + 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), + ) + }, + // Match the static top section exactly: plain surface, lifting to + // surfaceContainer once content scrolls under the bar. + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = scrollBehavior, + ) +} + +@Composable +private fun WeekDayHeader(days: List, today: LocalDate) { + val locale = currentLocale() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + ) { + Spacer(Modifier.width(GUTTER_WIDTH)) + days.forEach { date -> + val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1) + val isToday = date == today + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(2.dp)) + // Always reserve the 28dp circle slot so the header height is + // identical whether or not the week contains today. + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + if (isToday) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.fillMaxSize(), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } else { + Text( + text = date.day.toString(), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } +} + +@Composable +private fun AllDayStrip(state: WeekUiState.Success) { + if (state.allDaySpans.isEmpty()) return + val dark = isSystemInDarkTheme() + val lanes = state.allDaySpans.maxOf { it.lane } + 1 + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + ) { + Box( + modifier = Modifier.width(GUTTER_WIDTH), + contentAlignment = Alignment.TopEnd, + ) { + Text( + text = stringResource(R.string.week_all_day), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.End, + modifier = Modifier.padding(top = 2.dp, end = 4.dp), + ) + } + // Span bars are positioned absolutely so a multi-day event is one + // connected bar across columns rather than a chip per day. + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .height(ALL_DAY_ROW_HEIGHT * lanes), + ) { + val colWidth = maxWidth / 7 + state.allDaySpans.forEach { span -> + val spanCols = span.endCol - span.startCol + 1 + AllDayBar( + event = span.event, + dark = dark, + modifier = Modifier + .offset( + x = colWidth * span.startCol, + y = ALL_DAY_ROW_HEIGHT * span.lane, + ) + .width(colWidth * spanCols) + .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: WeekUiState.Success) { + val totalHeight = HOUR_HEIGHT * 24 + val dark = isSystemInDarkTheme() + val density = LocalDensity.current + + Box(modifier = Modifier.fillMaxSize()) { + val scrollState = rememberScrollState() + + // Center the timeline on noon once the content is measured. Deriving the + // target from maxValue (known after layout) is reliable, unlike reading + // the viewport height during the first composition. + LaunchedEffect(scrollState) { + 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) + } + + // One scroll container for the whole timeline. Gutter and day columns + // are siblings inside it, so they share the exact same scroll position + // and can never drift apart. + Row( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + // Hour gutter + Column( + modifier = Modifier + .width(GUTTER_WIDTH) + .height(totalHeight), + ) { + (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 columns bundled in a rounded container so the whole block has + // soft corners (rounded at the day's start and end). + Box( + modifier = Modifier + .weight(1f) + .height(totalHeight) + .clip(RoundedCornerShape(16.dp)), + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.days.forEach { day -> + DayColumnCard( + blocks = state.timedByDay[day].orEmpty(), + dark = dark, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } + } + } + } +} + +@Composable +private fun DayColumnCard( + blocks: List, + dark: Boolean, + modifier: Modifier = Modifier, +) { + Card( + shape = MaterialTheme.shapes.small, + 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) 2 else 1, + overflow = TextOverflow.Ellipsis, + color = Color.Black.copy(alpha = 0.85f), + ) + if (showTime) { + Text( + text = timeLabel, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + color = Color.Black.copy(alpha = 0.6f), + ) + } + } + } +} + +@Composable +private fun WeekLoading() { + val totalHeight = HOUR_HEIGHT * 24 + val scrollState = rememberScrollState() + Column(modifier = Modifier.fillMaxSize()) { + // Header skeleton + Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 2.dp) + .height(36.dp) + .background( + MaterialTheme.colorScheme.surfaceContainer, + RoundedCornerShape(8.dp), + ), + ) + } + } + Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Spacer(Modifier.width(GUTTER_WIDTH)) + repeat(7) { + 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 formatWeekRange(weekStart: LocalDate): String { + val locale = Locale.getDefault() + val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) + val monthName = { d: LocalDate -> + java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale) + } + return if (weekStart.month == end.month && weekStart.year == end.year) { + "${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}" + } else if (weekStart.year == end.year) { + "${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}" + } else { + "${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " + + "${end.day}. ${monthName(end)} ${end.year}" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt new file mode 100644 index 0000000..d4a5993 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.ui.week + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.LocalDate + +/** + * One timed event clipped to a single day and assigned a horizontal lane so + * overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander + * aufgelöst"). + * + * @param startMin minutes from this day's midnight, clamped to [0, 1440] + * @param endMin minutes from this day's midnight, clamped to [startMin, 1440]; + * equal to [startMin] for instant events (render enforces a + * minimum tap-target height) + * @param lane 0-based column within [laneCount] + * @param laneCount number of columns the event's overlap-cluster needs + */ +data class TimedBlock( + val event: EventInstance, + val startMin: Int, + val endMin: Int, + val lane: Int, + val laneCount: Int, +) + +/** + * An all-day (or multi-day) event laid out as a single horizontal bar spanning + * [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping + * spans don't collide. A multi-day event is one connected bar — not one chip per + * day. + * + * @param startCol first visible covered column, 0..6 (clamped to the week) + * @param endCol last visible covered column, 0..6, inclusive + * @param lane 0-based stacking row + */ +data class AllDaySpan( + val event: EventInstance, + val startCol: Int, + val endCol: Int, + val lane: Int, +) + +sealed interface WeekUiState { + data object Loading : WeekUiState + data class Failure(val reason: FailureReason) : WeekUiState + data class Success( + val weekStart: LocalDate, + val today: LocalDate, + /** The seven days of the week, [weekStart] first. */ + val days: List, + /** All-day/multi-day events as connected horizontal spans. */ + val allDaySpans: List, + /** Timed events, clipped to each day with lanes resolved. */ + val timedByDay: Map>, + ) : WeekUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt new file mode 100644 index 0000000..182adfa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -0,0 +1,227 @@ +package de.jeanlucmakiola.calendula.ui.week + +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 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.DayOfWeek +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 + +const val MINUTES_PER_DAY: Int = 24 * 60 + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class WeekViewModel @Inject constructor( + private val repository: CalendarRepository, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val zone = TimeZone.currentSystemDefault() + + // V1: week starts Monday. DataStore-driven preference comes with Settings. + private val weekStart: DayOfWeek = DayOfWeek.MONDAY + + private val todayDate: LocalDate + get() = Clock.System.now().toLocalDateTime(zone).date + + private val _weekStartDate = MutableStateFlow(todayDate.startOfWeek(weekStart)) + val weekStartDate: StateFlow = _weekStartDate + + val state: StateFlow = _weekStartDate + .flatMapLatest { start -> + val range = weekRange(start, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(start, calendars, instances) + } + } + .catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = WeekUiState.Loading, + ) + + fun goToPrev() { + _weekStartDate.value = _weekStartDate.value.minus(7, DateTimeUnit.DAY) + } + + fun goToNext() { + _weekStartDate.value = _weekStartDate.value.plus(7, DateTimeUnit.DAY) + } + + fun goToToday() { + _weekStartDate.value = todayDate.startOfWeek(weekStart) + } + + private fun buildState( + start: LocalDate, + calendars: List, + instances: List, + ): WeekUiState { + if (calendars.isEmpty()) { + return WeekUiState.Failure(FailureReason.NoCalendarsConfigured) + } + val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } + val allDay = instances.filter { it.isAllDay } + val timed = instances.filterNot { it.isAllDay } + return WeekUiState.Success( + weekStart = start, + today = todayDate, + days = days, + allDaySpans = layoutAllDay(allDay, days, zone), + timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) }, + ) + } +} + +/** + * Lay out all-day events as connected horizontal spans across the visible week. + * Each event becomes one [AllDaySpan] from its first to its last covered column; + * overlapping spans are stacked on separate lanes (greedy first-fit by start). + */ +internal fun layoutAllDay( + events: List, + days: List, + zone: TimeZone, +): List { + data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int) + + val raw = events + .mapNotNull { ev -> + val covered = days.indices.filter { ev.coversDay(days[it], zone) } + if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) + } + .sortedWith(compareBy({ it.startCol }, { it.endCol })) + + val laneEnd = ArrayList() // last occupied column per lane + return raw.map { r -> + var lane = laneEnd.indexOfFirst { it < r.startCol } + if (lane == -1) { + laneEnd.add(r.endCol) + lane = laneEnd.size - 1 + } else { + laneEnd[lane] = r.endCol + } + AllDaySpan(r.event, r.startCol, r.endCol, lane) + } +} + +/** Beginning of the week (at [weekStart]) that contains this date. */ +internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate { + // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. + val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7 + return minus(offset, DateTimeUnit.DAY) +} + +/** Half-open instant range covering the seven days starting at [start]. */ +internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange { + val from = start.atStartOfDayIn(zone) + val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) + return from..to +} + +/** True if this event overlaps the calendar [day] in [zone] (any portion). */ +internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { + val dayStart = day.atStartOfDayIn(zone) + val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) + return start < dayEnd && end > dayStart +} + +/** + * Clip [events] to a single [day] and assign lanes so overlapping events render + * side-by-side. Lane count is computed per overlap-cluster (a maximal run of + * chained-overlapping events), matching the common phone week-view behaviour. + * + * All-day events are ignored here — they live in the all-day strip. + */ +internal fun layoutDay( + events: List, + day: LocalDate, + zone: TimeZone, +): List { + val dayStart = day.atStartOfDayIn(zone) + val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) + + data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int) + + val raw = events.asSequence() + .filterNot { it.isAllDay } + .mapNotNull { ev -> + if (ev.start == ev.end) { + // Instant event: keep only if the point falls inside this day. + if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null + val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY) + Raw(ev, m, m) + } else { + val s = maxOf(ev.start, dayStart) + val e = minOf(ev.end, dayEnd) + if (e <= s) return@mapNotNull null + val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY) + val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY) + Raw(ev, startMin, endMin) + } + } + .sortedWith(compareBy({ it.startMin }, { it.endMin })) + .toList() + + val result = ArrayList(raw.size) + var i = 0 + while (i < raw.size) { + // Grow a cluster of chained-overlapping events. + var clusterEnd = raw[i].endMin + var j = i + 1 + while (j < raw.size && raw[j].startMin < clusterEnd) { + clusterEnd = maxOf(clusterEnd, raw[j].endMin) + j++ + } + val cluster = raw.subList(i, j) + // Greedy first-fit column assignment (= max overlap depth in the cluster). + val laneEnd = ArrayList() + val lanes = IntArray(cluster.size) + cluster.forEachIndexed { k, r -> + var placed = laneEnd.indexOfFirst { it <= r.startMin } + if (placed == -1) { + laneEnd.add(r.endMin) + placed = laneEnd.size - 1 + } else { + laneEnd[placed] = r.endMin + } + lanes[k] = placed + } + val laneCount = laneEnd.size + cluster.forEachIndexed { k, r -> + result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount)) + } + i = j + } + return result +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 48d938e..3a830da 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -20,10 +20,26 @@ System-Einstellungen öffnen Erneut versuchen - - DEBUG — wird mit Plan 03 durch die Monatsansicht ersetzt - Kalender - Nächste 50 Termine - Keine Kalender eingerichtet. Füge einen über DAVx5 oder die System-Einstellungen hinzu. - Keine anstehenden Termine in den nächsten 30 Tagen. + + Vorheriger Monat + Nächster Monat + Heute + Weitere Aktionen + Menü öffnen + Kalender + Zu Datum springen… + Einstellungen + Heute + + + Ganztägig + Diese Woche + + + (Ohne Titel) + + + Monat + Woche + Tag diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fb8916..c938d2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,10 +21,26 @@ Open system settings Try again - - DEBUG — replaced by month view in Plan 03 - Calendars - Next 50 events - No calendars configured. Add one via DAVx5 or system settings. - No upcoming events in the next 30 days. + + Previous month + Next month + Today + More actions + Open menu + Calendars + Jump to date… + Settings + Today + + + All-day + This week + + + (No title) + + + Month + Week + Day diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt deleted file mode 100644 index 0098977..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/debug/DebugViewModelTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -package de.jeanlucmakiola.calendula.ui.debug - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository -import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException -import de.jeanlucmakiola.calendula.domain.CalendarSource -import de.jeanlucmakiola.calendula.domain.EventDetail -import de.jeanlucmakiola.calendula.domain.EventInstance -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.time.Instant -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class DebugViewModelTest { - - private val testDispatcher = UnconfinedTestDispatcher() - - @BeforeEach - fun setUp() { - Dispatchers.setMain(testDispatcher) - } - - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - private class FakeRepo( - val calendarsFlow: MutableStateFlow> = MutableStateFlow(emptyList()), - val instancesFlow: MutableStateFlow> = MutableStateFlow(emptyList()), - ) : CalendarRepository { - override fun calendars(): Flow> = calendarsFlow - override fun instances(range: ClosedRange): Flow> = instancesFlow - override suspend fun eventDetail(eventId: Long): EventDetail = - throw NoSuchEventException(eventId) - } - - private fun makeCal(id: Long, name: String = "C $id"): CalendarSource = - CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true) - - private fun makeEvent(id: Long, title: String = "E $id") = EventInstance( - instanceId = id, eventId = id, calendarId = 1L, - title = title, - start = Instant.fromEpochMilliseconds(0L), - end = Instant.fromEpochMilliseconds(60_000L), - isAllDay = false, color = 0xFF000000.toInt(), location = null, - ) - - @Test - fun `initial state value is Loading before any subscriber`() { - val repo = FakeRepo() - val vm = DebugViewModel(repo, testDispatcher) - assertThat(vm.state.value).isEqualTo(DebugUiState.Loading) - } - - @Test - fun `Success contains calendars and capped events after subscription`() = runTest { - val repo = FakeRepo( - calendarsFlow = MutableStateFlow(listOf(makeCal(1L))), - instancesFlow = MutableStateFlow(listOf(makeEvent(10L, "X"))), - ) - val vm = DebugViewModel(repo, testDispatcher) - vm.state.test { - val success = awaitItem() as DebugUiState.Success - assertThat(success.calendars.map { it.id }).containsExactly(1L) - assertThat(success.nextEvents.map { it.title }).containsExactly("X") - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `instances are capped at 50`() = runTest { - val repo = FakeRepo( - calendarsFlow = MutableStateFlow(listOf(makeCal(1L))), - instancesFlow = MutableStateFlow((1L..100L).map { makeEvent(it, "E$it") }), - ) - val vm = DebugViewModel(repo, testDispatcher) - vm.state.test { - val success = awaitItem() as DebugUiState.Success - assertThat(success.nextEvents).hasSize(50) - assertThat(success.nextEvents.first().instanceId).isEqualTo(1L) - assertThat(success.nextEvents.last().instanceId).isEqualTo(50L) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `state updates when repository emits new data`() = runTest { - val repo = FakeRepo() - val vm = DebugViewModel(repo, testDispatcher) - vm.state.test { - // Empty initial: combine fires once because both StateFlows have initial empty value - val empty = awaitItem() as DebugUiState.Success - assertThat(empty.calendars).isEmpty() - assertThat(empty.nextEvents).isEmpty() - - repo.calendarsFlow.value = listOf(makeCal(1L), makeCal(2L)) - val updated = awaitItem() as DebugUiState.Success - assertThat(updated.calendars.map { it.id }).containsExactly(1L, 2L).inOrder() - - cancelAndIgnoreRemainingEvents() - } - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt new file mode 100644 index 0000000..43fa21c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -0,0 +1,179 @@ +package de.jeanlucmakiola.calendula.ui.week + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class WeekLayoutTest { + + private val zone = TimeZone.UTC + + // 2026-06-10 is a Wednesday; its Monday-anchored week starts 2026-06-08. + private val wed = LocalDate(2026, 6, 10) + private val mon = LocalDate(2026, 6, 8) + private val weekDays = (0..6).map { mon.plusDays(it) } + + private fun at(date: LocalDate, h: Int, m: Int = 0): Instant = + date.atTime(h, m).toInstant(zone) + + private fun event( + start: Instant, + end: Instant, + allDay: Boolean = false, + id: Long = 1L, + title: String = "E", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = start, + end = end, + isAllDay = allDay, + color = 0xFF112233.toInt(), + location = null, + ) + + @Test + fun `startOfWeek snaps to monday`() { + assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon) + assertThat(mon.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon) + } + + @Test + fun `weekRange spans seven days`() { + val range = weekRange(mon, zone) + assertThat(range.start).isEqualTo(at(mon, 0, 0)) + // endInclusive is the last second of day 7 (Sunday 2026-06-14) + assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone)) + } + + @Test + fun `coversDay is true for any overlap and false otherwise`() { + val ev = event(at(wed, 9), at(wed, 10)) + assertThat(ev.coversDay(wed, zone)).isTrue() + assertThat(ev.coversDay(mon, zone)).isFalse() + + val multiDay = event(at(mon, 22), at(wed, 2), allDay = true) + assertThat(multiDay.coversDay(mon, zone)).isTrue() + assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue() + assertThat(multiDay.coversDay(wed, zone)).isTrue() + assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse() + } + + @Test + fun `single timed event gets one lane`() { + val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone) + assertThat(blocks).hasSize(1) + val b = blocks.single() + assertThat(b.startMin).isEqualTo(9 * 60) + assertThat(b.endMin).isEqualTo(10 * 60 + 30) + assertThat(b.lane).isEqualTo(0) + assertThat(b.laneCount).isEqualTo(1) + } + + @Test + fun `overlapping events resolve to side-by-side lanes`() { + val a = event(at(wed, 9), at(wed, 11), id = 1L) + val b = event(at(wed, 10), at(wed, 12), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane } + assertThat(blocks.map { it.lane }).containsExactly(0, 1) + assertThat(blocks.all { it.laneCount == 2 }).isTrue() + } + + @Test + fun `back-to-back events reuse one lane`() { + val a = event(at(wed, 9), at(wed, 10), id = 1L) + val b = event(at(wed, 10), at(wed, 11), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone) + assertThat(blocks).hasSize(2) + assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() + } + + @Test + fun `event spanning midnight is clipped to the day`() { + // Starts the previous evening, ends 02:00 on wed. + val ev = event(at(mon.plusDays(1), 22), at(wed, 2)) + val blocks = layoutDay(listOf(ev), wed, zone) + assertThat(blocks).hasSize(1) + assertThat(blocks.single().startMin).isEqualTo(0) + assertThat(blocks.single().endMin).isEqualTo(2 * 60) + } + + @Test + fun `instant event is kept with zero-length`() { + val ev = event(at(wed, 12), at(wed, 12)) + val blocks = layoutDay(listOf(ev), wed, zone) + assertThat(blocks).hasSize(1) + assertThat(blocks.single().startMin).isEqualTo(12 * 60) + assertThat(blocks.single().endMin).isEqualTo(12 * 60) + } + + @Test + fun `all-day events are excluded from the timed layout`() { + val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true) + assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty() + } + + @Test + fun `events on other days are dropped`() { + val ev = event(at(mon, 9), at(mon, 10)) + assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty() + } + + @Test + fun `single-day all-day event is a one-column span`() { + // Wed only: start Wed 00:00, end Thu 00:00. + val ev = event(at(weekDays[2], 0), at(weekDays[3], 0), allDay = true) + val spans = layoutAllDay(listOf(ev), weekDays, zone) + assertThat(spans).hasSize(1) + val s = spans.single() + assertThat(s.startCol).isEqualTo(2) + assertThat(s.endCol).isEqualTo(2) + assertThat(s.lane).isEqualTo(0) + } + + @Test + fun `multi-day all-day event becomes one span across columns`() { + // Tue..Thu: end Fri 00:00 is exclusive, so Fri is not covered. + val ev = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true) + val s = layoutAllDay(listOf(ev), weekDays, zone).single() + assertThat(s.startCol).isEqualTo(1) + assertThat(s.endCol).isEqualTo(3) + } + + @Test + fun `span reaching outside the week is clamped to visible columns`() { + // Starts two days before Monday, ends Wed 00:00 → covers Mon..Tue. + val ev = event(at(mon.plusDays(-2), 0), at(weekDays[2], 0), allDay = true) + val s = layoutAllDay(listOf(ev), weekDays, zone).single() + assertThat(s.startCol).isEqualTo(0) + assertThat(s.endCol).isEqualTo(1) + } + + @Test + fun `overlapping all-day spans get separate lanes`() { + val a = event(at(weekDays[0], 0), at(weekDays[3], 0), allDay = true, id = 1L) // Mon..Wed + val b = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Tue..Thu + val spans = layoutAllDay(listOf(a, b), weekDays, zone) + assertThat(spans.map { it.lane }.toSet()).isEqualTo(setOf(0, 1)) + } + + @Test + fun `disjoint all-day spans reuse one lane`() { + val a = event(at(weekDays[0], 0), at(weekDays[1], 0), allDay = true, id = 1L) // Mon + val b = event(at(weekDays[3], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Thu + val spans = layoutAllDay(listOf(a, b), weekDays, zone) + assertThat(spans.all { it.lane == 0 }).isTrue() + } + + private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3bd04ac..592e972 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man # Material 3 (Expressive lives in this artifact for 1.5+) androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }