Compare commits
30 Commits
v2.15.0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 |
53
CHANGELOG.md
53
CHANGELOG.md
@@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.16.0] — 2026-07-17
|
||||
|
||||
### Added
|
||||
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||
The form edits the event in its own zone and shows the local equivalent under
|
||||
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||
time first and note the original beneath it, so both are always clear. Pick a
|
||||
zone from a full-screen picker with your device zone and recent choices on top,
|
||||
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||
("CEST") to gather every matching zone at once. All-day events stay
|
||||
date-anchored and carry no zone, as before ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||
button that fades into the corner while you're away from today for a permanent
|
||||
today icon in the top bar — always there, on today or not, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||
before ([#60]).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
|
||||
### Fixed
|
||||
- An all-day event no longer shows up again the day after it happened. In time
|
||||
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||
because all-day events are anchored to UTC midnight and the following day's
|
||||
window reached back across that boundary and pulled the event forward onto
|
||||
"today". Each all-day event now lists only on the day it actually falls on
|
||||
([#65]).
|
||||
|
||||
## [2.15.0] — 2026-07-15
|
||||
|
||||
### Added
|
||||
@@ -984,6 +1034,7 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
@@ -997,3 +1048,5 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21500
|
||||
versionName = "2.15.0"
|
||||
versionCode = 21600
|
||||
versionName = "2.16.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||
)
|
||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
||||
return CalendarRepositoryImpl(
|
||||
dataSource,
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -20,7 +20,11 @@ internal data class EventWriteTimes(
|
||||
/**
|
||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||
* timed events resolve their wall-clock values in [zone].
|
||||
* timed events resolve their wall-clock values in the form's own
|
||||
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||
* but it no longer overrides a pinned event's zone, which is what used to
|
||||
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||
*/
|
||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||
EventWriteTimes(
|
||||
@@ -31,10 +35,11 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
timezone = "UTC",
|
||||
)
|
||||
} else {
|
||||
val writeZone = timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: zone
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
||||
timezone = zone.id,
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,10 +139,14 @@ internal fun buildEventUpdateValues(
|
||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||
}
|
||||
|
||||
// A zone change counts as a time change even when the wall-clock is
|
||||
// untouched: the same 09:00 in another zone is a different instant, so
|
||||
// DTSTART has to move with it.
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
updated.rrule != original.rrule
|
||||
updated.rrule != original.rrule ||
|
||||
updated.timezone != original.timezone
|
||||
if (!timesChanged) return@buildMap
|
||||
|
||||
val newTimes = updated.toWriteTimes(zone)
|
||||
|
||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import java.time.ZoneId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -233,6 +234,21 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||
* only while the view is off today. Turning it ON moves it to a persistent
|
||||
* icon button in each calendar view's top bar (always shown, on today or
|
||||
* not) and drops the FAB pill.
|
||||
*/
|
||||
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -354,6 +370,26 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zones the user picked recently, most recent first — the timezone picker's
|
||||
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||
* later retires can't wedge the list.
|
||||
*/
|
||||
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||
}
|
||||
|
||||
suspend fun addRecentTimeZone(zoneId: String) {
|
||||
store.edit { prefs ->
|
||||
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
@@ -650,6 +686,13 @@ class SettingsPrefs @Inject constructor(
|
||||
private fun titleTemplateKey(type: SpecialDateType) =
|
||||
stringPreferencesKey("special_dates_title_${type.name}")
|
||||
|
||||
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||
stored?.split(',').orEmpty()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||
.distinct()
|
||||
.take(MAX_RECENT_TIME_ZONES)
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -710,6 +753,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
@@ -736,6 +780,10 @@ class SettingsPrefs @Inject constructor(
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||
|
||||
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
|
||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||
* wall-clock values in the device zone; the data layer translates them to
|
||||
* provider millis (all-day events normalise to UTC midnights there).
|
||||
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||
* millis (all-day events normalise to UTC midnights there).
|
||||
*/
|
||||
data class EventForm(
|
||||
val calendarId: Long?,
|
||||
@@ -18,6 +19,23 @@ data class EventForm(
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
* when this is saved", which is what an event authored and lived in one
|
||||
* place wants. The data layer resolves it at write time and always stamps a
|
||||
* concrete `EVENT_TIMEZONE`.
|
||||
*
|
||||
* A non-null value pins the event to a zone regardless of where the device
|
||||
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||
* sets it when the stored zone differs from the device's, so merely opening
|
||||
* a local event never reveals the field — and re-opening a pinned one in
|
||||
* another zone round-trips it rather than silently re-anchoring it.
|
||||
*
|
||||
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||
*/
|
||||
val timezone: String? = null,
|
||||
val location: String = "",
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
||||
|
||||
/**
|
||||
* The form's optional sections. Which ones show by default is a user setting;
|
||||
* the rest unfold behind a "more fields" button.
|
||||
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||
* at the end.
|
||||
*/
|
||||
enum class EventFormField {
|
||||
Location,
|
||||
Description,
|
||||
/**
|
||||
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||
* dates are deliberately zone-free.
|
||||
*/
|
||||
Timezone,
|
||||
Reminders,
|
||||
Recurrence,
|
||||
Availability,
|
||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||
* the user switches the event to timed.
|
||||
*
|
||||
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||
* same zone rather than the device's.
|
||||
*/
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
isAllDay = instance.isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = pinnedZone,
|
||||
location = instance.location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||
* actually means where the user is standing. Null when there's nothing to
|
||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||
if (isAllDay) return null
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||
if (pinned.id == target.id) return null
|
||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||
end.toInstant(pinned).toLocalDateTime(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional sections that hold a value in [form] — when editing, these
|
||||
* must be visible regardless of the user's default-fields setting, or the
|
||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import java.text.Normalizer
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||
*
|
||||
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||
*/
|
||||
data class TimeZoneOption(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val shortName: String,
|
||||
val offsetMinutes: Int,
|
||||
) {
|
||||
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||
val label: String get() = id.replace('_', ' ')
|
||||
|
||||
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||
|
||||
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||
}
|
||||
|
||||
private fun optionFor(
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
regionOf: (String) -> String?,
|
||||
): TimeZoneOption = TimeZoneOption(
|
||||
id = zone.id,
|
||||
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||
)
|
||||
|
||||
/**
|
||||
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||
* a "GMT+05:30" form when no name exists.
|
||||
*
|
||||
* Two things make this fiddlier than it looks. First, it goes through
|
||||
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||
* Second, ICU only surfaces the short name *commonly used in the display
|
||||
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||
* try that next; failing both, the caller shows the offset.
|
||||
*
|
||||
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||
*/
|
||||
private fun resolveAbbreviation(
|
||||
id: String,
|
||||
dst: Boolean,
|
||||
locale: Locale,
|
||||
regionOf: (String) -> String?,
|
||||
): String {
|
||||
fun shortIn(loc: Locale): String =
|
||||
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||
val regional = regionOf(id)
|
||||
?.takeIf { it.length == 2 }
|
||||
?.let { shortIn(Locale(locale.language, it)) }
|
||||
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||
// Either no region, or the region has no name for this zone: the device
|
||||
// locale is the next-best shot, and the offset the last resort.
|
||||
return shortIn(locale)
|
||||
}
|
||||
|
||||
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||
|
||||
/**
|
||||
* Every zone the JVM knows, resolved at [at]. This is ~600 entries, so build it
|
||||
* once and filter the result rather than rebuilding per keystroke. [regionOf]
|
||||
* (see [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||
*
|
||||
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||
* double up the real zones in the list, and none of them is what a user means
|
||||
* when they pick a place.
|
||||
*/
|
||||
fun timeZoneOptions(
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||
.asSequence()
|
||||
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||
.toList()
|
||||
|
||||
/**
|
||||
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||
* database doesn't know it — for labelling one known zone without paying to
|
||||
* build the whole catalogue.
|
||||
*/
|
||||
fun timeZoneOptionOf(
|
||||
id: String,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
at: Instant = Instant.now(),
|
||||
regionOf: (String) -> String? = { null },
|
||||
): TimeZoneOption? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
return optionFor(zone, locale, at, regionOf)
|
||||
}
|
||||
|
||||
/**
|
||||
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||
* the offset isn't stated twice in ICU's spelling and ours.
|
||||
*/
|
||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
val abbrev = option.shortName
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||
*
|
||||
* Ranking puts a city that *starts with* the query above one that merely
|
||||
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||
* and the id is matched ahead of the localized name so a user who knows the
|
||||
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||
* display region, i.e. what the row actually shows.
|
||||
*/
|
||||
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||
val needle = query.normalizeForSearch()
|
||||
if (needle.isEmpty()) return options
|
||||
return options
|
||||
.mapNotNull { option ->
|
||||
val city = option.city.normalizeForSearch()
|
||||
val id = option.id.normalizeForSearch()
|
||||
val name = option.displayName.normalizeForSearch()
|
||||
val abbrev = option.shortName.normalizeForSearch()
|
||||
val rank = when {
|
||||
city.startsWith(needle) -> 0
|
||||
abbrev == needle -> 1
|
||||
name.startsWith(needle) -> 2
|
||||
abbrev.startsWith(needle) -> 3
|
||||
city.contains(needle) -> 4
|
||||
id.contains(needle) -> 5
|
||||
name.contains(needle) -> 6
|
||||
else -> return@mapNotNull null
|
||||
}
|
||||
rank to option
|
||||
}
|
||||
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||
.map { it.second }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||
* a query types the way a place is spoken rather than the way the tz database
|
||||
* spells it.
|
||||
*/
|
||||
private fun String.normalizeForSearch(): String =
|
||||
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
.replace(Regex("\\p{Mn}+"), "")
|
||||
.replace('_', ' ')
|
||||
.replace('/', ' ')
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
|
||||
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||
if (offsetMinutes == 0) return "GMT"
|
||||
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||
val abs = kotlin.math.abs(offsetMinutes)
|
||||
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||
}
|
||||
@@ -88,6 +88,8 @@ fun CalendarHost(
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
@@ -318,6 +320,7 @@ fun CalendarHost(
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -329,6 +332,7 @@ fun CalendarHost(
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
@@ -339,6 +343,7 @@ fun CalendarHost(
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
@@ -350,6 +355,7 @@ fun CalendarHost(
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
@@ -65,10 +65,11 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
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.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -76,7 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -102,6 +103,7 @@ fun AgendaScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -151,12 +153,14 @@ fun AgendaScreen(
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
@@ -367,10 +371,14 @@ private fun AgendaList(
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
key = { _, event -> event.instanceId },
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
@@ -449,6 +457,7 @@ private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -460,7 +469,7 @@ private fun AgendaEventRow(
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
summary = agendaTimeSummary(event, day),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
@@ -504,6 +513,8 @@ private fun AgendaTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -522,6 +533,7 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -554,16 +566,29 @@ private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
||||
/**
|
||||
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||
*
|
||||
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||
*/
|
||||
@Composable
|
||||
private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
private fun agendaTimeSummary(event: EventInstance, day: LocalDate): String {
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts ->
|
||||
stringResource(R.string.agenda_span_starts, formatTime(label.start, is24Hour, locale))
|
||||
is AgendaTimeLabel.Ends ->
|
||||
stringResource(R.string.agenda_span_ends, formatTime(label.end, is24Hour, locale))
|
||||
is AgendaTimeLabel.Range ->
|
||||
"${formatTime(label.start, is24Hour, locale)} – ${formatTime(label.end, is24Hour, locale)}"
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||
*/
|
||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
* agenda screen and the agenda widget label multi-day events identically; each
|
||||
* surface only formats the instants into its own locale/24h string.
|
||||
*/
|
||||
sealed interface AgendaTimeLabel {
|
||||
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||
data object AllDay : AgendaTimeLabel
|
||||
/** The first day of a multi-day timed event: when it begins. */
|
||||
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||
/** The last day of a multi-day timed event: when it ends. */
|
||||
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||
/** A single-day timed event: its start–end range. */
|
||||
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||
}
|
||||
|
||||
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||
val firstDay = event.spanFirstDay(zone)
|
||||
val lastDay = event.spanLastDay(zone)
|
||||
return when {
|
||||
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||
}
|
||||
}
|
||||
|
||||
/** One calendar day with at least one event, for the agenda list. */
|
||||
data class AgendaDay(
|
||||
@@ -15,22 +76,41 @@ data class AgendaDay(
|
||||
|
||||
/**
|
||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||
* actually carry events). An event that began before [anchor] (ongoing or
|
||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
||||
* day, all-day events sort first, then ascending by start time, then title.
|
||||
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||
* before the window (ongoing) still lists from the anchor day, and one running
|
||||
* past the window stops at the last visible day. Within a day, all-day events
|
||||
* sort first, then ascending by start time, then title.
|
||||
*
|
||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||
* and order identically.
|
||||
*/
|
||||
fun groupAgendaDays(
|
||||
anchor: LocalDate,
|
||||
windowEnd: LocalDate,
|
||||
instances: List<EventInstance>,
|
||||
zone: TimeZone,
|
||||
): List<AgendaDay> =
|
||||
instances
|
||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
||||
.toSortedMap()
|
||||
.map { (date, dayEvents) ->
|
||||
): List<AgendaDay> {
|
||||
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||
for (instance in instances) {
|
||||
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||
// The provider returns an event whenever its instant span overlaps the query
|
||||
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||
// overlaps today's window start and comes back even though its true last day
|
||||
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||
if (lastDay < firstDay) continue
|
||||
var day = firstDay
|
||||
while (day <= lastDay) {
|
||||
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||
day = day.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
}
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
@@ -40,6 +120,7 @@ fun groupAgendaDays(
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
|
||||
@@ -159,11 +159,11 @@ class AgendaViewModel @Inject constructor(
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
* *absence* is itself the signal that you're in the current one — it appears the
|
||||
* moment you page out of it, which is when it starts carrying information.
|
||||
*/
|
||||
fun formatCalendarTitle(
|
||||
date: java.time.LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
skeleton: String,
|
||||
): String {
|
||||
val fields = if (date.year == currentYear) skeleton else skeleton + "y"
|
||||
return localizedDateFormatter(locale, fields).format(date)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Timezone -> Icons.Default.Public
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
||||
* weekday/month name formatting.
|
||||
*/
|
||||
@Composable
|
||||
fun currentLocale(): Locale {
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) {
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||
* device zone and recently-picked ones pinned on top so the common case needs no
|
||||
* typing. [selected] is the pinned zone id, or null when the event follows the
|
||||
* device — picking the device row reports null back, which is what keeps an
|
||||
* unpinned event unpinned rather than freezing it to today's zone.
|
||||
*
|
||||
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
|
||||
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
|
||||
* past what an eagerly composed column can carry.
|
||||
*/
|
||||
@Composable
|
||||
fun TimeZonePickerDialog(
|
||||
selected: String?,
|
||||
deviceZoneId: String,
|
||||
recents: List<String>,
|
||||
onSelect: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
// ~600 zones, each resolving a localized name and a DST-aware offset: build
|
||||
// once per open, then filter the result per keystroke.
|
||||
val locale = currentLocale()
|
||||
val allZones = remember(locale) { timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion) }
|
||||
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||
val recentZones = remember(allZones, recents) {
|
||||
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||
}
|
||||
val searching = query.isNotBlank()
|
||||
|
||||
fun choose(zoneId: String?) {
|
||||
onSelect(zoneId)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_timezone),
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
SearchField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
if (!searching) {
|
||||
// The device row is the "unpinned" choice, so it reads as a mode
|
||||
// rather than as a zone among zones — it stays out of the big
|
||||
// list and never carries an offset.
|
||||
item(key = "device") {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_timezone_device),
|
||||
summary = zoneSummary(deviceZoneId, allZones),
|
||||
position = Position.Alone,
|
||||
selected = selected == null,
|
||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||
trailing = if (selected == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { choose(null) },
|
||||
)
|
||||
}
|
||||
if (recentZones.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = recentZones,
|
||||
key = { _, zone -> "recent_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, recentZones.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "all_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
}
|
||||
}
|
||||
|
||||
if (searching && matches.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items = matches,
|
||||
key = { _, zone -> "zone_${zone.id}" },
|
||||
) { index, zone ->
|
||||
ZoneRow(
|
||||
zone = zone,
|
||||
position = positionOf(index, matches.size),
|
||||
selected = zone.id == selected,
|
||||
onClick = { choose(zone.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The query box: the family's borderless [InlineTextField] over a tonal surface,
|
||||
* so it reads like the rest of the app's inputs rather than a boxed Material
|
||||
* field. The clear button rides inside the surface (unlike the search screen,
|
||||
* which has a top bar to put it in) because the picker's bar is the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
|
||||
// anything lower vanishes into it.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the settings screens. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoneRow(
|
||||
zone: TimeZoneOption,
|
||||
position: Position,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = zone.label,
|
||||
summary = zoneDescriptor(zone),
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "CET · GMT+01:00" for [zoneId], or the bare id if the catalogue lacks it. */
|
||||
private fun zoneSummary(zoneId: String, allZones: List<TimeZoneOption>): String =
|
||||
allZones.firstOrNull { it.id == zoneId }
|
||||
?.let { zoneDescriptor(it) }
|
||||
?: zoneId
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.icu.util.TimeZone as IcuTimeZone
|
||||
|
||||
/**
|
||||
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
|
||||
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
|
||||
*
|
||||
* This is the `android.icu`-backed bridge the pure-JVM
|
||||
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
|
||||
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
|
||||
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
|
||||
* not in `domain/`, precisely because it needs an Android import.
|
||||
*/
|
||||
fun icuTimeZoneRegion(id: String): String? =
|
||||
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||
*/
|
||||
@Composable
|
||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
if (!show) return
|
||||
IconButton(onClick = onToday) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Today,
|
||||
contentDescription = stringResource(R.string.today_jump_action),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -83,7 +85,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
@@ -93,7 +95,9 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -125,6 +129,7 @@ fun DayScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -157,6 +162,13 @@ fun DayScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is DayUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
@@ -205,16 +217,19 @@ fun DayScreen(
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday,
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.day_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = { onCreateEvent(date, null) },
|
||||
@@ -360,16 +375,20 @@ private fun DaySuccess(
|
||||
@Composable
|
||||
private fun DayTopBar(
|
||||
date: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatDayTitle(date),
|
||||
text = formatDayTitle(date, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -382,6 +401,7 @@ private fun DayTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -669,10 +689,10 @@ private fun DayLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(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}"
|
||||
}
|
||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "EEEdMMM",
|
||||
)
|
||||
|
||||
@@ -93,12 +93,16 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
@@ -108,7 +112,6 @@ import kotlinx.datetime.TimeZone
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
@@ -453,14 +456,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
}
|
||||
|
||||
// Time zone — only when the event is timed and pinned to a zone other
|
||||
// than the device's, so cross-zone events read unambiguously.
|
||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||
// than the device's, so cross-zone events read unambiguously. It answers
|
||||
// "when was it set", which the zone's name alone never did: an 8 AM New
|
||||
// York call showing as 2 PM here should still say 8 AM somewhere.
|
||||
//
|
||||
// Same hierarchy as the When card above — the label carries the card,
|
||||
// the time sits small beneath it. The local time is the one the reader
|
||||
// acts on, so the original must stay quieter than it, not compete.
|
||||
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, locale) {
|
||||
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale)
|
||||
}
|
||||
foreignZone?.let { zoneOption ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||
// The id is the primary label ("Europe/Berlin"); the long
|
||||
// localized name spelled next to it made the field too wide.
|
||||
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||
// secondary half only for a same-day event; one spanning midnight
|
||||
// — which a cross-zone event easily does — carries the whole span
|
||||
// in the primary instead, so fall back to it. If the zone can't be
|
||||
// read back at all, drop to just the abbreviation + offset.
|
||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||
?.let { formatWhen(instance, it, locale) }
|
||||
?.let { (primary, secondary) -> secondary ?: primary }
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (originalTime != null) {
|
||||
"${zoneOption.shortName} · $originalTime"
|
||||
} else {
|
||||
zoneDescriptor(zoneOption)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,21 +785,15 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||
|
||||
/**
|
||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||
* but only when the event is timed and pinned to a zone different from the
|
||||
* device's. Returns null when there's nothing worth showing.
|
||||
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||
* pinned to a zone different from the device's — the cases where showing it
|
||||
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||
* tz database doesn't know).
|
||||
*/
|
||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||
private fun foreignTimeZone(tz: String?, isAllDay: Boolean, locale: Locale): TimeZoneOption? {
|
||||
if (isAllDay || tz.isNullOrBlank()) return null
|
||||
val deviceZone = ZoneId.systemDefault().id
|
||||
if (tz == deviceZone) return null
|
||||
return try {
|
||||
val zone = ZoneId.of(tz)
|
||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
if (name == tz) tz else "$name ($tz)"
|
||||
} catch (e: Exception) {
|
||||
tz
|
||||
}
|
||||
if (tz == ZoneId.systemDefault().id) return null
|
||||
return timeZoneOptionOf(tz, locale, regionOf = ::icuTimeZoneRegion)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
@@ -111,6 +112,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
@@ -126,6 +130,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
@@ -143,7 +149,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -160,6 +166,7 @@ import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toKotlinDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalTime
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -506,6 +513,7 @@ private fun EventEditContent(
|
||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||
@@ -670,6 +678,22 @@ private fun EventEditContent(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// The rows above edit a pinned event in *its own* zone, which is the
|
||||
// time it was set at — but that says nothing about when it lands for
|
||||
// whoever is reading it. Spell the local equivalent out rather than
|
||||
// leaving the user to do the offset arithmetic. Absent (null) unless
|
||||
// the event is pinned somewhere other than here.
|
||||
form.timesIn(TimeZone.currentSystemDefault())?.let { (localStart, localEnd) ->
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_timezone_local_time,
|
||||
formatTimeRange(localStart, localEnd, locale),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -706,6 +730,42 @@ private fun EventEditContent(
|
||||
|
||||
// Optional sections: which ones render by default is a setting; the
|
||||
// rest unfold behind the "more fields" button below.
|
||||
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = null,
|
||||
onClick = { showTimezonePicker = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val pinned = remember(form.timezone, locale) {
|
||||
form.timezone?.let { timeZoneOptionOf(it, locale, regionOf = ::icuTimeZoneRegion) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pinned?.label
|
||||
?: stringResource(R.string.event_edit_timezone_device),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = pinned?.let { zoneDescriptor(it) }
|
||||
?: stringResource(R.string.event_edit_timezone_device_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
EditCard(
|
||||
@@ -1081,6 +1141,16 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (showTimezonePicker) {
|
||||
TimeZonePickerDialog(
|
||||
selected = form.timezone,
|
||||
deviceZoneId = ZoneId.systemDefault().id,
|
||||
recents = state.recentTimeZones,
|
||||
onSelect = viewModel::setTimezone,
|
||||
onDismiss = { showTimezonePicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorPicker) {
|
||||
ColorPickerDialog(
|
||||
palette = state.colorPalette,
|
||||
@@ -1933,6 +2003,21 @@ private fun EditCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
|
||||
* time when both land on the same minute (an instant event shouldn't read as a
|
||||
* range against itself). Dates are deliberately absent — the rows above carry
|
||||
* them.
|
||||
*/
|
||||
@Composable
|
||||
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
||||
val from = timeFormat.format(start.time.toJavaLocalTime())
|
||||
val to = timeFormat.format(end.time.toJavaLocalTime())
|
||||
return if (from == to) from else "$from – $to"
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless text input used inside the cards (and as the headline title).
|
||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||
|
||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
||||
* neither list.
|
||||
*/
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||
val recentTimeZones: List<String> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -219,7 +220,8 @@ class EventEditViewModel @Inject constructor(
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
settingsPrefs.recentTimeZones.flowOn(io),
|
||||
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
@@ -235,14 +237,19 @@ class EventEditViewModel @Inject constructor(
|
||||
val pickerCalendars =
|
||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||
else external.writable
|
||||
val visibleFields = external.defaultFields + local.revealed
|
||||
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||
// the field is withheld from both lists rather than shown as a no-op.
|
||||
val offerableFields = EventFormField.entries.toSet() -
|
||||
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||
recentTimeZones = recentTimeZones,
|
||||
isEditing = local.editTarget != null,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
@@ -453,12 +460,28 @@ class EventEditViewModel @Inject constructor(
|
||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
update { it.copy(isAllDay = value) }
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||
// them. Coming back out of all-day leaves the event on the device zone,
|
||||
// which is the same thing a fresh event gets.
|
||||
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
applyDefaultReminder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||
* rides along as a recent so the picker can offer it without a search next
|
||||
* time — only real picks are remembered, not the device fallback.
|
||||
*/
|
||||
fun setTimezone(zoneId: String?) {
|
||||
update { it.copy(timezone = zoneId) }
|
||||
if (zoneId != null) {
|
||||
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching calendars drops any chosen colour: a palette key is
|
||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||
|
||||
@@ -64,8 +64,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -79,7 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
@@ -105,6 +107,7 @@ fun MonthScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -130,6 +133,13 @@ fun MonthScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is MonthUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
|
||||
@@ -186,16 +196,19 @@ fun MonthScreen(
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnCurrentMonth,
|
||||
todayVisible = !isOnCurrentMonth && !todayInToolbar,
|
||||
todayText = stringResource(R.string.month_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
@@ -294,16 +307,20 @@ private fun MonthContent(
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
month: YearMonth,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatMonthYear(month),
|
||||
text = formatMonthTitle(month, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -316,6 +333,7 @@ private fun MonthTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -732,9 +750,10 @@ private fun MonthGridLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMonthYear(ym: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(ym.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${ym.year}"
|
||||
}
|
||||
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
|
||||
@@ -135,7 +135,7 @@ import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||
@@ -571,6 +571,18 @@ private fun AppearanceScreen(
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_today_toolbar),
|
||||
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.todayButtonInToolbar,
|
||||
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartLabel(state.weekStart),
|
||||
|
||||
@@ -37,6 +37,8 @@ data class SettingsUiState(
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||
val showWeekNumbers: Boolean = false,
|
||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
|
||||
@@ -124,8 +124,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
) { hourLines, weekNumbers, showToday, soften ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften)
|
||||
prefs.todayButtonInToolbar,
|
||||
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
@@ -134,6 +135,7 @@ class SettingsViewModel @Inject constructor(
|
||||
showWeekNumbers = toggles.showWeekNumbers,
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -159,6 +161,7 @@ class SettingsViewModel @Inject constructor(
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -234,6 +237,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
@@ -241,6 +245,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -460,6 +465,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||
}
|
||||
|
||||
fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
|
||||
@@ -77,8 +77,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -93,7 +95,7 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
@@ -143,6 +145,7 @@ fun WeekScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
todayInToolbar: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -180,6 +183,13 @@ fun WeekScreen(
|
||||
else -> true
|
||||
}
|
||||
|
||||
// Drives whether the title carries the year. Falls back to the clock only
|
||||
// while the first load is in flight, when there is no state to read today from.
|
||||
val currentYear = when (val s = state) {
|
||||
is WeekUiState.Success -> s.today.year
|
||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||
}
|
||||
|
||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||
var slideDir by remember { mutableIntStateOf(0) }
|
||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||
@@ -228,16 +238,19 @@ fun WeekScreen(
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnCurrentWeek,
|
||||
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||
todayText = stringResource(R.string.week_today_action),
|
||||
onToday = jumpToToday,
|
||||
onCreate = {
|
||||
@@ -395,16 +408,20 @@ private fun WeekSuccess(
|
||||
@Composable
|
||||
private fun WeekTopBar(
|
||||
weekStart: LocalDate,
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatWeekRange(weekStart),
|
||||
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
@@ -417,6 +434,7 @@ private fun WeekTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TodayAction(show = showTodayButton, onToday = onToday)
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
@@ -854,18 +872,21 @@ private fun WeekLoading() {
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||
val monthName = { d: LocalDate ->
|
||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
||||
} else if (weekStart.year == end.year) {
|
||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
||||
} else {
|
||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
||||
"${end.day}. ${monthName(end)} ${end.year}"
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The week's title: just the month [weekStart] falls in.
|
||||
*
|
||||
* The day numbers are already in the column headers directly below, so spelling
|
||||
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||
* makes the title depend on nothing but [weekStart], so it cannot drift with the
|
||||
* direction you paged in from.
|
||||
*/
|
||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
@@ -131,13 +131,14 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = soften,
|
||||
weekStart = weekStart,
|
||||
|
||||
@@ -49,13 +49,15 @@ 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.AgendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
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.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -127,7 +129,7 @@ class RefreshAgendaAction : ActionCallback {
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
private sealed interface AgendaRow {
|
||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(val event: EventInstance) : AgendaRow
|
||||
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||
}
|
||||
@@ -185,7 +187,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +198,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
day = row.date,
|
||||
dark = dark,
|
||||
soften = data.soften,
|
||||
is24Hour = data.is24Hour,
|
||||
@@ -312,6 +315,7 @@ private fun PlaceholderRow(date: LocalDate) {
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
is24Hour: Boolean,
|
||||
@@ -357,7 +361,7 @@ private fun EventRow(
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
@@ -396,11 +400,20 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts ->
|
||||
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends ->
|
||||
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range ->
|
||||
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
|
||||
@@ -49,6 +49,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
@@ -162,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
|
||||
MonthHeader(label = monthLabel(ym))
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
weeks.forEach { week ->
|
||||
@@ -480,9 +481,13 @@ private fun PermissionMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun monthLabel(month: YearMonth): String {
|
||||
val locale = Locale.getDefault()
|
||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
return "$name ${month.year}"
|
||||
}
|
||||
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||
// anyway (same convention as AgendaWidget).
|
||||
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
@@ -99,6 +99,19 @@
|
||||
<string name="event_edit_availability">Availability</string>
|
||||
<string name="event_edit_visibility">Visibility</string>
|
||||
|
||||
<!-- Event form — time zone -->
|
||||
<string name="event_edit_timezone_device">Device time zone</string>
|
||||
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
|
||||
<string name="event_edit_timezone_search">Search time zones</string>
|
||||
<string name="event_edit_timezone_recent">Recent</string>
|
||||
<string name="event_edit_timezone_all">All time zones</string>
|
||||
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
||||
<string name="event_edit_timezone_clear">Use device zone</string>
|
||||
<!-- Shown under the time fields when the event is pinned to another zone:
|
||||
the same event expressed where the user actually is. %1$s is a time
|
||||
range, e.g. "2:00 PM – 3:00 PM". -->
|
||||
<string name="event_edit_timezone_local_time">%1$s your time</string>
|
||||
|
||||
<!-- Event form — per-event color -->
|
||||
<string name="event_edit_color">Color</string>
|
||||
<string name="event_edit_color_default">Calendar color</string>
|
||||
@@ -257,6 +270,12 @@
|
||||
<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>
|
||||
<!-- Time line for a multi-day event, on its first / last day (%1$s is a time, e.g. "14:00"). -->
|
||||
<string name="agenda_span_starts">Starts %1$s</string>
|
||||
<string name="agenda_span_ends">Ends %1$s</string>
|
||||
|
||||
<!-- Jump to today (toolbar icon button; see settings_today_toolbar) -->
|
||||
<string name="today_jump_action">Go to today</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -308,6 +327,8 @@
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
|
||||
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
||||
isAllDay: Boolean = false,
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
||||
timezone: String? = null,
|
||||
): EventForm = EventForm(
|
||||
calendarId = 1L,
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `timed event resolves wall clock in the given zone`() {
|
||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned zone wins over the device zone`() {
|
||||
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
|
||||
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
|
||||
assertThat(times.timezone).isEqualTo("America/New_York")
|
||||
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable pinned zone falls back to the device zone`() {
|
||||
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day ignores a pinned zone and stays UTC`() {
|
||||
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
|
||||
assertThat(times.timezone).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||
@@ -105,6 +132,42 @@ class EventWriteMapperTest {
|
||||
assertThat(update(original, original.copy())).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing the time of a pinned event keeps its zone`() {
|
||||
// The regression this guards: the update used to stamp the device zone
|
||||
// over the event's own, silently un-anchoring a foreign-zone event so it
|
||||
// stopped tracking that zone across DST.
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(title = "Standup", start = original.start))
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||
)
|
||||
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||
.isEqualTo("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changing only the zone still moves the event`() {
|
||||
// Same wall-clock, different zone: the instant moves, so DTSTART must be
|
||||
// rewritten even though start/end compare equal.
|
||||
val original = form()
|
||||
val values = update(original, original.copy(timezone = "America/New_York"))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpinning back to the device zone rewrites the times`() {
|
||||
val original = form(timezone = "America/New_York")
|
||||
val values = update(original, original.copy(timezone = null))
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text-only edit writes just the changed columns`() {
|
||||
val original = form()
|
||||
|
||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
||||
attendees: List<Attendee> = emptyList(),
|
||||
eventColor: Int? = null,
|
||||
eventColorKey: String? = null,
|
||||
eventTimezone: String? = null,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 1L,
|
||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
||||
accessLevel = accessLevel,
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
eventTimezone = eventTimezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
||||
assertThat(prefilled.description).isEqualTo("Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm leaves an event in the device zone unpinned`() {
|
||||
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// Same zone as the device: pinning it would only make the picker appear
|
||||
// on every ordinary event.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm pins a foreign zone and shows the times in it`() {
|
||||
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L, // 08:00Z
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||
// event's own wall-clock, which is what a later save re-anchors to.
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm never pins a zone on an all-day event`() {
|
||||
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
|
||||
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
|
||||
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm ignores an unparseable stored zone`() {
|
||||
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
|
||||
beginMillis = 1_781_164_800_000L,
|
||||
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||
zone = berlin,
|
||||
)
|
||||
// A malformed sync row must not be honoured, nor fail the open.
|
||||
assertThat(prefilled.timezone).isNull()
|
||||
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn converts a pinned event into the target zone`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, end) = form.timesIn(berlin)!!
|
||||
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
||||
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn crosses the date line when the offset pushes past midnight`() {
|
||||
val form = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
val (start, _) = form.timesIn(berlin)!!
|
||||
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
|
||||
// with it, not just the clock.
|
||||
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
|
||||
// In January both are on standard time: 08:00 EST is 14:00 CET — the
|
||||
// same six hours as July, but only because both shifted. Late March,
|
||||
// when the US has sprung forward and Europe hasn't, the gap is five.
|
||||
val march = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
assertThat(march.timesIn(berlin)!!.first)
|
||||
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timesIn returns null when there is nothing to disambiguate`() {
|
||||
val base = EventForm(
|
||||
calendarId = 1L,
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||
)
|
||||
// Unpinned: the form's times already are the local times.
|
||||
assertThat(base.timesIn(berlin)).isNull()
|
||||
// Pinned to the target itself: same thing.
|
||||
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
||||
// All-day: date-anchored, so there's no zone conversion to show.
|
||||
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
||||
.isNull()
|
||||
// Unparseable: can't convert, mustn't throw.
|
||||
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
class TimeZoneCatalogTest {
|
||||
|
||||
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
|
||||
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
|
||||
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
|
||||
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
|
||||
|
||||
private fun filter(query: String) = filterTimeZones(zones, query)
|
||||
|
||||
@Test
|
||||
fun `catalogue holds the real zones and drops the legacy aliases`() {
|
||||
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
|
||||
// Bare aliases the tz database keeps for compatibility would double up
|
||||
// the real zones in the picker.
|
||||
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
|
||||
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option exposes city and region split from the id`() {
|
||||
val ny = zones.first { it.id == "America/New_York" }
|
||||
assertThat(ny.city).isEqualTo("New York")
|
||||
assertThat(ny.region).isEqualTo("America")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label is the id with underscores undone`() {
|
||||
assertThat(zones.first { it.id == "America/New_York" }.label)
|
||||
.isEqualTo("America/New York")
|
||||
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolved abbreviation is compact, not the long name`() {
|
||||
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
|
||||
// short and space-free — that's the whole point of showing it instead of
|
||||
// "Central European Time".
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(berlin.shortName).doesNotContain(" ")
|
||||
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor pairs a named abbreviation with the offset`() {
|
||||
val option = TimeZoneOption(
|
||||
id = "Europe/Berlin",
|
||||
displayName = "Central European Time",
|
||||
shortName = "CET",
|
||||
offsetMinutes = 60,
|
||||
)
|
||||
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `descriptor drops the offset when the abbreviation already is one`() {
|
||||
// Repeating the offset after an offset-shaped abbreviation would just say
|
||||
// the same thing twice.
|
||||
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
|
||||
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
|
||||
)
|
||||
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
|
||||
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset is resolved at the given instant, not the current one`() {
|
||||
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
|
||||
assertThat(berlin.offsetMinutes).isEqualTo(120)
|
||||
|
||||
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
|
||||
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank query returns everything unchanged`() {
|
||||
assertThat(filter("")).isEqualTo(zones)
|
||||
assertThat(filter(" ")).isEqualTo(zones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the city, ignoring case and underscores`() {
|
||||
assertThat(filter("new york").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
|
||||
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches accented cities typed plainly`() {
|
||||
// Sao_Paulo has no accent in the id, but its localized name does — the
|
||||
// point is that a user typing plain ASCII still finds it.
|
||||
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
|
||||
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the full IANA id`() {
|
||||
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query matches the abbreviation, case-insensitively`() {
|
||||
// "cest" isn't a city, id, or substring of "Central European Summer
|
||||
// Time", so the only way these match is via the abbreviation.
|
||||
val hits = filter("cest")
|
||||
assertThat(hits).isNotEmpty()
|
||||
assertThat(hits.map { it.id }).contains("Europe/Berlin")
|
||||
// Every hit genuinely carries that abbreviation — nothing bled in.
|
||||
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
|
||||
// Case doesn't matter.
|
||||
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a city starting with the query outranks one merely containing it`() {
|
||||
val ids = filter("york").map { it.id }
|
||||
// "New York" contains "york"; nothing starts with it, so it should still
|
||||
// surface rather than being buried.
|
||||
assertThat(ids).contains("America/New_York")
|
||||
|
||||
// "col" starts Colombo but only appears mid-string elsewhere.
|
||||
val col = filter("col").map { it.id }
|
||||
assertThat(col.first()).isEqualTo("Asia/Colombo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no match yields an empty list rather than everything`() {
|
||||
assertThat(filter("zzzznotazone")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single zone resolves the same way the catalogue does`() {
|
||||
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
|
||||
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
|
||||
.isEqualTo(fromCatalogue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown zone id resolves to null`() {
|
||||
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gmt offsets format with a sign and padding`() {
|
||||
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
|
||||
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
|
||||
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
|
||||
// India is +05:30 — a whole-hour assumption would render this wrong.
|
||||
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
|
||||
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaTimeLabelTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
|
||||
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||
|
||||
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
|
||||
instanceId = 1,
|
||||
eventId = 1,
|
||||
calendarId = 1,
|
||||
title = "e",
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
|
||||
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
|
||||
|
||||
@Test
|
||||
fun `a single-day timed event is a start-end range`() {
|
||||
val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e))
|
||||
.isEqualTo(AgendaTimeLabel.Range(e.start, e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-day all-day event is all-day`() {
|
||||
val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day timed event names the start, middle, and end days`() {
|
||||
val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start))
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day all-day event is all-day on every day`() {
|
||||
val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true)
|
||||
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event begun before the shown day is not labelled as starting`() {
|
||||
// Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends.
|
||||
val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||
|
||||
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GroupAgendaDaysTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val anchor = LocalDate(2026, 7, 1)
|
||||
// A wide window so clamping to the window end is out of the way unless tested.
|
||||
private val windowEnd = LocalDate(2026, 7, 31)
|
||||
|
||||
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||
|
||||
private fun event(
|
||||
id: Long,
|
||||
title: String,
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean = false,
|
||||
) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1,
|
||||
title = title,
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = 0,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun days(instances: List<EventInstance>) =
|
||||
groupAgendaDays(anchor, windowEnd, instances, zone)
|
||||
|
||||
@Test
|
||||
fun `a timed multi-day event lists on every day it spans`() {
|
||||
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
|
||||
|
||||
val result = days(listOf(e))
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
).inOrder()
|
||||
result.forEach { assertThat(it.events).containsExactly(e) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
|
||||
// 1–3 July inclusive: end is the exclusive midnight opening 4 July.
|
||||
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
|
||||
|
||||
val result = days(listOf(e))
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-day event lists exactly once`() {
|
||||
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date })
|
||||
.containsExactly(LocalDate(2026, 7, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event ending exactly at midnight does not reach the next day`() {
|
||||
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date })
|
||||
.containsExactly(LocalDate(2026, 7, 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event begun before the anchor is clamped to the anchor day`() {
|
||||
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||
|
||||
assertThat(days(listOf(e)).map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event running past the window is clamped to the last visible day`() {
|
||||
val narrowEnd = LocalDate(2026, 7, 2)
|
||||
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
|
||||
|
||||
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 1),
|
||||
LocalDate(2026, 7, 2),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
// All-day events are stored at UTC midnights; resolving them in a device
|
||||
// zone east of UTC would leak them onto the following day (see spanLastDay).
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
|
||||
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
|
||||
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
|
||||
|
||||
@Test
|
||||
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
|
||||
assertThat(e.spansMultipleDays(berlin)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day multi-day event spans its true days in an eastern zone`() {
|
||||
// 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July.
|
||||
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||
|
||||
assertThat(result.map { it.date }).containsExactly(
|
||||
LocalDate(2026, 7, 2),
|
||||
LocalDate(2026, 7, 3),
|
||||
LocalDate(2026, 7, 4),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() {
|
||||
// The agenda window starts at the anchor's local midnight, which in Berlin is
|
||||
// 22:00 UTC the day before — so an all-day event that ended yesterday (whose
|
||||
// exclusive UTC-midnight end sits at that instant) is still returned by the
|
||||
// provider. It must not be clamped onto the anchor's "today" section (#65).
|
||||
val jul19 = LocalDate(2026, 7, 19)
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event that falls entirely past the window end is dropped`() {
|
||||
// The same guard, at the far edge: an instance whose true days are all beyond
|
||||
// windowEnd occupies no visible day and must not be clamped onto the last one.
|
||||
val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true)
|
||||
|
||||
val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `within a day all-day events sort before timed ones`() {
|
||||
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
|
||||
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
|
||||
|
||||
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
|
||||
|
||||
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,30 @@ on-device — see plan 03):
|
||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||
|
||||
### Event time zones
|
||||
|
||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||
"the device zone at save time"** — not "no zone". The data layer resolves it in
|
||||
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
|
||||
event behaves exactly as it did before the field existed.
|
||||
|
||||
- A non-null value **pins** the event: it keeps tracking that zone's offset
|
||||
across DST no matter where the device is. `toEditForm` only pins when the
|
||||
stored zone differs from the device's, so the optional Time-zone field stays
|
||||
hidden on ordinary events and reveals itself (via `populatedFields`) on
|
||||
foreign-zone ones.
|
||||
- A pinned event is prefilled **in its own zone**, so the form shows the
|
||||
wall-clock the event means rather than the device's rendering of it.
|
||||
- A zone change counts as a **time change** even with the wall-clock untouched
|
||||
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
|
||||
includes it in `timesChanged` and rewrites `DTSTART`.
|
||||
- **All-day events never carry a zone.** They're date-anchored — the UTC
|
||||
midnights above are an anchor, not a location — so the field is withheld from
|
||||
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
|
||||
|
||||
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
|
||||
`AllDayReminderEncoding`'s offset (see its KDoc).
|
||||
|
||||
## Save conflicts
|
||||
|
||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||
|
||||
32
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
32
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
@@ -0,0 +1,32 @@
|
||||
### Added
|
||||
- Give an event its own time zone. A new Time zone field (under "more fields" in
|
||||
the event form) pins an event to a specific zone, so a call set for 8:00 AM in
|
||||
New York stays 8:00 AM in New York wherever you open it, and keeps tracking
|
||||
that zone across daylight-saving changes. The form shows the local equivalent
|
||||
under the times, and the event's details keep your local time first with the
|
||||
original noted beneath. Pick a zone from a searchable full-screen picker — by
|
||||
city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored
|
||||
and carry no zone ([#31]).
|
||||
- Put the "jump to today" button in the toolbar. A new Today button in toolbar
|
||||
setting (Settings → Appearance, off by default) swaps the floating corner
|
||||
button for a permanent today icon in the top bar — always there, matching the
|
||||
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
|
||||
|
||||
### Changed
|
||||
- Dates in the Month, Week and Day title bars now follow your language and
|
||||
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||
German-style order with a trailing dot on the day number, whatever your
|
||||
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||
disagreed about the same date. All four views now share one formatter, and the
|
||||
day/month order, the separators and the ordinal all come from your locale —
|
||||
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||
each correct for where you are ([#60]).
|
||||
- The title bar drops the year while you're in the current one — "July" rather
|
||||
than "July 2026". The year reappears the moment you page out of the current
|
||||
year, which is when it tells you something you didn't already know.
|
||||
- The Week view's title now names the month instead of spelling out the day range.
|
||||
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||
headers right below it, in the widest string in the bar. A week that straddles
|
||||
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||
|
||||
Submodule floret-kit updated: 2124227a7f...df8bdbaf73
Reference in New Issue
Block a user