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`
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
vCalendar files and the `application/ics` type), so opening or sharing those
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
into Calendula works too.
|
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
|
### Fixed
|
||||||
- Reminders for events on another day no longer read as if they were today. A
|
- 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
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||||
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||||
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#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 }
|
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 calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* 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_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_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_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 TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
|
|||||||
@@ -384,6 +384,14 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = 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
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
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]
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
* (inclusive), for a human-readable header:
|
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
* 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")
|
* - [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(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
|||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
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 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) {
|
return when (range) {
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
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.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.DateRange
|
|
||||||
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.Card
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.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.calendula.ui.common.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -83,7 +85,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
@@ -106,6 +107,7 @@ fun AgendaScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -170,9 +172,11 @@ fun AgendaScreen(
|
|||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -190,6 +194,7 @@ fun AgendaScreen(
|
|||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -213,9 +218,11 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
* The agenda's current range, tapped to open the range picker as a session-only
|
||||||
* range picker as a session-only override. Filled with the primary container
|
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||||
* while an override is active, so the temporary state is obvious.
|
* 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
|
@Composable
|
||||||
private fun AgendaRangePill(
|
private fun AgendaRangePill(
|
||||||
@@ -224,43 +231,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
onClick = onClick,
|
||||||
} else {
|
shape = MaterialTheme.shapes.large,
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
}
|
containerColor = if (isOverride) {
|
||||||
val content = if (isOverride) {
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
} else {
|
||||||
} else {
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
},
|
||||||
}
|
contentColor = if (isOverride) {
|
||||||
Surface(
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
color = container,
|
} else {
|
||||||
contentColor = content,
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
shape = RoundedCornerShape(50),
|
},
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
),
|
||||||
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = agendaRangeLabel(range),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
style = MaterialTheme.typography.labelLarge,
|
||||||
) {
|
)
|
||||||
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
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
* 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
|
@Composable
|
||||||
private fun AgendaRangeBanner(
|
private fun AgendaRangeBanner(
|
||||||
@@ -277,8 +275,10 @@ private fun AgendaRangeBanner(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
||||||
text = "${agendaRangeLabel(range)}, $window",
|
text = window,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -289,6 +289,7 @@ private fun AgendaRangeBanner(
|
|||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
|
showToday: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -304,7 +305,7 @@ private fun AgendaContent(
|
|||||||
// Hiding drops finished events — and any day they leave empty; dimming
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
// keeps them but fades the row. Recomputed each minute so events fall
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
// away (or fade) as they end while the screen stays open.
|
// 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 ->
|
state.days.mapNotNull { day ->
|
||||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
@@ -312,6 +313,14 @@ private fun AgendaContent(
|
|||||||
} else {
|
} else {
|
||||||
state.days
|
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()) {
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
@@ -349,17 +358,24 @@ private fun AgendaList(
|
|||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
if (day.events.isEmpty()) {
|
||||||
items = day.events,
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
key = { _, event -> event.instanceId },
|
item(key = "placeholder-${day.date}") {
|
||||||
) { index, event ->
|
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||||
AgendaEventRow(
|
}
|
||||||
event = event,
|
} else {
|
||||||
position = positionOf(index, day.events.size),
|
itemsIndexed(
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
items = day.events,
|
||||||
modifier = animateItemMotion(),
|
key = { _, event -> event.instanceId },
|
||||||
onClick = { onEventClick(event) },
|
) { index, event ->
|
||||||
)
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
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
|
@Composable
|
||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
@@ -520,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
|
|||||||
private fun formatAgendaDate(date: LocalDate): String {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
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
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
* grouped by day (only days that actually have events appear).
|
* grouped by day (only days that actually have events appear).
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
|
|||||||
initialValue = PastEventDisplay.SHOW,
|
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 zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.core.os.ConfigurationCompat
|
import androidx.core.os.ConfigurationCompat
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
|
|||||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
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.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.AttendeeStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
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
|
* 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
|
* 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
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +131,7 @@ fun EventDetailScreen(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -160,15 +166,14 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action —
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
// edit, delete, or duplicate — held here until the result comes back.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) pendingWrite?.invoke()
|
||||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
pendingWrite = null
|
||||||
}
|
|
||||||
pendingEdit = false
|
|
||||||
}
|
}
|
||||||
val hasWritePermission = {
|
val hasWritePermission = {
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -176,21 +181,20 @@ fun EventDetailScreen(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
if (hasWritePermission()) {
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
action()
|
||||||
} else {
|
} else {
|
||||||
pendingEdit = false
|
pendingWrite = action
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onEditClick = {
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
if (hasWritePermission()) {
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
onEdit()
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
} else {
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
pendingEdit = true
|
val onDuplicateClick = {
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
@@ -229,7 +233,8 @@ fun EventDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
val s = state
|
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) {
|
if (s is EventDetailUiState.Success) {
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -237,6 +242,15 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_share),
|
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,
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
// 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.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -138,6 +141,31 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
}.getOrNull()
|
}.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 {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
|
|||||||
@@ -631,6 +631,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showAgendaWidgetRange = true },
|
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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ data class SettingsUiState(
|
|||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
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). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** 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.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
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_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.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -116,14 +117,20 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.agendaScreenRange,
|
prefs.agendaScreenRange,
|
||||||
prefs.agendaWidgetRange,
|
prefs.agendaWidgetRange,
|
||||||
prefs.timeFormat,
|
prefs.timeFormat,
|
||||||
// Two grid-display toggles folded into one flow so they fit this
|
// Display toggles folded into one flow so they fit this group —
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
combine(
|
||||||
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
prefs.showHourLines,
|
||||||
|
prefs.showWeekNumbers,
|
||||||
|
prefs.agendaShowToday,
|
||||||
|
::Triple,
|
||||||
|
),
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
view, screenRange, widgetRange, timeFormat,
|
view, screenRange, widgetRange, timeFormat,
|
||||||
showHourLines = gridToggles.first,
|
showHourLines = toggles.first,
|
||||||
showWeekNumbers = gridToggles.second,
|
showWeekNumbers = toggles.second,
|
||||||
|
agendaShowToday = toggles.third,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -147,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
showWeekNumbers = views.showWeekNumbers,
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
|
agendaShowToday = views.agendaShowToday,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -220,6 +228,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -398,6 +407,22 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
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) {
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ sealed interface AgendaWidgetData {
|
|||||||
val savedRange: AgendaRange,
|
val savedRange: AgendaRange,
|
||||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||||
val savedPastDisplay: PastEventDisplay,
|
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. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -117,6 +123,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
@@ -132,6 +139,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
savedShowToday = showToday,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
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.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
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.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
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")
|
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() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
data class Event(val event: EventInstance) : 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
|
@Composable
|
||||||
@@ -139,37 +151,49 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val pastDisplay =
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
// Slice to the chosen range, then drop finished events (and any 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.
|
// 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
|
||||||
.filter { it.date <= rangeEnd }
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
.let { days ->
|
val visibleDays = anchorTodayIfMissing(
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
days = data.days
|
||||||
days.mapNotNull { day ->
|
.filter { it.date <= rangeEnd }
|
||||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
.let { days ->
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
days
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
days
|
today = data.today,
|
||||||
}
|
enabled = showToday,
|
||||||
}
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
if (day.events.isEmpty()) {
|
||||||
|
add(AgendaRow.Placeholder(day.date))
|
||||||
|
} else {
|
||||||
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
dark = dark,
|
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
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
@@ -337,9 +399,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
}
|
}
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// than a hardcoded day-month layout.
|
||||||
val formatted = "$weekday, ${date.day} $monthName"
|
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
<string name="event_detail_edit">Edit</string>
|
<string name="event_detail_edit">Edit</string>
|
||||||
<string name="event_detail_delete">Delete</string>
|
<string name="event_detail_delete">Delete</string>
|
||||||
<string name="event_detail_share">Share</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_chooser_title">Share event</string>
|
||||||
<string name="event_share_failed">Couldn\'t share this event.</string>
|
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||||
<string name="event_delete_title">Delete 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_today">Today</string>
|
||||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||||
<string name="agenda_empty_title">You\'re all caught up</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 -->
|
<!-- Event search -->
|
||||||
<string name="search_action">Search</string>
|
<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_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">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_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">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="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>
|
<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