Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efa0abbaed |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.3.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.compose.material.icons.core)
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
|||||||
@@ -11,16 +11,26 @@ private const val TAG = "EventDetailMapper"
|
|||||||
|
|
||||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
val end = getLong(EventDetailProjection.IDX_DTEND)
|
|
||||||
|
|
||||||
if (begin < 0L) {
|
if (begin < 0L) {
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (end < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$end < dtstart=$begin")
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
rawEnd
|
||||||
|
}
|
||||||
|
|
||||||
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
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.day.DayScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
* 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) }
|
var view by rememberSaveable { mutableStateOf(CalendarView.Month) }
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||||
|
|
||||||
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
|
pendingDayIso = date.toString()
|
||||||
|
view = CalendarView.Day
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
|
// it overlays whichever calendar view is active. We forward the tapped
|
||||||
|
// occurrence's own times (eventId + begin + end, packed as a saveable
|
||||||
|
// long[]) so recurring events show the correct date, not the series start.
|
||||||
|
// [heldKey] keeps the last shown key alive through the slide-out (when
|
||||||
|
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
|
||||||
|
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||||
|
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||||
|
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||||
|
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||||
|
val key = longArrayOf(
|
||||||
|
event.eventId,
|
||||||
|
event.start.toEpochMilliseconds(),
|
||||||
|
event.end.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
heldKey = key
|
||||||
|
detailKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (view) {
|
when (view) {
|
||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
modifier = modifier,
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
modifier = modifier,
|
onEventClick = onEventClick,
|
||||||
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
modifier = modifier,
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateDpAsState
|
|||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -105,12 +106,19 @@ private fun DayUiState.Success.allDayStripHeight(): Dp {
|
|||||||
fun DayScreen(
|
fun DayScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val date by viewModel.date.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 scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -182,6 +190,7 @@ fun DayScreen(
|
|||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -198,6 +207,7 @@ private fun DayContent(
|
|||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -265,6 +275,7 @@ private fun DayContent(
|
|||||||
topSectionColor = topSectionColor,
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +287,7 @@ private fun DaySuccess(
|
|||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -283,6 +295,7 @@ private fun DaySuccess(
|
|||||||
AllDayStrip(
|
AllDayStrip(
|
||||||
state = state,
|
state = state,
|
||||||
height = allDayHeight,
|
height = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(topSectionColor),
|
||||||
@@ -290,7 +303,7 @@ private fun DaySuccess(
|
|||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
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(
|
private fun AllDayStrip(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -364,6 +378,7 @@ private fun AllDayStrip(
|
|||||||
AllDayBar(
|
AllDayBar(
|
||||||
event = span.event,
|
event = span.event,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onClick = { onEventClick(span.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
||||||
.width(barWidth)
|
.width(barWidth)
|
||||||
@@ -376,11 +391,17 @@ private fun AllDayStrip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
@@ -396,7 +417,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
@@ -443,6 +468,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight),
|
||||||
@@ -456,6 +482,7 @@ private fun Timeline(state: DayUiState.Success, scrollState: ScrollState) {
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
@@ -477,6 +504,7 @@ private fun DayColumnCard(
|
|||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
.width(laneWidth)
|
.width(laneWidth)
|
||||||
@@ -492,6 +520,7 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -500,6 +529,7 @@ private fun EventBlock(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class DayViewModel @Inject constructor(
|
|||||||
_date.value = todayDate
|
_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(
|
private fun buildState(
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.icu.text.ListFormatter
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only full-screen event detail (spec S4, realised as a navigation
|
||||||
|
* destination rather than a bottom sheet — MD3 list→detail pattern). Back
|
||||||
|
* gesture and the top-bar arrow both return to the calendar. The only action is
|
||||||
|
* tapping the location to open a maps intent.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EventDetailScreen(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
|
viewModel.open(eventId, beginMillis, endMillis)
|
||||||
|
}
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { /* TODO: edit event (V2) */ }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { /* TODO: delete event (V2) */ }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
val contentModifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
when (val s = state) {
|
||||||
|
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
|
||||||
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
|
reason = s.reason,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
)
|
||||||
|
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||||
|
val detail = state.detail
|
||||||
|
val instance = detail.instance
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val locale = currentDetailLocale()
|
||||||
|
val accent = pastelize(instance.color, dark)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
|
) {
|
||||||
|
// Title with a short accent line in the calendar colour underneath.
|
||||||
|
Text(
|
||||||
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(48.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Every piece of info shares one card design: a tonal container with a
|
||||||
|
// leading icon in the gutter and the value to the right. 12dp gaps stack
|
||||||
|
// them cleanly.
|
||||||
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// "When" — date/all-day plus the time range.
|
||||||
|
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||||
|
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||||
|
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (whenSecondary != null) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = whenSecondary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
|
// separate colour dot is needed.
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.CalendarMonth,
|
||||||
|
iconTint = accent,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location (conditional, tap → maps).
|
||||||
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
|
val context = LocalContext.current
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Place,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = location,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { openInMaps(context, location) }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (conditional).
|
||||||
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
|
) {
|
||||||
|
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendees (conditional).
|
||||||
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.People,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||||
|
) {
|
||||||
|
attendees.forEach { AttendeeRow(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
DetailCard(
|
||||||
|
icon = Icons.Default.Repeat,
|
||||||
|
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = recurrenceText(rrule, locale),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||||
|
@Composable
|
||||||
|
private fun DetailCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconContentDescription: String?,
|
||||||
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = iconContentDescription,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f), content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AttendeeRow(attendee: Attendee) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 3.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||||
|
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SkeletonBar(widthFraction = 1f, height = 64.dp)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(widthFraction)
|
||||||
|
.height(height)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
RoundedCornerShape(8.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun currentDetailLocale(): Locale {
|
||||||
|
val config = LocalContext.current.resources.configuration
|
||||||
|
return config.locales[0] ?: Locale.getDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||||
|
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||||
|
AttendeeStatus.Declined -> R.string.event_attendee_declined
|
||||||
|
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
|
||||||
|
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
|
||||||
|
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||||
|
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||||
|
* Falls back to a generic label for rules we don't render in full (ordinal
|
||||||
|
* monthly/yearly BYDAY, etc.).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||||
|
val eq = token.indexOf('=')
|
||||||
|
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val freq = parts["FREQ"]?.uppercase()
|
||||||
|
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||||
|
val base = when (freq) {
|
||||||
|
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
|
||||||
|
else stringResource(R.string.recurrence_every_n_days, interval)
|
||||||
|
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_weeks, interval)
|
||||||
|
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_months, interval)
|
||||||
|
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
|
||||||
|
else stringResource(R.string.recurrence_every_n_years, interval)
|
||||||
|
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
|
||||||
|
// The day names + their joined block are tracked so only the names (not the
|
||||||
|
// commas/conjunction) can be italicised in the final string.
|
||||||
|
val byDay = parts["BYDAY"]
|
||||||
|
var dayNames: List<String>? = null
|
||||||
|
var joinedDays: String? = null
|
||||||
|
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
|
||||||
|
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
|
||||||
|
if (days.isNotEmpty()) {
|
||||||
|
val joined = ListFormatter.getInstance(locale).format(days)
|
||||||
|
dayNames = days
|
||||||
|
joinedDays = joined
|
||||||
|
stringResource(R.string.recurrence_on_days, base, joined)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
|
||||||
|
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
|
||||||
|
val count = parts["COUNT"]?.toIntOrNull()
|
||||||
|
val full = when {
|
||||||
|
until != null -> stringResource(R.string.recurrence_with_until, main, until)
|
||||||
|
count != null -> stringResource(R.string.recurrence_with_count, main, count)
|
||||||
|
else -> main
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
append(full)
|
||||||
|
val names = dayNames
|
||||||
|
val joined = joinedDays
|
||||||
|
if (names != null && joined != null) {
|
||||||
|
// Italicise each day name within the joined block only — leaving the
|
||||||
|
// separators and conjunction ("und"/"and") in the regular style.
|
||||||
|
val regionStart = full.indexOf(joined)
|
||||||
|
if (regionStart >= 0) {
|
||||||
|
val regionEnd = regionStart + joined.length
|
||||||
|
var cursor = regionStart
|
||||||
|
for (name in names) {
|
||||||
|
val at = full.indexOf(name, cursor)
|
||||||
|
if (at in regionStart until regionEnd) {
|
||||||
|
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
|
||||||
|
cursor = at + name.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
|
||||||
|
private fun rruleDayName(token: String, locale: Locale): String? {
|
||||||
|
val dow = when (token.takeLast(2).uppercase()) {
|
||||||
|
"MO" -> DayOfWeek.MONDAY
|
||||||
|
"TU" -> DayOfWeek.TUESDAY
|
||||||
|
"WE" -> DayOfWeek.WEDNESDAY
|
||||||
|
"TH" -> DayOfWeek.THURSDAY
|
||||||
|
"FR" -> DayOfWeek.FRIDAY
|
||||||
|
"SA" -> DayOfWeek.SATURDAY
|
||||||
|
"SU" -> DayOfWeek.SUNDAY
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
|
val digits = raw.takeWhile { it.isDigit() }
|
||||||
|
if (digits.length < 8) return null
|
||||||
|
return try {
|
||||||
|
val date = java.time.LocalDate.of(
|
||||||
|
digits.substring(0, 4).toInt(),
|
||||||
|
digits.substring(4, 6).toInt(),
|
||||||
|
digits.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an event's time into a primary line (date, or "All day") and an
|
||||||
|
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||||
|
* single primary line spanning both ends.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatWhen(
|
||||||
|
instance: EventInstance,
|
||||||
|
zone: TimeZone,
|
||||||
|
locale: Locale,
|
||||||
|
): Pair<String, String?> {
|
||||||
|
val zid = ZoneId.of(zone.id)
|
||||||
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
|
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||||
|
|
||||||
|
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|
||||||
|
if (instance.isAllDay) {
|
||||||
|
// All-day end is the exclusive next midnight; step back to the last
|
||||||
|
// covered day so a one-day event reads as a single date.
|
||||||
|
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
|
||||||
|
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
|
||||||
|
allDayLabel to dateFull.format(startLdt.toLocalDate())
|
||||||
|
} else {
|
||||||
|
allDayLabel to
|
||||||
|
"${dateMedium.format(startLdt.toLocalDate())} – ${dateMedium.format(lastLdt.toLocalDate())}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||||
|
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||||
|
dateFull.format(startLdt.toLocalDate()) to
|
||||||
|
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||||
|
} else {
|
||||||
|
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||||
|
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||||
|
"$start – $end" to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||||
|
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
|
||||||
|
|
||||||
|
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
|
||||||
|
private fun openInMaps(context: Context, query: String) {
|
||||||
|
val encoded = Uri.encode(query)
|
||||||
|
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
|
||||||
|
try {
|
||||||
|
context.startActivity(geo)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
val web = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
context.startActivity(web)
|
||||||
|
} catch (e2: ActivityNotFoundException) {
|
||||||
|
// No browser either — nothing sensible to do; swallow.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.detail
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a single event's detail on demand for the bottom sheet (spec S4).
|
||||||
|
* The event id is set via [open]; the sheet observes [state].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class EventDetailViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
|
// Bumped by retry() to re-run the load for the same target.
|
||||||
|
private val _reload = MutableStateFlow(0)
|
||||||
|
|
||||||
|
val state: StateFlow<EventDetailUiState> =
|
||||||
|
combine(_target, _reload) { target, _ -> target }
|
||||||
|
.flatMapLatest { target ->
|
||||||
|
if (target == null) {
|
||||||
|
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||||
|
} else {
|
||||||
|
flow {
|
||||||
|
emit(EventDetailUiState.Loading)
|
||||||
|
emit(loadDetail(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = EventDetailUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
|
||||||
|
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||||
|
* override the series DTSTART/DTEND so recurring events show the correct
|
||||||
|
* date instead of the first occurrence.
|
||||||
|
*/
|
||||||
|
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
|
_target.value = Target(eventId, beginMillis, endMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-run the current load after a failure. */
|
||||||
|
fun retry() {
|
||||||
|
_reload.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
|
val detail = repository.eventDetail(target.eventId)
|
||||||
|
// The Events row holds the series start; replace it with this
|
||||||
|
// occurrence's time so recurring events render correctly.
|
||||||
|
val corrected = detail.copy(
|
||||||
|
instance = detail.instance.copy(
|
||||||
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val calendarName = repository.calendars().first()
|
||||||
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
|
?.displayName
|
||||||
|
EventDetailUiState.Success(corrected, calendarName)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.EventNotFound)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ import java.util.Locale
|
|||||||
fun MonthScreen(
|
fun MonthScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -168,6 +169,7 @@ fun MonthScreen(
|
|||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,7 @@ private fun MonthContent(
|
|||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 6.dp.toPx() }
|
val threshold = with(density) { 6.dp.toPx() }
|
||||||
@@ -218,7 +221,11 @@ private fun MonthContent(
|
|||||||
when (s) {
|
when (s) {
|
||||||
MonthUiState.Loading -> MonthGridLoading()
|
MonthUiState.Loading -> MonthGridLoading()
|
||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
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(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||||
@@ -323,6 +331,7 @@ private fun MonthGrid(
|
|||||||
date = date,
|
date = date,
|
||||||
isToday = date == state.today,
|
isToday = date == state.today,
|
||||||
data = state.cells[date],
|
data = state.cells[date],
|
||||||
|
onClick = { onOpenDay(date) },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -340,6 +349,7 @@ private fun DayCard(
|
|||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
isToday: Boolean,
|
isToday: Boolean,
|
||||||
data: DayCellData?,
|
data: DayCellData?,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
|
||||||
@@ -362,7 +372,7 @@ private fun DayCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
onClick = { /* TODO: open the day view (S3) for this date */ },
|
onClick = onClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.animateDpAsState
|
|||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -111,6 +112,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
|||||||
fun WeekScreen(
|
fun WeekScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -188,6 +190,7 @@ fun WeekScreen(
|
|||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -204,6 +207,7 @@ private fun WeekContent(
|
|||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -274,6 +278,7 @@ private fun WeekContent(
|
|||||||
topSectionColor = topSectionColor,
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,6 +290,7 @@ private fun WeekSuccess(
|
|||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -293,12 +299,12 @@ private fun WeekSuccess(
|
|||||||
.background(topSectionColor),
|
.background(topSectionColor),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today)
|
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
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
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
|
@Composable
|
||||||
private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
private fun AllDayStrip(
|
||||||
|
state: WeekUiState.Success,
|
||||||
|
height: Dp,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -461,6 +471,7 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
|||||||
AllDayBar(
|
AllDayBar(
|
||||||
event = span.event,
|
event = span.event,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onClick = { onEventClick(span.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colWidth * span.startCol,
|
x = colWidth * span.startCol,
|
||||||
@@ -476,11 +487,17 @@ private fun AllDayStrip(state: WeekUiState.Success, height: Dp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
@@ -496,7 +513,11 @@ private fun AllDayBar(event: EventInstance, dark: Boolean, modifier: Modifier =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
@@ -551,6 +572,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -566,6 +588,7 @@ private fun Timeline(state: WeekUiState.Success, scrollState: ScrollState) {
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
@@ -587,6 +610,7 @@ private fun DayColumnCard(
|
|||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
.width(laneWidth)
|
.width(laneWidth)
|
||||||
@@ -602,6 +626,7 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -610,6 +635,7 @@ private fun EventBlock(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -38,6 +38,36 @@
|
|||||||
<!-- Tagesansicht (S3) -->
|
<!-- Tagesansicht (S3) -->
|
||||||
<string name="day_today_action">Heute</string>
|
<string name="day_today_action">Heute</string>
|
||||||
|
|
||||||
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
|
<string name="event_detail_back">Zurück</string>
|
||||||
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
|
<string name="event_detail_delete">Löschen</string>
|
||||||
|
<string name="event_detail_all_day">Ganztägig</string>
|
||||||
|
<string name="event_detail_calendar">Kalender</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||||
|
<string name="event_detail_location">Ort</string>
|
||||||
|
<string name="event_detail_description">Beschreibung</string>
|
||||||
|
<string name="event_detail_attendees">Teilnehmer</string>
|
||||||
|
<string name="event_detail_recurrence">Wiederholung</string>
|
||||||
|
<string name="event_detail_recurring">Wiederkehrender Termin</string>
|
||||||
|
<string name="recurrence_daily">Jeden Tag</string>
|
||||||
|
<string name="recurrence_weekly">Jede Woche</string>
|
||||||
|
<string name="recurrence_monthly">Jeden Monat</string>
|
||||||
|
<string name="recurrence_yearly">Jedes Jahr</string>
|
||||||
|
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
|
||||||
|
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
|
||||||
|
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
|
||||||
|
<string name="recurrence_on_days">%1$s am %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s bis %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
|
||||||
|
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
|
||||||
|
<string name="event_attendee_accepted">Zugesagt</string>
|
||||||
|
<string name="event_attendee_declined">Abgesagt</string>
|
||||||
|
<string name="event_attendee_tentative">Vorläufig</string>
|
||||||
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,36 @@
|
|||||||
<!-- Day view (S3) -->
|
<!-- Day view (S3) -->
|
||||||
<string name="day_today_action">Today</string>
|
<string name="day_today_action">Today</string>
|
||||||
|
|
||||||
|
<!-- Event detail screen (S4) -->
|
||||||
|
<string name="event_detail_back">Back</string>
|
||||||
|
<string name="event_detail_edit">Edit</string>
|
||||||
|
<string name="event_detail_delete">Delete</string>
|
||||||
|
<string name="event_detail_all_day">All day</string>
|
||||||
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
|
<string name="event_detail_location">Location</string>
|
||||||
|
<string name="event_detail_description">Description</string>
|
||||||
|
<string name="event_detail_attendees">Attendees</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrence</string>
|
||||||
|
<string name="event_detail_recurring">Repeating event</string>
|
||||||
|
<string name="recurrence_daily">Every day</string>
|
||||||
|
<string name="recurrence_weekly">Every week</string>
|
||||||
|
<string name="recurrence_monthly">Every month</string>
|
||||||
|
<string name="recurrence_yearly">Every year</string>
|
||||||
|
<string name="recurrence_every_n_days">Every %1$d days</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
|
||||||
|
<string name="recurrence_every_n_months">Every %1$d months</string>
|
||||||
|
<string name="recurrence_every_n_years">Every %1$d years</string>
|
||||||
|
<string name="recurrence_on_days">%1$s on %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s until %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d times</string>
|
||||||
|
<string name="event_detail_not_found">This event no longer exists.</string>
|
||||||
|
<string name="event_attendee_accepted">Accepted</string>
|
||||||
|
<string name="event_attendee_declined">Declined</string>
|
||||||
|
<string name="event_attendee_tentative">Tentative</string>
|
||||||
|
<string name="event_attendee_needs_action">No response</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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+)
|
# Material 3 (Expressive lives in this artifact for 1.5+)
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
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-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
|
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
# Hilt
|
# Hilt
|
||||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
|||||||
Reference in New Issue
Block a user