feat(detail): full-screen event detail (S4) with humanized recurrence

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>
This commit is contained in:
2026-06-10 21:52:35 +02:00
parent d3fbe28843
commit 03ffa3b6ba
14 changed files with 905 additions and 29 deletions

View File

@@ -7,6 +7,25 @@ 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

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,15 +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
@@ -21,21 +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,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
modifier = modifier,
)
CalendarView.Month -> 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

@@ -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.Box
@@ -105,12 +106,19 @@ private fun DayUiState.Success.allDayStripHeight(): Dp {
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()
@@ -182,6 +190,7 @@ fun DayScreen(
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
@@ -198,6 +207,7 @@ private fun DayContent(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
@@ -265,6 +275,7 @@ private fun DayContent(
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
)
}
}
@@ -276,6 +287,7 @@ private fun DaySuccess(
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,
@@ -283,6 +295,7 @@ private fun DaySuccess(
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
@@ -290,7 +303,7 @@ private fun DaySuccess(
// 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)
}
}
@@ -337,6 +350,7 @@ private fun DayTopBar(
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
@@ -364,6 +378,7 @@ private fun AllDayStrip(
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
@@ -376,11 +391,17 @@ private fun AllDayStrip(
}
@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,
@@ -396,7 +417,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
}
@Composable
private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
@@ -443,6 +468,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
DayColumnCard(
blocks = state.timed,
dark = dark,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
@@ -456,6 +482,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
@@ -477,6 +504,7 @@ private fun DayColumnCard(
EventBlock(
block = block,
dark = dark,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
@@ -492,6 +520,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) }
@@ -500,6 +529,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

@@ -78,6 +78,11 @@ class DayViewModel @Inject constructor(
_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>,

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

@@ -38,6 +38,36 @@
<!-- 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

@@ -39,6 +39,36 @@
<!-- 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" }