Compare commits

...

3 Commits

Author SHA1 Message Date
efa0abbaed feat(detail): full-screen event detail (S4) with humanized recurrence
Some checks failed
CI / ci (push) Failing after 6m55s
Build and Release to F-Droid / ci (push) Successful in 7m48s
Build and Release to F-Droid / build-and-deploy (push) Successful in 9m40s
Tapping an event in the week/day timeline opens a full-screen detail
destination (MD3 list→detail, not a bottom sheet) overlaying the calendar
with a slide transition. One card per field (when, calendar, location,
description, attendees, recurrence) with leading icons; location taps open
a maps intent. Loading/Failure/Success throughout.

Recurrence is humanized from the RRULE — e.g. "Every week on Tue and Thu
until 31 Dec 2026" — covering FREQ/INTERVAL/BYDAY/UNTIL/COUNT with
abbreviated, italicised day names and localized list formatting, falling
back to a generic label for rules it can't render.

Also:
- fix: recurring events failed to open (series row stores DURATION, not
  DTEND, so the mapper dropped them as EventNotFound). The detail keeps
  them and shows the tapped occurrence's own times from Instances.
- feat: month day cell → opens the day view anchored to that date.
- build: add material-icons-extended (R8 strips unused icons in release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:52:35 +02:00
d3fbe28843 docs: record v0.3.0 (month/week/day views, view switcher) in CHANGELOG
All checks were successful
CI / ci (push) Successful in 10m49s
Build and Release to F-Droid / ci (push) Successful in 6m16s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m43s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:44:22 +02:00
951fb640a6 feat(day): single-column day view, wire into view switcher
Day view as a one-column slice of the week view: shared TimedBlock/
AllDaySpan layout, per-day swipe navigation, hoisted noon-centred scroll,
animated all-day strip, and a compact top bar showing the full date.

- DayUiState / DayViewModel / DayScreen under ui/day
- reuse layoutDay/layoutAllDay/coversDay from the week package
- add Day to IMPLEMENTED_VIEWS; CalendarHost routes it explicitly
- day_today_action strings (en/de)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:40:02 +02:00
16 changed files with 1602 additions and 25 deletions

View File

@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] — 2026-06-10
### Added
- Event detail (S4): full-screen destination (MD3 list→detail, not a bottom
sheet) opened by tapping an event in the week/day timeline — title with a
calendar-colour accent line, a card per field (when, calendar, location,
description, attendees, recurrence) with leading icons, location tap opens a
maps intent, Loading/Failure/Success states, slide-in/out over the calendar
- Human-readable recurrence: RRULE rendered as e.g. "Every week on _Tue_ and
_Thu_ until 31 Dec 2026" (FREQ/INTERVAL/BYDAY/UNTIL/COUNT, abbreviated +
italicised day names, localized list formatting), with a generic fallback
- Month → day navigation: tapping a day cell opens the day view on that date
### Fixed
- Recurring events failed to open in the detail view: the series row stores
DURATION instead of DTEND, so the mapper dropped it (EventNotFound). The
detail now keeps such events and shows the tapped occurrence's own times
(from CalendarContract.Instances) instead of the series start
## [0.3.0] — 2026-06-10
### 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"
@@ -16,7 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- Day view (S3): single-column slice of the week schedule reusing its
overlap-lane layout, per-day swipe navigation, noon-centred scroll that
persists across swipes, animated all-day strip, compact top bar with the
full date, Loading/Failure/Success states
- Functional view-switcher (M1) cycling Month ↔ Week ↔ Day
- Shared calendar UI building blocks in `ui/common/` (navigation drawer,
failure screen, view-switcher pill, color pastelizer, observable locale)

View File

@@ -99,6 +99,7 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)

View File

@@ -11,15 +11,25 @@ private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
val end = getLong(EventDetailProjection.IDX_DTEND)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
return null
}
if (end < begin) {
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
return null
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
} else {
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
if (rawEnd < begin) {
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
return null
}
rawEnd
}
val rawTitle = getString(EventDetailProjection.IDX_TITLE)

