@@ -0,0 +1,206 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||||
|
// split style can list a day with exactly the same visual language instead of
|
||||||
|
// growing a parallel set of event rows.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||||
|
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||||
|
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||||
|
* an empty selected day in the month view's split style.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
// Match a single event row's resting corner radius (floret groupedShape).
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Coffee,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(eventFill(event.color, dark, soften)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||||
|
*
|
||||||
|
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||||
|
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||||
|
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||||
|
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||||
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||||
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
|
R.string.agenda_span_starts,
|
||||||
|
formatTime(label.start, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
|
R.string.agenda_span_ends,
|
||||||
|
formatTime(label.end, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||||
|
formatTime(label.end, zone, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -17,13 +14,11 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -32,7 +27,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -46,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -59,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
@@ -69,25 +59,15 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
// No file-level zone constant here on purpose: it would be fixed for the process
|
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||||
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||||
@@ -370,7 +350,10 @@ private fun AgendaList(
|
|||||||
if (day.events.isEmpty()) {
|
if (day.events.isEmpty()) {
|
||||||
// An anchored, event-less today (#35) — "nothing left today".
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
item(key = "placeholder-${day.date}") {
|
item(key = "placeholder-${day.date}") {
|
||||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
@@ -396,100 +379,6 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaDayHeader(
|
|
||||||
date: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onOpenDay(date) },
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = agendaDayLabel(date, today),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = if (date == today) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
|
|
||||||
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
|
|
||||||
* visible slot when nothing is left rather than a bare header.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
|
||||||
Card(
|
|
||||||
// Match a single event row's resting corner radius (floret groupedShape).
|
|
||||||
shape = RoundedCornerShape(22.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
.clickable(onClick = onClick),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 18.dp, horizontal = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Coffee,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agenda_no_more_today),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaEventRow(
|
|
||||||
event: EventInstance,
|
|
||||||
day: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
position: Position,
|
|
||||||
dimmed: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
GroupedRow(
|
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
|
||||||
title = title,
|
|
||||||
summary = agendaTimeSummary(event, day, zone),
|
|
||||||
position = position,
|
|
||||||
minHeight = 64.dp,
|
|
||||||
leading = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 6.dp, height = 36.dp)
|
|
||||||
.clip(RoundedCornerShape(3.dp))
|
|
||||||
.background(eventFill(event.color, dark, soften)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -559,64 +448,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
|
||||||
@Composable
|
|
||||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
|
||||||
val relative = when (date) {
|
|
||||||
today -> stringResource(R.string.agenda_header_today)
|
|
||||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val formatted = formatAgendaDate(date)
|
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
|
||||||
*
|
|
||||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
|
||||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
|
||||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
|
||||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
|
|
||||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
|
||||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
|
||||||
is AgendaTimeLabel.Starts -> stringResource(
|
|
||||||
R.string.agenda_span_starts,
|
|
||||||
formatTime(label.start, zone, is24Hour, locale),
|
|
||||||
)
|
|
||||||
is AgendaTimeLabel.Ends -> stringResource(
|
|
||||||
R.string.agenda_span_ends,
|
|
||||||
formatTime(label.end, zone, is24Hour, locale),
|
|
||||||
)
|
|
||||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
|
||||||
formatTime(label.end, zone, is24Hour, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
|
||||||
return if (location != null) "$time · $location" else time
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(
|
|
||||||
instant: Instant,
|
|
||||||
zone: TimeZone,
|
|
||||||
is24Hour: Boolean,
|
|
||||||
locale: Locale,
|
|
||||||
): String {
|
|
||||||
val t = instant.toLocalDateTime(zone).time
|
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatAgendaDate(date: LocalDate): String {
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
|
||||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
|
||||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
|
||||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ data class MonthWeek(
|
|||||||
sealed interface MonthUiState {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : MonthUiState
|
data class Failure(val reason: FailureReason) : MonthUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [weeks] is what the grid draws; [instancesByDay] is the same events keyed by
|
||||||
|
* date and *uncapped*, so the split style's day pane can list everything on a
|
||||||
|
* date without a second provider query — the month grid range already covers
|
||||||
|
* it. All-day events sort first, then by start.
|
||||||
|
*/
|
||||||
data class Success(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
val weeks: List<MonthWeek>,
|
||||||
|
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,10 +117,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +151,22 @@ internal fun layoutMonthWeeks(
|
|||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
|
layoutCalendarWeek(days, instances, zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||||
|
* the continuous style — which streams weeks rather than months and so has no
|
||||||
|
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||||
|
*
|
||||||
|
* [days] must be the row's seven consecutive dates, in display order.
|
||||||
|
*/
|
||||||
|
internal fun layoutCalendarWeek(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): MonthWeek {
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
@@ -163,7 +181,7 @@ internal fun layoutMonthWeeks(
|
|||||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MonthWeek(
|
return MonthWeek(
|
||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
@@ -171,9 +189,25 @@ internal fun layoutMonthWeeks(
|
|||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||||
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
|
* date in full without querying the provider again.
|
||||||
|
*/
|
||||||
|
internal fun instancesByDay(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Map<LocalDate, List<EventInstance>> =
|
||||||
|
days.associateWith { day ->
|
||||||
|
instances
|
||||||
|
.filter { it.coversDay(day, zone) }
|
||||||
|
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||||
* trailing days of the previous month and the leading days of the next month.
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MonthLayoutTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
|
||||||
|
// 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st.
|
||||||
|
private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE)
|
||||||
|
private val jun1 = LocalDate(2026, 6, 1)
|
||||||
|
private val jun8 = LocalDate(2026, 6, 8)
|
||||||
|
private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) }
|
||||||
|
|
||||||
|
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||||
|
date.atTime(h, m).toInstant(zone)
|
||||||
|
|
||||||
|
private fun timed(
|
||||||
|
date: LocalDate,
|
||||||
|
startHour: Int,
|
||||||
|
endHour: Int,
|
||||||
|
id: Long = 1L,
|
||||||
|
title: String = "E",
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = at(date, startHour),
|
||||||
|
end = at(date, endHour),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All-day events live at UTC midnights with an *exclusive* end. */
|
||||||
|
private fun allDay(
|
||||||
|
from: LocalDate,
|
||||||
|
toInclusive: LocalDate = from,
|
||||||
|
id: Long = 100L,
|
||||||
|
title: String = "A",
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `startOfGridWeek snaps back to the configured week start`() {
|
||||||
|
val wed = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
|
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
|
// A Sunday-anchored week containing the 10th starts on the 7th.
|
||||||
|
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthGridRange always covers 42 days from the grid start`() {
|
||||||
|
val range = monthGridRange(jun, DayOfWeek.MONDAY, zone)
|
||||||
|
assertThat(range.start).isEqualTo(at(jun1, 0))
|
||||||
|
// 42 days inclusive → the last second of 2026-07-12.
|
||||||
|
assertThat(range.endInclusive)
|
||||||
|
.isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week count follows the month's shape rather than a fixed six rows`() {
|
||||||
|
// June 2026: starts Monday, 30 days → 5 rows.
|
||||||
|
assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5)
|
||||||
|
// August 2026: starts Saturday, 31 days → spills to 6 rows.
|
||||||
|
val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST)
|
||||||
|
assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6)
|
||||||
|
// February 2021: starts Monday, 28 days → exactly 4 rows.
|
||||||
|
val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY)
|
||||||
|
assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `layoutMonthWeeks rows are contiguous and seven days wide`() {
|
||||||
|
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)
|
||||||
|
assertThat(weeks.first().days.first()).isEqualTo(jun1)
|
||||||
|
weeks.forEach { assertThat(it.days).hasSize(7) }
|
||||||
|
val allDays = weeks.flatMap { it.days }
|
||||||
|
allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day event becomes one span across its columns`() {
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans).hasSize(1)
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th
|
||||||
|
assertThat(span.endCol).isEqualTo(4) // Friday the 12th
|
||||||
|
assertThat(span.lane).isEqualTo(0)
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
assertThat(span.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a span running past the row end is flagged as continuing`() {
|
||||||
|
// Saturday the 13th through Tuesday the 16th straddles the row boundary.
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16))
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(5)
|
||||||
|
assertThat(span.endCol).isEqualTo(6)
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
assertThat(span.continuesRight).isTrue()
|
||||||
|
|
||||||
|
// The following row picks it up with the flags mirrored.
|
||||||
|
val nextRow = layoutCalendarWeek(
|
||||||
|
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
|
||||||
|
listOf(ev),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
val tail = nextRow.spans.single()
|
||||||
|
assertThat(tail.startCol).isEqualTo(0)
|
||||||
|
assertThat(tail.endCol).isEqualTo(1)
|
||||||
|
assertThat(tail.continuesLeft).isTrue()
|
||||||
|
assertThat(tail.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping spans are stacked on separate lanes`() {
|
||||||
|
val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L)
|
||||||
|
val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single-day timed events stay pills and sort by start`() {
|
||||||
|
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||||
|
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans).isEmpty()
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId })
|
||||||
|
.containsExactly(2L, 1L)
|
||||||
|
.inOrder()
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `countByDay totals bars and pills on each date`() {
|
||||||
|
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
||||||
|
val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone)
|
||||||
|
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `layoutMonthWeeks agrees with laying each row out on its own`() {
|
||||||
|
val events = listOf(
|
||||||
|
allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L),
|
||||||
|
timed(LocalDate(2026, 6, 18), 9, 10, id = 2L),
|
||||||
|
)
|
||||||
|
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone)
|
||||||
|
|
||||||
|
weeks.forEach { row ->
|
||||||
|
assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay puts all-day events first then orders by start`() {
|
||||||
|
val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L)
|
||||||
|
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L)
|
||||||
|
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L)
|
||||||
|
|
||||||
|
val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone)
|
||||||
|
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId })
|
||||||
|
.containsExactly(1L, 3L, 2L)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay repeats a multi-day event on every date it covers`() {
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||||
|
val byDay = instancesByDay(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty()
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay covers every date in the grid, empty ones included`() {
|
||||||
|
val byDay = instancesByDay(weekOf8th, emptyList(), zone)
|
||||||
|
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||||
|
assertThat(byDay.values.flatten()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user