Compare commits
5 Commits
1b731a4ab0
...
a19772e3a7
| Author | SHA1 | Date | |
|---|---|---|---|
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -29,6 +29,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||
into Calendula works too.
|
||||
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||
Agenda screen and its home-screen widget even once nothing is left today —
|
||||
under today's header a small "No more events today" card appears — so the first
|
||||
events you see are clearly today's rather than a future day's. Turn it off to
|
||||
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||
a one-off like a shift or an appointment can be recreated by just changing the
|
||||
day and time instead of re-typing every field. The copy keeps the original's
|
||||
time, and its title, location, notes, colour, guests and reminders come along;
|
||||
it's saved as its own single event (any repeat is left off — add one in the
|
||||
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||
([#52]).
|
||||
|
||||
### Fixed
|
||||
- Reminders for events on another day no longer read as if they were today. A
|
||||
@@ -945,8 +960,10 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
|
||||
@@ -252,6 +252,20 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||
* always anchors today at the top with a "nothing left today" placeholder,
|
||||
* even once today has no remaining events (issue #35). Default ON — makes
|
||||
* today's events easy to tell apart from a future day's at a glance.
|
||||
*/
|
||||
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
@@ -673,6 +687,8 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val AGENDA_SHOW_TODAY_KEY =
|
||||
booleanPreferencesKey("agenda_show_today")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
|
||||
@@ -384,6 +384,14 @@ fun CalendarHost(
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
// in-app new event (#52).
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,17 @@ 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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -65,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -83,7 +85,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
@@ -106,6 +107,7 @@ fun AgendaScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -170,9 +172,11 @@ fun AgendaScreen(
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
@@ -190,6 +194,7 @@ fun AgendaScreen(
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -213,9 +218,11 @@ fun AgendaScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
@@ -224,43 +231,34 @@ private fun AgendaRangePill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
* A header naming the concrete window currently shown under a "showing …" label,
|
||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
@@ -277,8 +275,10 @@ private fun AgendaRangeBanner(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -289,6 +289,7 @@ private fun AgendaRangeBanner(
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -304,7 +305,7 @@ private fun AgendaContent(
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
@@ -312,6 +313,14 @@ private fun AgendaContent(
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
@@ -349,6 +358,12 @@ private fun AgendaList(
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
@@ -361,6 +376,7 @@ private fun AgendaList(
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
@@ -391,6 +407,44 @@ private fun AgendaDayHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -520,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,24 @@ fun groupAgendaDays(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
* grouped by day (only days that actually have events appear).
|
||||
|
||||
@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether to keep today anchored at the top with a "nothing left today"
|
||||
* placeholder even once it has no remaining events (#35). A display concern
|
||||
* applied in the composition (after past-event filtering), so it rides
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
||||
* (via Android's best-pattern matching), so dates read naturally per locale
|
||||
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
||||
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
||||
*/
|
||||
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(
|
||||
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
||||
locale,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -87,6 +88,7 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
@@ -116,7 +118,10 @@ import kotlin.time.Instant
|
||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||
* event form for this occurrence — for recurring events the form asks how
|
||||
* far the change reaches when saving.
|
||||
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||
* for any event, including read-only ones, since the copy lands in an
|
||||
* editable calendar.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -126,6 +131,7 @@ fun EventDetailScreen(
|
||||
endMillis: Long,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -160,15 +166,14 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action.
|
||||
var pendingEdit by remember { mutableStateOf(false) }
|
||||
// upgrade in place. Granting continues straight into the tapped action —
|
||||
// edit, delete, or duplicate — held here until the result comes back.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
||||
}
|
||||
pendingEdit = false
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
@@ -176,21 +181,20 @@ fun EventDetailScreen(
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
val onDeleteClick = {
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
if (hasWritePermission()) {
|
||||
showDeleteDialog = true
|
||||
action()
|
||||
} else {
|
||||
pendingEdit = false
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
val onEditClick = {
|
||||
if (hasWritePermission()) {
|
||||
onEdit()
|
||||
} else {
|
||||
pendingEdit = true
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
@@ -229,7 +233,8 @@ fun EventDetailScreen(
|
||||
},
|
||||
actions = {
|
||||
val s = state
|
||||
// Share works for any loaded event — it only reads the event.
|
||||
// Share and duplicate work for any loaded event — both only
|
||||
// read it; the duplicate is created into a writable calendar.
|
||||
if (s is EventDetailUiState.Success) {
|
||||
IconButton(onClick = onShareClick) {
|
||||
Icon(
|
||||
@@ -237,6 +242,15 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_share),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDuplicateClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||
// birthday calendars etc. are read-only at the provider level.
|
||||
|
||||
@@ -8,10 +8,12 @@ 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.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
@@ -138,6 +141,31 @@ class EventDetailViewModel @Inject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a create form seeded from the open event so the user can save a
|
||||
* copy as a new, independent event (#52). Returns null when nothing is
|
||||
* loaded.
|
||||
*
|
||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||
* wants a series can re-add one in the edit form that opens. The source
|
||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||
* the create resolves to the last-used/first-writable calendar — which lets
|
||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||
* editable calendar. The occurrence's own times carry over unchanged.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
val detail = loaded.detail
|
||||
return detail.toEditForm(
|
||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -631,6 +631,18 @@ private fun AppearanceScreen(
|
||||
position = Position.Middle,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_show_today),
|
||||
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowToday,
|
||||
onCheckedChange = viewModel::setAgendaShowToday,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
|
||||
@@ -39,6 +39,8 @@ data class SettingsUiState(
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
|
||||
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -116,14 +117,20 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
// Two grid-display toggles folded into one flow so they fit this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
||||
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
||||
// Display toggles folded into one flow so they fit this group —
|
||||
// the outer combine is already at its five-arg limit.
|
||||
combine(
|
||||
prefs.showHourLines,
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
::Triple,
|
||||
),
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
view, screenRange, widgetRange, timeFormat,
|
||||
showHourLines = gridToggles.first,
|
||||
showWeekNumbers = gridToggles.second,
|
||||
showHourLines = toggles.first,
|
||||
showWeekNumbers = toggles.second,
|
||||
agendaShowToday = toggles.third,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -147,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -220,6 +228,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -398,6 +407,22 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
// The agenda widget reads this reactively, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@ sealed interface AgendaWidgetData {
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/**
|
||||
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||
* placeholder even when it has no events left. The fallback read reactively
|
||||
* from Glance state before an instance has its own state set.
|
||||
*/
|
||||
val savedShowToday: Boolean,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -117,6 +123,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val showToday = prefs.agendaShowToday.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
@@ -132,6 +139,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
savedShowToday = showToday,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
@@ -48,11 +49,13 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
||||
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
||||
* so toggling the setting reflects on the live widget without depending on the
|
||||
* `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -139,13 +151,17 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
// Finally re-anchor today (as an empty placeholder day) when the
|
||||
// "always show today" pref is on and today has no events left (#35).
|
||||
val visibleDays = anchorTodayIfMissing(
|
||||
days = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
@@ -156,20 +172,28 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
},
|
||||
today = data.today,
|
||||
enabled = showToday,
|
||||
)
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
@@ -262,6 +286,44 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A card under an anchored, event-less today (#35) — echoing the widget's
|
||||
* full-empty message ("You're all caught up" here becomes "No more events
|
||||
* today") in a rounded surface so today's slot never looks like a bare header.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaceholderRow(date: LocalDate) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.background(GlanceTheme.colors.surfaceVariant)
|
||||
.cornerRadius(16.dp)
|
||||
.padding(vertical = 18.dp, horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
@@ -337,9 +399,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val formatted = "$weekday, ${date.day} $monthName"
|
||||
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||
// than a hardcoded day-month layout.
|
||||
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<string name="event_detail_edit">Edit</string>
|
||||
<string name="event_detail_delete">Delete</string>
|
||||
<string name="event_detail_share">Share</string>
|
||||
<string name="event_detail_duplicate">Duplicate</string>
|
||||
<string name="event_share_chooser_title">Share event</string>
|
||||
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||
<string name="event_delete_title">Delete event?</string>
|
||||
@@ -255,6 +256,7 @@
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
<string name="agenda_no_more_today">No more events today</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -321,6 +323,8 @@
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Covers the agenda widget's "always show today" anchor logic (#35). */
|
||||
class AnchorTodayTest {
|
||||
|
||||
private val today = LocalDate(2026, 6, 17)
|
||||
private val tomorrow = LocalDate(2026, 6, 18)
|
||||
|
||||
@Test
|
||||
fun `disabled leaves the days untouched even when today is absent`() {
|
||||
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled prepends an empty today when it is missing`() {
|
||||
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||
assertThat(result).hasSize(2)
|
||||
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
|
||||
assertThat(result[1]).isEqualTo(days.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled anchors today into an otherwise empty list`() {
|
||||
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
|
||||
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled is a no-op when today already has its own day`() {
|
||||
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user