View File

@@ -1,14 +1,27 @@
package de.jeanlucmakiola.calendula.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
@@ -20,17 +33,69 @@ 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,
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
// it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onEventClick: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
)
}
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
activeKey?.let { key ->
EventDetailScreen(
eventId = key[0],
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
)
}
}
}
}

View File

@@ -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<CalendarView> = listOf(CalendarView.Month, CalendarView.Week)
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {

View File

@@ -0,0 +1,582 @@
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.clickable
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,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
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,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> 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,
onEventClick = onEventClick,
)
}
}
}
@Composable
private fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
) {
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,
onEventClick = onEventClick,
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, onEventClick = onEventClick)
}
}
@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,
onEventClick: (EventInstance) -> Unit,
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,
onClick = { onEventClick(span.event) },
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,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.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,
onEventClick: (EventInstance) -> Unit,
) {
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,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
onEventClick: (EventInstance) -> Unit,
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,
onClick = { onEventClick(block.event) },
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,
onClick: () -> Unit,
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))
.clickable(onClick = onClick)
.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}"
}

View File

@@ -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<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}

View File

@@ -0,0 +1,111 @@
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<LocalDate> = _date
val state: StateFlow<DayUiState> = _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
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): 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<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}

View File

@@ -0,0 +1,536 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.icu.text.ListFormatter
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.ColumnScope
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.layout.width
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
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.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.datetime.TimeZone
import java.time.DayOfWeek
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Read-only full-screen event detail (spec S4, realised as a navigation
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
* gesture and the top-bar arrow both return to the calendar. The only action is
* tapping the location to open a maps intent.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
eventId: Long,
beginMillis: Long,
endMillis: Long,
onBack: () -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
viewModel.open(eventId, beginMillis, endMillis)
}
val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.event_detail_back),
)
}
},
actions = {
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = stringResource(R.string.event_detail_edit),
)
}
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
val contentModifier = Modifier
.fillMaxSize()
.padding(innerPadding)
when (val s = state) {
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason,
onRetry = viewModel::retry,
)
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
}
}
}
@Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark)
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title with a short accent line in the calendar colour underneath.
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
// Every piece of info shares one card design: a tonal container with a
// leading icon in the gutter and the value to the right. 12dp gaps stack
// them cleanly.
val gap = 12.dp
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
Spacer(Modifier.height(2.dp))
Text(
text = whenSecondary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed.
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.CalendarMonth,
iconTint = accent,
iconContentDescription = stringResource(R.string.event_detail_calendar),
) {
Text(
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium,
)
}
// Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
Text(
text = location,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable { openInMaps(context, location) }
.padding(vertical = 2.dp),
)
}
}
// Description (conditional).
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = stringResource(R.string.event_detail_description),
) {
Text(text = description, style = MaterialTheme.typography.bodyMedium)
}
}
// Attendees (conditional).
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees),
) {
attendees.forEach { AttendeeRow(it) }
}
}
// Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence),
) {
Text(
text = recurrenceText(rrule, locale),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
@Composable
private fun AttendeeRow(attendee: Attendee) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(attendeeStatusLabel(attendee.status)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun EventDetailLoading(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
Spacer(Modifier.height(24.dp))
SkeletonBar(widthFraction = 1f, height = 64.dp)
Spacer(Modifier.height(28.dp))
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
Spacer(Modifier.height(8.dp))
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
Spacer(Modifier.height(24.dp))
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
Spacer(Modifier.height(8.dp))
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
}
}
@Composable
private fun SkeletonBar(widthFraction: Float, height: Dp) {
Box(
modifier = Modifier
.fillMaxWidth(widthFraction)
.height(height)
.background(
MaterialTheme.colorScheme.surfaceContainerHighest,
RoundedCornerShape(8.dp),
),
)
}
// --- helpers -------------------------------------------------------------
@Composable
private fun currentDetailLocale(): Locale {
val config = LocalContext.current.resources.configuration
return config.locales[0] ?: Locale.getDefault()
}
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
AttendeeStatus.Declined -> R.string.event_attendee_declined
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
}
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.).
*/
@Composable
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a
* single primary line spanning both ends.
*/
@Composable
private fun formatWhen(
instance: EventInstance,
zone: TimeZone,
locale: Locale,
): Pair<String, String?> {
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day end is the exclusive next midnight; step back to the last
// covered day so a one-day event reads as a single date.
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
allDayLabel to dateFull.format(startLdt.toLocalDate())
} else {
allDayLabel to
"${dateMedium.format(startLdt.toLocalDate())} ${dateMedium.format(lastLdt.toLocalDate())}"
}
}
val endLdt = instance.end.toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to
"${timeShort.format(startLdt)} ${timeShort.format(endLdt)}"
} else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
"$start $end" to null
}
}
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
private fun openInMaps(context: Context, query: String) {
val encoded = Uri.encode(query)
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
try {
context.startActivity(geo)
} catch (e: ActivityNotFoundException) {
val web = Intent(
Intent.ACTION_VIEW,
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
)
try {
context.startActivity(web)
} catch (e2: ActivityNotFoundException) {
// No browser either — nothing sensible to do; swallow.
}
}
}

