diff --git a/CHANGELOG.md b/CHANGELOG.md index f926f77..37c869c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5168a47..67b2b71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index c07f44e..90c8ffa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -11,15 +11,25 @@ private const val TAG = "EventDetailMapper" internal fun ColumnReader.toEventDetailCore(attendees: List): 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 74a998a..f1f172b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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(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(null) } + var heldKey by remember { mutableStateOf(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 }, + ) + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index e37fefa..a6b271e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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, 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" }, ) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt index 85bc817..961d0c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayViewModel.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt new file mode 100644 index 0000000..d4dd3c8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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 → " on "; 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? = 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 { + 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. + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt new file mode 100644 index 0000000..725cce1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt new file mode 100644 index 0000000..c7282c5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -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(null) + // Bumped by retry() to re-run the load for the same target. + private val _reload = MutableStateFlow(0) + + val state: StateFlow = + combine(_target, _reload) { target, _ -> target } + .flatMapLatest { target -> + if (target == null) { + flowOf(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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index d1d3d44..ff1541c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 368cfa1..dd3b94e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, 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" }, ) { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2b41883..955ec9c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -38,6 +38,36 @@ Heute + + Zurück + Bearbeiten + Löschen + Ganztägig + Kalender + Unbekannter Kalender + Ort + Beschreibung + Teilnehmer + Wiederholung + Wiederkehrender Termin + Jeden Tag + Jede Woche + Jeden Monat + Jedes Jahr + Alle %1$d Tage + Alle %1$d Wochen + Alle %1$d Monate + Alle %1$d Jahre + %1$s am %2$s + %1$s bis %2$s + %1$s, %2$d Mal + Dieser Termin existiert nicht mehr. + Zugesagt + Abgesagt + Vorläufig + Keine Antwort + + (Ohne Titel) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fe14e9..eb3e6d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,36 @@ Today + + Back + Edit + Delete + All day + Calendar + Unknown calendar + Location + Description + Attendees + Recurrence + Repeating event + Every day + Every week + Every month + Every year + Every %1$d days + Every %1$d weeks + Every %1$d months + Every %1$d years + %1$s on %2$s + %1$s until %2$s + %1$s, %2$d times + This event no longer exists. + Accepted + Declined + Tentative + No response + + (No title) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 592e972..6ffc1ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }