From efa0abbaed31186a5254543b0b4576761f3e84dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 10 Jun 2026 21:52:35 +0200 Subject: [PATCH] feat(detail): full-screen event detail (S4) with humanized recurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 19 + app/build.gradle.kts | 1 + .../data/calendar/EventDetailMapper.kt | 18 +- .../calendula/ui/CalendarHost.kt | 90 ++- .../calendula/ui/day/DayScreen.kt | 36 +- .../calendula/ui/day/DayViewModel.kt | 5 + .../calendula/ui/detail/EventDetailScreen.kt | 536 ++++++++++++++++++ .../calendula/ui/detail/EventDetailUiState.kt | 17 + .../ui/detail/EventDetailViewModel.kt | 101 ++++ .../calendula/ui/month/MonthScreen.kt | 14 +- .../calendula/ui/week/WeekScreen.kt | 36 +- app/src/main/res/values-de/strings.xml | 30 + app/src/main/res/values/strings.xml | 30 + gradle/libs.versions.toml | 1 + 14 files changed, 905 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt 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" }