View File

@@ -0,0 +1,17 @@
package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* UI state for the event-detail bottom sheet (spec S4). Read-only in V1.
*/
sealed interface EventDetailUiState {
data object Loading : EventDetailUiState
data class Failure(val reason: FailureReason) : EventDetailUiState
data class Success(
val detail: EventDetail,
/** Display name of the owning calendar, null if it can't be resolved. */
val calendarName: String?,
) : EventDetailUiState
}

View File

@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.detail
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.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
/**
* Loads a single event's detail on demand for the bottom sheet (spec S4).
* The event id is set via [open]; the sheet observes [state].
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
// Bumped by retry() to re-run the load for the same target.
private val _reload = MutableStateFlow(0)
val state: StateFlow<EventDetailUiState> =
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
if (target == null) {
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
} else {
flow {
emit(EventDetailUiState.Loading)
emit(loadDetail(target))
}
}
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading,
)
/**
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
* the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct
* date instead of the first occurrence.
*/
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
_target.value = Target(eventId, beginMillis, endMillis)
}
/** Re-run the current load after a failure. */
fun retry() {
_reload.value += 1
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly.
val corrected = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
val calendarName = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
?.displayName
EventDetailUiState.Success(corrected, calendarName)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
EventDetailUiState.Failure(FailureReason.EventNotFound)
} catch (e: SecurityException) {
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
} catch (e: Exception) {
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
}
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
}

View File

@@ -84,6 +84,7 @@ import java.util.Locale
fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
@@ -168,6 +169,7 @@ fun MonthScreen(
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
}
}
@@ -181,6 +183,7 @@ private fun MonthContent(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
) {
val density = LocalDensity.current
val threshold = with(density) { 6.dp.toPx() }
@@ -218,7 +221,11 @@ private fun MonthContent(
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(state = s, weekStart = DayOfWeek.MONDAY)
is MonthUiState.Success -> MonthGrid(
state = s,
weekStart = DayOfWeek.MONDAY,
onOpenDay = onOpenDay,
)
}
}
}
@@ -290,6 +297,7 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
private fun MonthGrid(
state: MonthUiState.Success,
weekStart: DayOfWeek,
onOpenDay: (LocalDate) -> Unit,
) {
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
@@ -323,6 +331,7 @@ private fun MonthGrid(
date = date,
isToday = date == state.today,
data = state.cells[date],
onClick = { onOpenDay(date) },
modifier = Modifier.weight(1f),
)
} else {
@@ -340,6 +349,7 @@ private fun DayCard(
date: LocalDate,
isToday: Boolean,
data: DayCellData?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
@@ -362,7 +372,7 @@ private fun DayCard(
)
Card(
onClick = { /* TODO: open the day view (S3) for this date */ },
onClick = onClick,
interactionSource = interactionSource,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(

View File

@@ -7,6 +7,7 @@ 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.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -111,6 +112,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
@@ -188,6 +190,7 @@ fun WeekScreen(
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -204,6 +207,7 @@ private fun WeekContent(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
@@ -274,6 +278,7 @@ private fun WeekContent(
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
)
}
}
@@ -285,6 +290,7 @@ private fun WeekSuccess(
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -293,12 +299,12 @@ private fun WeekSuccess(
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
AllDayStrip(state = state, height = allDayHeight)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState)
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
}
}
@@ -433,7 +439,11 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
}
@Composable
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
private fun AllDayStrip(
state: WeekUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
) {
val dark = isSystemInDarkTheme()
Row(
@@ -461,6 +471,7 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(
x = colWidth * span.startCol,
@@ -476,11 +487,17 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
}
@Composable
private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier = Modifier) {
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
@@ -496,7 +513,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
}
@Composable
private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
@@ -551,6 +572,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
onEventClick = onEventClick,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
@@ -566,6 +588,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
@@ -587,6 +610,7 @@ private fun DayColumnCard(
EventBlock(
block = block,
dark = dark,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
@@ -602,6 +626,7 @@ private fun DayColumnCard(
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
@@ -610,6 +635,7 @@ private fun EventBlock(
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
) {

View File

@@ -35,6 +35,39 @@
<string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string>
<!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
<string name="event_detail_delete">Löschen</string>
<string name="event_detail_all_day">Ganztägig</string>
<string name="event_detail_calendar">Kalender</string>
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
<string name="event_detail_location">Ort</string>
<string name="event_detail_description">Beschreibung</string>
<string name="event_detail_attendees">Teilnehmer</string>
<string name="event_detail_recurrence">Wiederholung</string>
<string name="event_detail_recurring">Wiederkehrender Termin</string>
<string name="recurrence_daily">Jeden Tag</string>
<string name="recurrence_weekly">Jede Woche</string>
<string name="recurrence_monthly">Jeden Monat</string>
<string name="recurrence_yearly">Jedes Jahr</string>
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
<string name="recurrence_on_days">%1$s am %2$s</string>
<string name="recurrence_with_until">%1$s bis %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
<string name="event_attendee_accepted">Zugesagt</string>
<string name="event_attendee_declined">Abgesagt</string>
<string name="event_attendee_tentative">Vorläufig</string>
<string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>

View File

@@ -36,6 +36,39 @@
<string name="week_today_action">This week</string>
<string name="week_number_label">Wk</string>
<!-- Day view (S3) -->
<string name="day_today_action">Today</string>
<!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string>
<string name="event_detail_edit">Edit</string>
<string name="event_detail_delete">Delete</string>
<string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_location">Location</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string>
<string name="event_detail_recurrence">Recurrence</string>
<string name="event_detail_recurring">Repeating event</string>
<string name="recurrence_daily">Every day</string>
<string name="recurrence_weekly">Every week</string>
<string name="recurrence_monthly">Every month</string>
<string name="recurrence_yearly">Every year</string>
<string name="recurrence_every_n_days">Every %1$d days</string>
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
<string name="recurrence_every_n_months">Every %1$d months</string>
<string name="recurrence_every_n_years">Every %1$d years</string>
<string name="recurrence_on_days">%1$s on %2$s</string>
<string name="recurrence_with_until">%1$s until %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d times</string>
<string name="event_detail_not_found">This event no longer exists.</string>
<string name="event_attendee_accepted">Accepted</string>
<string name="event_attendee_declined">Declined</string>
<string name="event_attendee_tentative">Tentative</string>
<string name="event_attendee_needs_action">No response</string>
<string name="event_attendee_unknown"></string>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>

View File

@@ -42,6 +42,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" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }