Compare commits
1 Commits
fix/fossif
...
renovate/g
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c141fc9a9 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -5,4 +5,3 @@
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
app/src/test/resources/ics/*.ics -text
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
image: ghcr.io/renovatebot/renovate:44.31.0
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
|
||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -7,66 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **A failed import no longer costs you the whole file.** One event the calendar
|
||||
refuses used to abort the entire import with nothing on screen but "couldn't
|
||||
read this file". Events are now added one at a time: anything rejected is
|
||||
counted and shown, and the rest still arrive. A faulty repeat rule in the file
|
||||
is repaired instead of being handed on ([#225]).
|
||||
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
||||
chose, not at midnight UTC — the same rule the app already applied to all-day
|
||||
events you create yourself — and an export no longer leaves them out of the
|
||||
file entirely ([#225]).
|
||||
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
||||
Fossify writes the ones it mirrors from your contacts as events that start and
|
||||
end on the same day, which the calendar read as lasting no time at all: they
|
||||
imported without complaint and then appeared nowhere. Any all-day event that
|
||||
ends where it starts, or carries no end at all, is now a one-day event
|
||||
([#225]).
|
||||
|
||||
### Added
|
||||
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||
rather than being dropped in silence, deleted occurrences of a repeating event
|
||||
stay deleted, a calendar named like the one the file came from is preselected,
|
||||
and event colours are carried over — matched to the closest colour your
|
||||
calendar's account offers, so a migrated calendar still reads at a glance
|
||||
([#225]).
|
||||
|
||||
## [2.19.2] — 2026-08-17
|
||||
|
||||
### Changed
|
||||
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||
week, day, agenda, search and both widgets — and no longer schedules a
|
||||
reminder. Declining an invitation in Google Calendar left the event looking
|
||||
like any other in Calendula, and it still notified you about a meeting you had
|
||||
said no to. It stays visible rather than disappearing: the organiser still
|
||||
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||
|
||||
### Fixed
|
||||
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||
the only way to reach an event from the month was to open its day first and
|
||||
find it again there. Tapping anywhere else in the cell still opens the day
|
||||
([#187]).
|
||||
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||
description to an event and opening it again showed the sheet as it was before
|
||||
the save, because the detail was only re-read when a different occurrence was
|
||||
opened ([#196]).
|
||||
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||
more than the launcher's buffer takes. The third update overran it, and Android
|
||||
responded by dropping the whole widget host, which killed updates for *every*
|
||||
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||
instead of clipping ([#214]).
|
||||
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||
instead of asking Android to update all of them, which does nothing in a
|
||||
process the tap has just woken from cold ([#18]).
|
||||
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||
leaving a sliver of the previous row on screen ([#191]).
|
||||
- Day and week view no longer run their events flush against the right edge
|
||||
([#192]).
|
||||
|
||||
## [2.19.1] — 2026-08-11
|
||||
|
||||
### Added
|
||||
@@ -1474,10 +1414,3 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||
|
||||
@@ -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 = 21902
|
||||
versionName = "2.19.2"
|
||||
versionCode = 21901
|
||||
versionName = "2.19.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
@@ -91,10 +90,8 @@ interface CalendarDataSource {
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||
*/
|
||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -104,18 +101,10 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||
* minting one when absent); returns the new `Events._ID`.
|
||||
*
|
||||
* [colorPalette] is the target account's published event colours, looked up
|
||||
* once per import; [allDayReminderTimeMinutes] is the user's preferred
|
||||
* all-day firing time, applied exactly as a hand-created event's is.
|
||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||
*/
|
||||
fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long
|
||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||
|
||||
/**
|
||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||
@@ -754,10 +743,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
@@ -792,7 +778,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
reader.toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -801,53 +786,55 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events.UID_2445),
|
||||
// DELETED rows linger until a sync adapter purges them; counting those
|
||||
// as present would make a re-import skip everything the user has since
|
||||
// deleted, reporting "all duplicates" and importing nothing.
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0",
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||
} ?: emptySet()
|
||||
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
val values = buildImportedEventValues(
|
||||
event = event,
|
||||
calendarId = calendarId,
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
val startMillis = event.start.toEpochMillis()
|
||||
val endMillis = event.end.toEpochMillis()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||
// only when the source event carried none.
|
||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
palette = colorPalette,
|
||||
put(
|
||||
CalendarContract.Events.UID_2445,
|
||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
)
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
val startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
event.semanticReminderMinutes().forEach { minutes ->
|
||||
// An all-day reminder is stored the same way a hand-created one is,
|
||||
// so it fires at the time the user picked rather than at UTC midnight.
|
||||
val providerMinutes = if (event.isAllDay) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = startDate,
|
||||
zone = ZoneId.systemDefault(),
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
minutes
|
||||
}
|
||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
@@ -857,6 +844,18 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
@@ -27,7 +26,6 @@ import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
@@ -212,41 +210,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = withContext(io) {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
// thousand-event restore to two extra queries.
|
||||
val palette = dataSource.eventColorPalette(targetCalendarId)
|
||||
val allDayMinutes = allDayReminderTimeMinutes()
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
var failed = 0
|
||||
for (event in events) {
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||
} else {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||
imported++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// One event the provider won't take must not cost the user the
|
||||
// rest of the file — foreign exports do carry rows it rejects
|
||||
// outright (a malformed RRULE throws straight out of insert).
|
||||
failed++
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped, failed = failed)
|
||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||
@@ -305,11 +288,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
) = withContext(io) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
|
||||
private companion object {
|
||||
const val TAG = "CalendarRepository"
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
|
||||
@@ -3,11 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Duration
|
||||
@@ -503,73 +499,3 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||
*
|
||||
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||
* parser hands them over.
|
||||
*
|
||||
* [palette] is the target account's published event colours. A calendar that
|
||||
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||
*/
|
||||
internal fun buildImportedEventValues(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
uid: String,
|
||||
palette: List<EventColorOption>,
|
||||
): Map<String, Any?> = buildMap {
|
||||
val startMillis = event.start.toEpochMilliseconds()
|
||||
val endMillis = event.end.toEpochMilliseconds()
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
)
|
||||
event.exDates.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||
}
|
||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
event.color?.let { color ->
|
||||
val key = palette.nearestTo(color)?.key
|
||||
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
||||
* series, seconds otherwise. An all-day series is never shorter than a day —
|
||||
* the provider expands a zero-length one into no instances at all, so the
|
||||
* series would vanish (which is what a literal read of the Fossify family's
|
||||
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
||||
* docs/ARCHITECTURE.md).
|
||||
*/
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
@@ -15,23 +12,14 @@ import java.time.ZoneOffset
|
||||
* [calendarName] the display name of its calendar (emitted as
|
||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||
* MapColumnReader.
|
||||
*
|
||||
* An all-day row's raw offset has the firing time of day encoded into it and is
|
||||
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
||||
* would be dropped as a trigger that fires after the event, losing the reminder.
|
||||
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
||||
* time the file should carry, which an import re-encodes for its own device.
|
||||
*/
|
||||
internal fun ColumnReader.toIcsEvent(
|
||||
reminderMinutes: List<Int>,
|
||||
calendarName: String?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): IcsEvent {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||
|
||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||
@@ -52,20 +40,13 @@ internal fun ColumnReader.toIcsEvent(
|
||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = isAllDay,
|
||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||
?: "UTC",
|
||||
recurrenceRule = rrule,
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
reminderMinutes = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
reminderMinutes.map {
|
||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||
}
|
||||
} else {
|
||||
reminderMinutes
|
||||
},
|
||||
reminderMinutes = reminderMinutes,
|
||||
status = status,
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
@@ -39,7 +38,5 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ internal object InstanceProjection {
|
||||
CalendarContract.Instances.EVENT_COLOR,
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
@@ -65,7 +64,6 @@ internal object InstanceProjection {
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
@@ -184,7 +182,6 @@ internal object SearchProjection {
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -200,7 +197,6 @@ internal object SearchProjection {
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
@@ -43,7 +42,5 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,16 +44,9 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
// `visible` is the flag the app's one visibility model writes (#75).
|
||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||
// An invitation the user declined is answered — it stays on the calendar
|
||||
// struck through, but it plans nothing (#180). NULL again means "no
|
||||
// answer recorded", which is not a "no".
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != " +
|
||||
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
|
||||
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
|
||||
@@ -110,17 +110,3 @@ private const val CURATION_TRIGGER_SIZE = 36
|
||||
* apart that two swatches never read as the same colour in the grid.
|
||||
*/
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
|
||||
/**
|
||||
* The palette entry closest to [argb], or null when the account publishes no
|
||||
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||
*
|
||||
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||
* closest rather than the one that is closest in RGB coordinates.
|
||||
*/
|
||||
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||
val target = oklchOf(argb)
|
||||
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||
}
|
||||
|
||||
@@ -67,13 +67,6 @@ data class EventInstance(
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
/**
|
||||
* This device user answered "no" to the invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||
* still an appointment someone expects an answer about — but every surface
|
||||
* strikes it through, and it plans no reminders (#180).
|
||||
*/
|
||||
val isDeclined: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||
*
|
||||
* Three encodings are in the wild and all appear in the files Calendula is
|
||||
* asked to import:
|
||||
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||
* (`COLOR:tomato`);
|
||||
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||
*
|
||||
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||
* lookup misses — resolves to null.
|
||||
*/
|
||||
fun parseIcsColorValue(raw: String?): Int? {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return null
|
||||
|
||||
if (value.startsWith("#")) {
|
||||
val hex = value.substring(1)
|
||||
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||
return when (hex.length) {
|
||||
6 -> parsed or OPAQUE_ALPHA
|
||||
8 -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||
return cssColors[value.lowercase()]
|
||||
}
|
||||
|
||||
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||
private val cssColors: Map<String, Int> by lazy {
|
||||
CSS3_TABLE.split(' ').associate { entry ->
|
||||
val name = entry.substringBefore('=')
|
||||
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CSS3_TABLE =
|
||||
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||
"yellow=ffff00 yellowgreen=9acd32"
|
||||
@@ -7,25 +7,14 @@ import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||
* how the writer encoded them.
|
||||
*
|
||||
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||
* them — minutes *before* start, so a negative entry means "after start".
|
||||
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||
*/
|
||||
data class ParsedIcsEvent(
|
||||
val uid: String?,
|
||||
@@ -41,42 +30,8 @@ data class ParsedIcsEvent(
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
val availability: Availability = Availability.Busy,
|
||||
val calendarName: String? = null,
|
||||
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||
val color: Int? = null,
|
||||
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||
val exDates: List<String> = emptyList(),
|
||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||
val isTask: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||
* the event, never negative.
|
||||
*
|
||||
* For a timed event the raw lead time already is that. All-day reminders are
|
||||
* whole days before the event, fired at the user's configured time of day (see
|
||||
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
||||
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
||||
*
|
||||
* Rounding to the nearest day instead would lose a day for every producer whose
|
||||
* all-day notifications fire after noon: a file's raw offset is
|
||||
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
||||
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
||||
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
||||
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
||||
* ceils to zero days before.
|
||||
*/
|
||||
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||
.map { raw ->
|
||||
if (isAllDay) {
|
||||
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||
} else {
|
||||
raw.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||
enum class IcsParseWarning {
|
||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||
@@ -90,12 +45,6 @@ enum class IcsParseWarning {
|
||||
|
||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||
UnknownTimezone,
|
||||
|
||||
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||
TasksImportedAsEvents,
|
||||
|
||||
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||
RecurrenceRuleRepaired,
|
||||
}
|
||||
|
||||
data class IcsParseResult(
|
||||
@@ -103,16 +52,8 @@ data class IcsParseResult(
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||
* provider rejected — the import continues past them, so a single unusable
|
||||
* event can't cost the user the rest of the file.
|
||||
*/
|
||||
data class IcsImportSummary(
|
||||
val imported: Int,
|
||||
val skippedDuplicate: Int,
|
||||
val failed: Int = 0,
|
||||
)
|
||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||
|
||||
/**
|
||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||
@@ -121,9 +62,6 @@ data class IcsImportSummary(
|
||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||
*
|
||||
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||
* tasks, and dropping them would silently lose half of some exports.
|
||||
*/
|
||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||
|
||||
@@ -131,49 +69,37 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
val lines = unfoldLines(text)
|
||||
val events = mutableListOf<ParsedIcsEvent>()
|
||||
val warnings = mutableSetOf<IcsParseWarning>()
|
||||
// Scanned up front rather than in document order: an X-WR-CALNAME after
|
||||
// the first VEVENT still names the calendar every event came from.
|
||||
val calendarName = lines.asSequence()
|
||||
.mapNotNull(::parseContentLine)
|
||||
.firstOrNull { it.name == "X-WR-CALNAME" }
|
||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||
var calendarName: String? = null
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line == null) { i++; continue }
|
||||
val component = when {
|
||||
line.isBegin("VEVENT") -> "VEVENT"
|
||||
line.isBegin("VTODO") -> "VTODO"
|
||||
else -> null
|
||||
}
|
||||
when {
|
||||
component != null -> {
|
||||
val end = indexOfEnd(lines, i + 1, component)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, end),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
line.isBegin("VEVENT") -> {
|
||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||
?.let(events::add)
|
||||
i = end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||
}
|
||||
line.name == "X-WR-CALNAME" -> {
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||
i++
|
||||
}
|
||||
else -> i++
|
||||
}
|
||||
}
|
||||
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||
return IcsParseResult(events, warnings)
|
||||
}
|
||||
|
||||
private fun parseComponent(
|
||||
private fun parseVevent(
|
||||
body: List<String>,
|
||||
fileCalendarName: String?,
|
||||
warnings: MutableSet<IcsParseWarning>,
|
||||
isTask: Boolean,
|
||||
): ParsedIcsEvent? {
|
||||
var uid: String? = null
|
||||
var summary = ""
|
||||
@@ -186,10 +112,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var status = EventStatus.Confirmed
|
||||
var availability = Availability.Busy
|
||||
var calendarName = fileCalendarName
|
||||
var eventColor: Int? = null
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
val exDateLines = mutableListOf<IcsContentLine>()
|
||||
var skipAsOverride = false
|
||||
|
||||
var i = 0
|
||||
@@ -206,12 +129,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||
"SUMMARY" -> summary = unescapeText(line.value)
|
||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||
// A VTODO has no DTSTART of its own in most producers' output.
|
||||
"DUE" -> if (isTask && dtStart == null) dtStart = parseIcsDateTime(line, warnings)
|
||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"EXDATE" -> exDateLines.add(line)
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
@@ -222,19 +142,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
// Fossify names the source calendar per event; it never writes
|
||||
// an X-WR-CALNAME, so this is the only calendar label its files carry.
|
||||
"CATEGORIES" -> calendarName = unescapeText(line.value).trim()
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
?: calendarName
|
||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||
// folds several source calendars into one keeps them apart visually.
|
||||
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||
}
|
||||
i++
|
||||
}
|
||||
@@ -247,111 +154,33 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||
return null
|
||||
}
|
||||
val cleanRrule = sanitizeRrule(rrule)
|
||||
if (rrule != null && cleanRrule != rrule.removePrefix("RRULE:").trim()) {
|
||||
warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
val end = dtEnd
|
||||
?: duration?.let {
|
||||
start.copy(
|
||||
instant = Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
),
|
||||
)
|
||||
}
|
||||
val end = resolveEnd(start, dtEnd, duration)
|
||||
?: start
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start.instant,
|
||||
end = end,
|
||||
end = end.instant,
|
||||
isAllDay = start.isAllDay,
|
||||
zoneId = start.zoneId,
|
||||
recurrenceRule = cleanRrule,
|
||||
recurrenceRule = rrule,
|
||||
location = location,
|
||||
description = description,
|
||||
reminderMinutes = reminders.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
calendarName = calendarName,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (cleanRrule == null) emptyList() else normalizeExDates(exDateLines, start),
|
||||
isTask = isTask,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's end instant.
|
||||
*
|
||||
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
||||
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
||||
* since the provider expands a zero-length all-day series into no instances
|
||||
* at all — an all-day event may never end at or before it starts.
|
||||
*/
|
||||
private fun resolveEnd(
|
||||
start: IcsDateTime,
|
||||
dtEnd: IcsDateTime?,
|
||||
duration: String?,
|
||||
): Instant {
|
||||
val explicit = dtEnd?.instant ?: duration?.let {
|
||||
Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
)
|
||||
}
|
||||
if (!start.isAllDay) return explicit ?: start.instant
|
||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||
*
|
||||
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||
* out that way even for timed series, where the property is then ambiguous.
|
||||
* Such a value is resolved against the series' own time of day, which is
|
||||
* what it meant — its repetitions never move within the day.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
.mapNotNull { (line, token) ->
|
||||
if (token.isEmpty()) return@mapNotNull null
|
||||
if (token.contains('T')) {
|
||||
val instant = parseExDateTime(token, line, startZone) ?: return@mapNotNull null
|
||||
if (start.isAllDay) utcDayCode(instant) else utcStamp(instant)
|
||||
} else {
|
||||
val date = parseBasicDate(token) ?: return@mapNotNull null
|
||||
if (start.isAllDay) {
|
||||
utcDayCode(date.atStartOfDayIn(TimeZone.UTC))
|
||||
} else {
|
||||
utcStamp(LocalDateTime(date, startTime).toInstant(startZone))
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
||||
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
||||
* and reading it in the device's zone instead would put the exclusion on an
|
||||
* instant no occurrence has.
|
||||
*/
|
||||
private fun parseExDateTime(
|
||||
token: String,
|
||||
line: IcsContentLine,
|
||||
startZone: TimeZone,
|
||||
): Instant? {
|
||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||
val zone = when {
|
||||
token.endsWith("Z") -> TimeZone.UTC
|
||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||
?: startZone
|
||||
}
|
||||
return ldt.toInstant(zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||
* trigger that fires after the start (which is how the Fossify family
|
||||
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||
* isn't a usable relative offset.
|
||||
*/
|
||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||
val trigger = body.asSequence()
|
||||
.mapNotNull { parseContentLine(it) }
|
||||
@@ -360,7 +189,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||
return (-millis / 60_000L).toInt()
|
||||
// Negative = before start (the normal case) → positive lead minutes.
|
||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||
@@ -388,17 +218,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
fun Instant.plusDays(days: Int): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||
|
||||
fun utcDayCode(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02d".format(year, month.number, day)
|
||||
}
|
||||
|
||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
private val VALID_FREQ = setOf(
|
||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||
)
|
||||
|
||||
/** RRULE parts whose value is a comma-separated list, so empty items can be dropped. */
|
||||
private val LIST_PARTS = setOf(
|
||||
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY",
|
||||
"BYWEEKNO", "BYMONTH", "BYSETPOS",
|
||||
)
|
||||
|
||||
/**
|
||||
* Make a foreign RRULE safe to hand `CalendarContract`, or return null when it
|
||||
* can't be salvaged.
|
||||
*
|
||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||
* from a later read. One bad rule in an imported file would therefore abort the
|
||||
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||
* the state every weekly event it imported from elsewhere is left in.
|
||||
*
|
||||
* Empty and malformed parts are dropped rather than failing the rule; only a
|
||||
* missing or unrecognised `FREQ` — without which there is no rule at all —
|
||||
* returns null.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): String? {
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return null
|
||||
|
||||
var freqSeen = false
|
||||
val parts = rule.split(';').mapNotNull { part ->
|
||||
val key = part.substringBefore('=', "").trim().uppercase()
|
||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||
if (key.isEmpty() || value.isEmpty()) return@mapNotNull null
|
||||
when {
|
||||
key == "FREQ" -> {
|
||||
if (value.uppercase() !in VALID_FREQ) return null
|
||||
freqSeen = true
|
||||
"FREQ=${value.uppercase()}"
|
||||
}
|
||||
// A non-positive or non-numeric INTERVAL is rejected by the provider.
|
||||
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||
key in LIST_PARTS -> value.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { "$key=${it.joinToString(",")}" }
|
||||
else -> "$key=$value"
|
||||
}
|
||||
}
|
||||
return if (freqSeen) parts.joinToString(";") else null
|
||||
}
|
||||
@@ -13,11 +13,6 @@ import kotlinx.datetime.toLocalDateTime
|
||||
* calendar, exactly like a fresh create — the user confirms the target and
|
||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||
*
|
||||
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||
* where the target is known up front, does keep it.
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
@@ -36,7 +31,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
end = end,
|
||||
location = location.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
reminders = reminderMinutes.distinct().sorted(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
@@ -24,13 +24,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -131,8 +129,8 @@ internal fun AgendaEventRow(
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(title, event.isDeclined),
|
||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
|
||||
@@ -36,14 +36,6 @@ val GUTTER_WIDTH = 48.dp
|
||||
*/
|
||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
|
||||
/**
|
||||
* End inset for everything that lines up with the day columns — the week's day
|
||||
* header, the all-day strip and the timeline itself. Without it the last column
|
||||
* runs flush into the screen edge while the gutter gives the other side room
|
||||
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
|
||||
*/
|
||||
val TIMELINE_CONTENT_END_INSET = 8.dp
|
||||
|
||||
private val BADGE_HEIGHT = 20.dp
|
||||
|
||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -18,30 +14,3 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
|
||||
/**
|
||||
* How a declined invitation's title is struck through (#180) — the mark every
|
||||
* surface uses to say "you answered no", chosen over hiding the event because it
|
||||
* is still something the organiser expects you at. Null for everything else, so
|
||||
* it drops straight into a `Text`'s `textDecoration`.
|
||||
*/
|
||||
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||
if (isDeclined) TextDecoration.LineThrough else null
|
||||
|
||||
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
||||
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
|
||||
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||
} else {
|
||||
AnnotatedString(title)
|
||||
}
|
||||
|
||||
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
||||
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||
if (!isDeclined) {
|
||||
title
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(title)
|
||||
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
||||
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.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -119,7 +118,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
@@ -473,11 +471,7 @@ private fun AllDayStrip(
|
||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||
// inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(
|
||||
top = ALL_DAY_VERTICAL_PADDING,
|
||||
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day column.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
@@ -531,7 +525,6 @@ private fun AllDayBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -577,7 +570,6 @@ private fun Timeline(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
@@ -780,7 +772,6 @@ private fun EventBlock(
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
@@ -801,12 +792,7 @@ private fun DayLoading() {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||
// doesn't resize the moment the real day arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -48,14 +48,9 @@ class EventDetailViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
// Bumped by retry() and by re-opening the target already shown, to re-run
|
||||
// the load without changing the target.
|
||||
// Bumped by retry() to re-run the load for the same target.
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
// Last target whose content is already on screen; a re-read of it skips the
|
||||
// Loading skeleton so the sheet doesn't blank out between two identical reads.
|
||||
private var loadedTarget: Target? = null
|
||||
|
||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
@@ -63,14 +58,11 @@ class EventDetailViewModel @Inject constructor(
|
||||
combine(_target, _reload) { target, _ -> target }
|
||||
.flatMapLatest { target ->
|
||||
if (target == null) {
|
||||
loadedTarget = null
|
||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||
} else {
|
||||
flow {
|
||||
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
||||
val loaded = loadDetail(target)
|
||||
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
||||
emit(loaded)
|
||||
emit(EventDetailUiState.Loading)
|
||||
emit(loadDetail(target))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,15 +78,9 @@ class EventDetailViewModel @Inject constructor(
|
||||
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||
* override the series DTSTART/DTEND so recurring events show the correct
|
||||
* date instead of the first occurrence.
|
||||
*
|
||||
* Re-opening the *same* occurrence always re-reads it. The view model
|
||||
* outlives the sheet, and a `StateFlow` conflates an identical value away,
|
||||
* so assigning the target alone would leave an edit that changed no time —
|
||||
* adding a description, say (#196) — showing the state from before the save.
|
||||
*/
|
||||
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
val target = Target(eventId, beginMillis, endMillis)
|
||||
if (_target.value == target) _reload.value += 1 else _target.value = target
|
||||
_target.value = Target(eventId, beginMillis, endMillis)
|
||||
}
|
||||
|
||||
/** Re-run the current load after a failure. */
|
||||
|
||||
@@ -93,11 +93,15 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
// read it without the user scrolling to a bottom button (see
|
||||
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
|
||||
// first arrives (keyed on it), then holds the pick.
|
||||
// read it without the user scrolling to a bottom button. Defaults to the
|
||||
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||
// group leads) — so the pre-selection lines up with the top of the list;
|
||||
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||
val many = state as? ImportUiState.Many
|
||||
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
|
||||
val defaultTarget = many?.calendars?.let { cals ->
|
||||
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||
}
|
||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||
|
||||
Scaffold(
|
||||
@@ -248,15 +252,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (state.summary.failed > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.import_done_failed_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -295,19 +290,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (state.summary.failed > 0) {
|
||||
ImportStatCard(
|
||||
count = state.summary.failed,
|
||||
label = stringResource(R.string.import_done_failed_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_failed,
|
||||
state.summary.failed,
|
||||
state.summary.failed,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
@@ -319,7 +301,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||
@Composable
|
||||
private fun RowScope.ImportStatCard(
|
||||
count: Int,
|
||||
@@ -361,9 +343,6 @@ private fun WarningText(warning: IcsParseWarning) {
|
||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
|
||||
IcsParseWarning.RecurrenceRuleRepaired ->
|
||||
stringResource(R.string.import_warning_recurrence_repaired)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
|
||||
@@ -43,12 +43,6 @@ sealed interface ImportUiState {
|
||||
val events: List<ParsedIcsEvent>,
|
||||
val warnings: Set<IcsParseWarning>,
|
||||
val calendars: List<CalendarSource>,
|
||||
/**
|
||||
* The calendar the file says its events came from, when it names exactly
|
||||
* one — used to preselect a matching target. Null when the file names
|
||||
* none or its events disagree.
|
||||
*/
|
||||
val fileCalendarName: String? = null,
|
||||
) : ImportUiState
|
||||
|
||||
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||
@@ -98,10 +92,6 @@ class ImportViewModel @Inject constructor(
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.isEventTarget },
|
||||
fileCalendarName = parsed.events
|
||||
.mapNotNull { it.calendarName }
|
||||
.distinct()
|
||||
.singleOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -123,22 +113,3 @@ class ImportViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The target calendar to preselect for a bulk import.
|
||||
*
|
||||
* A calendar named like the one the file came from wins — restoring "Birthdays"
|
||||
* onto the Birthdays calendar is what the user means. Otherwise the first
|
||||
* *local* calendar, which is the first row the picker shows ("Your calendars"
|
||||
* leads), so the pre-selection lines up with the top of the list; failing that
|
||||
* the first calendar of any kind.
|
||||
*/
|
||||
internal fun defaultImportTarget(
|
||||
calendars: List<CalendarSource>,
|
||||
fileCalendarName: String?,
|
||||
): Long? {
|
||||
val named = fileCalendarName?.let { name ->
|
||||
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
|
||||
}
|
||||
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -113,7 +111,6 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -141,7 +138,6 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -443,7 +439,6 @@ fun MonthScreen(
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else if (viewStyle == MonthViewStyle.Split) {
|
||||
SplitMonthContent(
|
||||
@@ -468,7 +463,6 @@ fun MonthScreen(
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -579,7 +573,6 @@ private fun MonthContent(
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
@@ -606,7 +599,6 @@ private fun MonthContent(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -625,7 +617,6 @@ private fun ContinuousMonthContent(
|
||||
showWeekNumbers: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
// The scrolling styles get their own skeleton rather than the paged
|
||||
@@ -640,7 +631,6 @@ private fun ContinuousMonthContent(
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else {
|
||||
ContinuousMonthGrid(
|
||||
@@ -648,7 +638,6 @@ private fun ContinuousMonthContent(
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -747,8 +736,7 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
||||
|
||||
/** Width of the split style's selected-day outline. */
|
||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||
internal const val MAX_EVENT_ROWS = 3
|
||||
private const val MAX_EVENT_ROWS = 3
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -766,15 +754,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
*/
|
||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||
|
||||
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||
private val DENSE_HEADER_GAP = 4.dp
|
||||
|
||||
@Composable
|
||||
internal fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
@@ -795,7 +779,6 @@ internal fun MonthGrid(
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -826,7 +809,6 @@ internal fun ContinuousMonthGrid(
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val monthCount = remember { continuousMonthCount() }
|
||||
@@ -854,7 +836,6 @@ internal fun ContinuousMonthGrid(
|
||||
today = state.today,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -874,7 +855,6 @@ private fun ContinuousMonthBlock(
|
||||
today: LocalDate,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
|
||||
Column(
|
||||
@@ -897,7 +877,6 @@ private fun ContinuousMonthBlock(
|
||||
blankOutside = true,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
@@ -956,7 +935,6 @@ internal fun DenseMonthGrid(
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
@@ -964,15 +942,10 @@ internal fun DenseMonthGrid(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// The gap under the weekday header is a real margin, not content
|
||||
// padding: a lazy list scrolls its rows *through* the before-content
|
||||
// padding, so scrolling to a week landed its row that far down with
|
||||
// the tail of the previous one showing above it (#191).
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = DENSE_HEADER_GAP),
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
@@ -987,7 +960,6 @@ internal fun DenseMonthGrid(
|
||||
inMonth = { true },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
labelMonthOnFirst = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1215,9 +1187,6 @@ private fun SplitMonthBody(
|
||||
onSelectDay(it)
|
||||
onSetExpanded(false)
|
||||
},
|
||||
// A tapped chip is asking for that event, not for its day, so it
|
||||
// opens the detail from here too rather than collapsing (#187).
|
||||
onEventClick = onEventClick,
|
||||
onCollapse = { onSetExpanded(false) },
|
||||
)
|
||||
} else {
|
||||
@@ -1318,7 +1287,6 @@ private fun SplitMonthExpanded(
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCollapse: () -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
@@ -1341,7 +1309,6 @@ private fun SplitMonthExpanded(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = sel,
|
||||
)
|
||||
}
|
||||
@@ -1822,7 +1789,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(DENSE_HEADER_GAP))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
// More rows than a viewport holds; the clip takes the overflow.
|
||||
repeat(6) {
|
||||
@@ -1870,7 +1837,6 @@ private fun MonthWeekRow(
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
blankOutside: Boolean = false,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
@@ -2169,17 +2135,11 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
// Tap layer: a tap on a chip opens that event, anything else opens the
|
||||
// day (#187). The chips take no pointer input of their own — this
|
||||
// layer covers them — so the lane under the finger is resolved
|
||||
// geometrically, exactly as the drag pickup above does it; the down
|
||||
// position is read on the initial pass, which consumes nothing and so
|
||||
// leaves both the click and a pickup in flight untouched. Padded and
|
||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||
// clipped to the background pill so the ripple matches it. A blanked
|
||||
// cell isn't part of this month, so it takes no taps either.
|
||||
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEachIndexed { col, d ->
|
||||
week.days.forEach { d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).fillMaxHeight())
|
||||
} else {
|
||||
@@ -2187,33 +2147,9 @@ private fun MonthWeekRow(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(col) {
|
||||
awaitEachGesture {
|
||||
downY[col] = awaitFirstDown(
|
||||
requireUnconsumed = false,
|
||||
pass = PointerEventPass.Initial,
|
||||
).position.y
|
||||
}
|
||||
}
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.clickable {
|
||||
// Cleared on read: a click with no fresh down
|
||||
// (TalkBack, D-pad) would otherwise resolve the
|
||||
// previous tap's position and reopen its chip.
|
||||
val cellY = downY[col]
|
||||
downY[col] = NO_DOWN_Y
|
||||
val chip = week.chipAtCellY(
|
||||
col = col,
|
||||
cellY = cellY,
|
||||
bandTopInCell = bandTopInCell(
|
||||
cellCoordinates,
|
||||
bandCoordinates,
|
||||
),
|
||||
rowHeightPx = rowHeightPx,
|
||||
)
|
||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||
},
|
||||
.clickable { onOpenDay(d) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2222,43 +2158,6 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the event band sits below the top of the row's day-column box, or null
|
||||
* while either is unmeasured. Read off the live coordinates rather than summed
|
||||
* from the padding constants, so it can't drift from what the row actually drew.
|
||||
*/
|
||||
private fun bandTopInCell(
|
||||
cell: Array<LayoutCoordinates?>,
|
||||
band: Array<LayoutCoordinates?>,
|
||||
): Float? {
|
||||
val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||
return bandTop - cellTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
|
||||
*/
|
||||
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
|
||||
|
||||
/**
|
||||
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
|
||||
* the row's day-column box. Null for a tap above the band (the day number), on an
|
||||
* empty lane, or on the overflow dots — all of which mean "open the day", the
|
||||
* dots included: their point is that the day holds more than fits.
|
||||
*/
|
||||
internal fun MonthWeek.chipAtCellY(
|
||||
col: Int,
|
||||
cellY: Float,
|
||||
bandTopInCell: Float?,
|
||||
rowHeightPx: Float,
|
||||
): EventInstance? {
|
||||
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||
val bandY = cellY - bandTopInCell
|
||||
if (bandY < 0f) return null
|
||||
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||
@@ -2459,7 +2358,6 @@ private fun MonthBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ internal fun MonthStylePreview(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
@@ -56,7 +55,6 @@ internal fun MonthStylePreview(
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
@@ -68,7 +66,6 @@ internal fun MonthStylePreview(
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
|
||||
@@ -85,7 +85,6 @@ import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -520,7 +519,7 @@ private fun SearchResultRow(
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||
title = marked(event.title, hit.titleSpans, highlight),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
|
||||
@@ -90,7 +90,6 @@ 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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
@@ -130,7 +129,6 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -508,7 +506,7 @@ private fun WeekDayHeader(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers. The start inset centres
|
||||
@@ -604,11 +602,7 @@ private fun AllDayStrip(
|
||||
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||
// padding sits inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(
|
||||
top = ALL_DAY_VERTICAL_PADDING,
|
||||
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
@@ -668,7 +662,6 @@ private fun AllDayBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -715,7 +708,6 @@ private fun Timeline(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
@@ -948,7 +940,6 @@ private fun EventBlock(
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
@@ -986,12 +977,7 @@ private fun WeekLoading() {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||
// columns don't resize the moment the real week arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
|
||||
@@ -35,10 +34,3 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||
}
|
||||
GlanceTheme(colors = colors, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
||||
* reads the same on the home screen as it does inside the app (#180).
|
||||
*/
|
||||
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,6 +24,7 @@ import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
@@ -74,6 +74,11 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
@@ -108,11 +113,6 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
||||
*/
|
||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -135,23 +135,17 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
||||
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
||||
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
||||
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
||||
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
||||
*/
|
||||
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().update(context.applicationContext, glanceId)
|
||||
AgendaWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||
* stays well inside the binder transaction limit regardless of range and calendar
|
||||
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
|
||||
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||
* risking a failed update for.
|
||||
*/
|
||||
@@ -422,11 +416,7 @@ private fun EventRow(
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleColor,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +25,7 @@ import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -33,7 +33,6 @@ import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
@@ -98,27 +97,10 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*
|
||||
* Everything here is written to keep the published `RemoteViews` small. A widget
|
||||
* update reaches the launcher as a *oneway* binder call, and the host's async
|
||||
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
|
||||
* the system drops the whole host, freezing every widget on the home screen until
|
||||
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||
* row here emits only what it fills.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
/**
|
||||
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||
* Exact costs one serialised copy per host size, which is affordable now that
|
||||
* a rendering is ~190 views rather than ~740.
|
||||
*/
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
@@ -136,17 +118,7 @@ class MonthWidget : GlanceAppWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the displayed month by the `delta` action parameter (±1).
|
||||
*
|
||||
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
|
||||
* never `updateAll`. `updateAll` resolves its targets through the provider-name
|
||||
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
|
||||
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
|
||||
* comes back empty and the redraw is dropped: the state write lands, nothing
|
||||
* recomposes, and the arrows read as dead until something else opens the app and
|
||||
* starts a session (#18). The tapped widget's own id needs no lookup.
|
||||
*/
|
||||
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||
class ShiftMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
@@ -154,7 +126,7 @@ class ShiftMonthAction : ActionCallback {
|
||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||
}
|
||||
MonthWidget().update(context.applicationContext, glanceId)
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -162,11 +134,11 @@ class ShiftMonthAction : ActionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
||||
/** Jump the displayed month back to the current month. */
|
||||
class ResetMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||
MonthWidget().update(context.applicationContext, glanceId)
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,31 +286,9 @@ private fun WeekRow(
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hidden = week.hiddenPerDay()
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||
// every cell in the column carrying a click target of its own. The content
|
||||
// above is not clickable, so touches fall through to this layer; only an
|
||||
// event bar opts out to open its own detail.
|
||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||
week.days.forEach { date ->
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.fillMaxHeight()
|
||||
.clickable(openDayAction(context, date)),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
// Day numbers. Fixed height so every week's number row lines up whether
|
||||
// or not it holds today's larger pill.
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Day numbers.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
@@ -351,36 +301,12 @@ private fun WeekRow(
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// Lanes past the last one this week fills emit nothing at all.
|
||||
repeat(week.usedLanes()) { lane ->
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||
OverflowRow(week = week, colW = colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||
* per day and most weeks leave the last two empty.
|
||||
*/
|
||||
private fun MonthWeek.usedLanes(): Int {
|
||||
for (lane in MAX_LANES - 1 downTo 0) {
|
||||
val filled = spans.any { it.lane == lane } ||
|
||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||
if (filled) return lane + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,14 +318,25 @@ private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
/**
|
||||
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||
* Today keeps its filled circle, which does need the Box.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val style = TextStyle(
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
@@ -407,78 +344,38 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
if (!isToday) {
|
||||
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||
return
|
||||
}
|
||||
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||
.background(GlanceTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = date.day.toString(), style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||
|
||||
/**
|
||||
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||
*/
|
||||
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
fun closeGap() {
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
gap = 0
|
||||
}
|
||||
while (col < 7) {
|
||||
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||
when {
|
||||
span != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||
col = span.endCol + 1
|
||||
}
|
||||
timed != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(timed, 1)
|
||||
col += 1
|
||||
}
|
||||
else -> {
|
||||
gap += 1
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
closeGap()
|
||||
return cells
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.laneCells(lane).forEach { cell ->
|
||||
if (cell.event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||
col = span.endCol + 1
|
||||
} else {
|
||||
SpanBar(
|
||||
event = cell.event,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = colW * cell.cols,
|
||||
)
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
}
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,39 +414,41 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(eventInk(fill)),
|
||||
fontSize = 9.sp,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||
@Composable
|
||||
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var gap = 0
|
||||
hidden.forEach { count ->
|
||||
if (count == 0) {
|
||||
gap += 1
|
||||
return@forEach
|
||||
}
|
||||
if (gap > 0) {
|
||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
gap = 0
|
||||
}
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||
// The overflow row is part of the day column too: tapping it (whether
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = "+$count",
|
||||
text = "+$hidden",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
modifier = GlanceModifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
|
||||
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
|
||||
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
|
||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك.</string>
|
||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
|
||||
<string name="permission_request_button">منح الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
|
||||
<string name="permission_retry_button">أعِد المحاولة</string>
|
||||
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
|
||||
<string name="permission_benefit_private_title">الخصوصية حسب التصميم</string>
|
||||
<string name="permission_benefit_private_body">لا يوجد إذن بالإنترنت، ولا تتبع عن بُعد — لا شيء يغادر هاتفك أبدًا.</string>
|
||||
<string name="permission_benefit_private_title">يبقى على جهازك</string>
|
||||
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
|
||||
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
|
||||
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
|
||||
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
|
||||
@@ -126,7 +126,6 @@
|
||||
<string name="event_edit_recurrence_none">لا يتكرر</string>
|
||||
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="zero">تم استيراد هذا الحدث مع (%1$d) لا تذكيرات.</item>
|
||||
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
|
||||
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
|
||||
@@ -178,7 +177,7 @@
|
||||
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
||||
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
|
||||
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
|
||||
<string name="reminder_onboarding_body">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||
@@ -243,17 +242,17 @@
|
||||
<string name="agenda_range_this_week">هذا الأسبوع</string>
|
||||
<string name="settings_past_events_show">إظهار</string>
|
||||
<string name="settings_past_events_hide">إخفاء</string>
|
||||
<string name="settings_agenda_header">الجدول</string>
|
||||
<string name="settings_agenda_header">جدول</string>
|
||||
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
||||
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق ”%1$s“</string>
|
||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
||||
<string name="event_edit_timezone_recent">الأحدث</string>
|
||||
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق</item>
|
||||
<item quantity="one">%d دقيقة واحده</item>
|
||||
<item quantity="one">(%d) دقيقة واحده</item>
|
||||
<item quantity="two">%d دقيقتان</item>
|
||||
<item quantity="few">%d دقائق</item>
|
||||
<item quantity="many">%d دقيقة</item>
|
||||
@@ -261,7 +260,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="zero">(%d) لا ساعات</item>
|
||||
<item quantity="one">%d ساعة واحده</item>
|
||||
<item quantity="one">(%d) ساعة واحده</item>
|
||||
<item quantity="two">%d ساعتان</item>
|
||||
<item quantity="few">%d ساعات</item>
|
||||
<item quantity="many">%d ساعة</item>
|
||||
@@ -269,7 +268,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">%d يوم واحد</item>
|
||||
<item quantity="one">(%d) يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
@@ -277,7 +276,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع</item>
|
||||
<item quantity="one">%d أسبوع واحد</item>
|
||||
<item quantity="one">(%d) أسبوع واحد</item>
|
||||
<item quantity="two">%d أسبوعان</item>
|
||||
<item quantity="few">%d أسابيع</item>
|
||||
<item quantity="many">%d أسبوع</item>
|
||||
@@ -292,7 +291,7 @@
|
||||
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_past_events_dim">تعتيم</string>
|
||||
<string name="settings_past_events">الأحداث السابقة</string>
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
@@ -315,6 +314,7 @@
|
||||
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
|
||||
<string name="settings_section_event_form">نموذج حدث جديد</string>
|
||||
<string name="settings_quick_switch_header">زر التبديل السريع</string>
|
||||
<string name="app_name">التقويم</string>
|
||||
<string name="settings_theme_system_summary">الحالي %1$s</string>
|
||||
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
|
||||
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||
@@ -329,7 +329,7 @@
|
||||
<string name="settings_widget_size_extra_large">كبير جدًا</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">%d يوم واحد</item>
|
||||
<item quantity="one">(%d) يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
@@ -383,7 +383,7 @@
|
||||
<string name="event_edit_recurrence_next">التالي: %1$s</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق قبل</item>
|
||||
<item quantity="one">%d دقيقة واحده قبل</item>
|
||||
<item quantity="one">(%d) دقيقة واحده قبل</item>
|
||||
<item quantity="two">%d دقيقتان قبل</item>
|
||||
<item quantity="few">%d دقائق قبل</item>
|
||||
<item quantity="many">%d دقيقة قبل</item>
|
||||
@@ -391,7 +391,7 @@
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="zero">(%d) لا ساعات قبل</item>
|
||||
<item quantity="one">%d ساعة واحده قبل</item>
|
||||
<item quantity="one">(%d) ساعة واحده قبل</item>
|
||||
<item quantity="two">%d ساعتان قبل</item>
|
||||
<item quantity="few">%d ساعات قبل</item>
|
||||
<item quantity="many">%d ساعة قبل</item>
|
||||
@@ -399,7 +399,7 @@
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="zero">(%d) لا أيام قبل</item>
|
||||
<item quantity="one">%d يوم واحد قبل</item>
|
||||
<item quantity="one">(%d) يوم واحد قبل</item>
|
||||
<item quantity="two">%d يومان قبل</item>
|
||||
<item quantity="few">%d أيام قبل</item>
|
||||
<item quantity="many">%d يوم قبل</item>
|
||||
@@ -407,13 +407,13 @@
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع قبل</item>
|
||||
<item quantity="one">%d أسبوع واحد قبل</item>
|
||||
<item quantity="one">(%d) أسبوع واحد قبل</item>
|
||||
<item quantity="two">%d أسبوعان قبل</item>
|
||||
<item quantity="few">%d أسابيع قبل</item>
|
||||
<item quantity="many">%d أسبوع قبل</item>
|
||||
<item quantity="other">%d أسبوع قبل</item>
|
||||
</plurals>
|
||||
<string name="reminder_benefit_reversible_body">يوجد مفتاح التبديل في الإعدادات، ضمن الإشعارات.</string>
|
||||
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
|
||||
<string name="reminder_custom_amount">القيمة</string>
|
||||
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
|
||||
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||
@@ -451,13 +451,13 @@
|
||||
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في ملف .ics.</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
|
||||
<string name="calendars_export_action">تصدير</string>
|
||||
<string name="calendars_restore_header">استعادة</string>
|
||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
|
||||
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
|
||||
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||
@@ -472,7 +472,7 @@
|
||||
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
|
||||
<item quantity="one">تم تصدير %d حدث واحد.</item>
|
||||
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
|
||||
<item quantity="two">تم تصدير %d حدثان.</item>
|
||||
<item quantity="few">تم تصدير %d أحداث.</item>
|
||||
<item quantity="many">تم تصدير %d حدث.</item>
|
||||
@@ -520,7 +520,7 @@
|
||||
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
|
||||
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
|
||||
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
|
||||
<string name="calendars_state_read_only">للقراءة فقط</string>
|
||||
@@ -542,140 +542,4 @@
|
||||
<string name="timeline_scale_regular">العادي</string>
|
||||
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
|
||||
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
|
||||
<string name="event_edit_timezone_device_summary">يتابعك أينما كنت</string>
|
||||
<string name="event_access_confidential_summary">مُعَلَّم كـ سري؛ ما يعنيه ذلك متروك لحساب التقويم</string>
|
||||
<string name="settings_widget_size_summary">نص الحدث %1$d%%</string>
|
||||
<string name="settings_calendar_durations_hint">امنح تقويم طوله الافتراضي خاص به — للمثال. ٨ ساعات لمناوبات العمل.</string>
|
||||
<string name="settings_backup_subtitle">تصدير، استيراد، النسخ الاحتياطي التلقائي</string>
|
||||
<string name="settings_views_all_header">كل طُرق العرض</string>
|
||||
<string name="settings_calendar_durations_title">المدة لكل تقويم</string>
|
||||
<string name="event_move_done">تم النقل إلى %1$s</string>
|
||||
<string name="event_move_undo">تراجع</string>
|
||||
<string name="event_move_undone">تم التراجع عن النقل</string>
|
||||
<string name="event_move_failed">تعذّر نقل الحدث</string>
|
||||
<string name="event_move_write_denied">Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث</string>
|
||||
<string name="event_move_gone">لم يعد هذا الحدث موجودًا</string>
|
||||
<string name="onboarding_step_counter">الخطوة %1$d من %2$d</string>
|
||||
<string name="onboarding_backup_title">أحداثك تعيش فقط هنا</string>
|
||||
<string name="onboarding_backup_body">لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك.</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">مرة واحدة يوميًا، من تلقاء نفسه</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula يقم بتصدير تقويماتك المحلية في الخلفية. يمكنك تغيير مدى المرات في الإعدادات.</string>
|
||||
<string name="onboarding_backup_enable_button">اختر مجلد وانسخ احتياطيًا</string>
|
||||
<string name="onboarding_backup_skip_button">ليس الآن</string>
|
||||
<string name="onboarding_view_title">ما الذي يجب أن يفتح أولاً؟</string>
|
||||
<string name="onboarding_month_style_title">كيف يجب أن يبدو الشهر؟</string>
|
||||
<string name="onboarding_view_continue_button">متابعة</string>
|
||||
<string name="onboarding_back">رجوع</string>
|
||||
<string name="onboarding_visibility_button">فهمت</string>
|
||||
<string name="onboarding_done_title">تم إعدادك بالكامل</string>
|
||||
<string name="onboarding_done_body">تقويماتك جاهزه. يمكن تغيير كل ما اخترته للتو لاحقًا في الإعدادات.</string>
|
||||
<string name="onboarding_done_button">افتح تقويمي</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="zero">تم تحديد (%d)</item>
|
||||
<item quantity="one">تم تحديد %d</item>
|
||||
<item quantity="two">تم تحديد %d</item>
|
||||
<item quantity="few">تم تحديد %d</item>
|
||||
<item quantity="many">تم تحديد %d</item>
|
||||
<item quantity="other">تم تحديد %d</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">عُثِر في الأوصاف</string>
|
||||
<string name="search_selection_close">إلغاء التحديد</string>
|
||||
<string name="search_select_all">تحديد الكل</string>
|
||||
<string name="search_delete_selected">حذف المُحدّد</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="zero">حذف (%d) حدث؟</item>
|
||||
<item quantity="one">حذف %d حدث واحد؟</item>
|
||||
<item quantity="two">حذف %d حدثان؟</item>
|
||||
<item quantity="few">حذف %d أحداث؟</item>
|
||||
<item quantity="many">حذف %d حدث؟</item>
|
||||
<item quantity="other">حذف %d حدث؟</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="zero">تم حذف (%d) لا حدث</item>
|
||||
<item quantity="one">تم حذف %d حدث واحد</item>
|
||||
<item quantity="two">تم حذف %d حدثان</item>
|
||||
<item quantity="few">تم حذف %d أحداث</item>
|
||||
<item quantity="many">تم حذف %d حدث</item>
|
||||
<item quantity="other">تم حذف %d حدث</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">تم حذف %1$d، وتعذّر حذف %2$d</string>
|
||||
<string name="search_delete_denied_partial">تم حذف %1$d، ثم تم سحب صلاحية للكتابة</string>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="zero">تم تخطي (%d) بالفعل في هذا التقويم.</item>
|
||||
<item quantity="one">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="two">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="few">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="many">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="other">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="zero">تم استيراد (%d) لا أحداث.</item>
|
||||
<item quantity="one">تم استيراد %d حدث واحد.</item>
|
||||
<item quantity="two">تم استيراد %d حدثان.</item>
|
||||
<item quantity="few">تم استيراد %d أحداث.</item>
|
||||
<item quantity="many">تم استيراد %d حدث.</item>
|
||||
<item quantity="other">تم استيراد %d حدث.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="zero">استيراد (%d) لا أحداث</item>
|
||||
<item quantity="one">استيراد %d حدث واحد</item>
|
||||
<item quantity="two">استيراد %d حدثان</item>
|
||||
<item quantity="few">استيراد %d أحداث</item>
|
||||
<item quantity="many">استيراد %d حدث</item>
|
||||
<item quantity="other">استيراد %d حدث</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="zero">(%d) لا أحداث في هذا الملف.</item>
|
||||
<item quantity="one">%d حدث واحد في هذا الملف.</item>
|
||||
<item quantity="two">%d حدثان في هذا الملف.</item>
|
||||
<item quantity="few">%d أحداث في هذا الملف.</item>
|
||||
<item quantity="many">%d حدث في هذا الملف.</item>
|
||||
<item quantity="other">%d حدث في هذا الملف.</item>
|
||||
</plurals>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="zero">يتم استيراد (%d) لا أحداث</item>
|
||||
<item quantity="one">يتم استيراد %d حدث واحد</item>
|
||||
<item quantity="two">يتم استيراد %d حدثان</item>
|
||||
<item quantity="few">يتم استيراد %d أحداث</item>
|
||||
<item quantity="many">يتم استيراد %d حدث</item>
|
||||
<item quantity="other">يتم استيراد %d حدث</item>
|
||||
</plurals>
|
||||
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
||||
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
||||
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
||||
<string name="settings_drawer_order_header">قائمة التنقل</string>
|
||||
<string name="settings_default_reminder_allday">أحداث يوم كامل</string>
|
||||
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
|
||||
<string name="reminder_none">لا شئ</string>
|
||||
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Calendula لكي تصل في الوقت المحدد.</string>
|
||||
<string name="settings_calendar_reminders_title">التذكيرات لكل تقويم</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_special_dates_paused_title">متوقف</string>
|
||||
<string name="settings_special_dates_calendar_hint">اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات.</string>
|
||||
<string name="calendars_visibility_hint">قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت.</string>
|
||||
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
|
||||
<string name="settings_agenda_widget_range">نطاق ودجت الجدول</string>
|
||||
<string name="settings_widget_size">حجم ودجت الجدول</string>
|
||||
<string name="settings_widgets_subtitle">ودجت الجدول، عنصر الإعدادات السريعة</string>
|
||||
<string name="settings_drag_to_reschedule">السحب لتعديل الموعد</string>
|
||||
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
|
||||
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
|
||||
<string name="crash_dialog_report">تقرير</string>
|
||||
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر الموجود في أعلى اليمين، واسحب لإعادة ترتيبها. تظل طرق العرض التي تم إيقاف تشغيلها متاحة من قائمة التنقل.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="settings_widgets_hint">إعدادات لوجدتز الشاشة الرئيسية وعنصر لوحة الإعدادات السريعة. أضف وجدت عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
||||
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
|
||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
||||
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين إلى المدى المحفوظ الخاص بك عند إعادة فتح Calendula.</string>
|
||||
<string name="settings_past_events_sample_morning">مراجعة الفريق</string>
|
||||
<string name="settings_past_events_sample_midday">غداء مع روبن</string>
|
||||
<string name="settings_past_events_sample_evening">ممارسة الجوقة</string>
|
||||
<string name="settings_special_dates_last_synced">آخر مزامنة %1$s</string>
|
||||
<string name="settings_special_dates_disable_type_message">يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه.</string>
|
||||
<string name="special_dates_default_title_birthday">عيد ميلاد {name} ({year})</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi.</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||
<string name="permission_retry_button">Riprova</string>
|
||||
<string name="permission_benefit_private_title">Privato by design</string>
|
||||
<string name="permission_benefit_private_body">Nessun accesso a internet, nessun tracking: nulla lascia il tuo dispositivo.</string>
|
||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||
@@ -301,8 +301,8 @@
|
||||
<string name="settings_language_auto">Lingua di sistema</string>
|
||||
<string name="settings_translate">Aiuta a tradurre</string>
|
||||
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Tema, colori, font</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default e comportamento</string>
|
||||
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||
<string name="settings_section_about">A proposito</string>
|
||||
<string name="settings_license">Licenza</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -414,7 +414,7 @@
|
||||
<string name="agenda_range_this_week">Questa settimana</string>
|
||||
<string name="settings_reminders">Promemoria per gli eventi</string>
|
||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="settings_font_headings">Carattere del titolo</string>
|
||||
@@ -423,13 +423,13 @@
|
||||
<string name="settings_font_choose_file">Scegli file…</string>
|
||||
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||
<string name="settings_section_views">Vista</string>
|
||||
<string name="settings_section_views">Viste</string>
|
||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||
<string name="settings_views_subtitle">Vista di default, layout, ordine</string>
|
||||
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
|
||||
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
||||
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
||||
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
||||
@@ -478,8 +478,8 @@
|
||||
<string name="reminder_day_yesterday">Ieri</string>
|
||||
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="settings_soften_colors">Armonizza i colori del calendario</string>
|
||||
<string name="settings_soften_colors_summary">Mantieni i colori del calendario ma attenua la luminosità, così gli eventi rimangono leggibili e i colori sono armonizzati. Deseleziona per mostrare i colori originali.</string>
|
||||
<string name="settings_soften_colors">Attenua i colori del calendario</string>
|
||||
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
|
||||
<string name="settings_week_numbers">Numeri di settimana</string>
|
||||
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
|
||||
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
|
||||
@@ -499,151 +499,4 @@
|
||||
<item quantity="many">Importando %d eventi</item>
|
||||
<item quantity="other">Importando %d eventi</item>
|
||||
</plurals>
|
||||
<string name="event_move_action">Sposta…</string>
|
||||
<string name="event_move_recurring_title">Sposta un evento ricorrente</string>
|
||||
<string name="event_move_done">Spostato al %1$s</string>
|
||||
<string name="event_move_undo">Annulla ripianificazione</string>
|
||||
<string name="event_move_undone">Ripianificazione annullata</string>
|
||||
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
|
||||
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato.</string>
|
||||
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
|
||||
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
|
||||
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
|
||||
<string name="event_edit_timezone_search">Seleziona fuso orario</string>
|
||||
<string name="event_edit_timezone_recent">Recenti</string>
|
||||
<string name="event_edit_timezone_all">Tutti i fusi orari</string>
|
||||
<string name="event_edit_timezone_none">Nessun fuso corrisponde a \"%1$s\"</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s nel tuo fuso orario</string>
|
||||
<string name="event_edit_recurrence_incomplete">Inserisci un numero tra 1 e 999</string>
|
||||
<string name="event_edit_recurrence_next">Prossimo evento: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Questa regola non si ripete</string>
|
||||
<string name="event_access_default_summary">In base all\'impostazione del calendario</string>
|
||||
<string name="event_access_public_summary">Chiunque acceda può vedere i dettagli completi</string>
|
||||
<string name="event_access_private_summary">Gli altri vedranno solo che sei impegnato</string>
|
||||
<string name="event_access_confidential_summary">Confidenziale; dipende dalle impostazioni dell\'account</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">Al più %1$s</string>
|
||||
<string name="onboarding_step_counter">Step %1$d di %2$d</string>
|
||||
<string name="onboarding_backup_title">I tuoi eventi rimangono qui</string>
|
||||
<string name="onboarding_backup_body">Nulla viene sincronizzato, perdere i dati presenti su questo dispositivo significa perdere i tuoi calendari. Calendula può fare un backup per te.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Una cartella a scelta</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">I backup sono file .ics. Mettili al sicuro sul cloud o su una scheda SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Giornaliero, in automatico</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula esporta i tuoi calendari locali in background. Selezione la frequenza nelle Impostazioni.</string>
|
||||
<string name="onboarding_backup_enable_button">Seleziona la cartella ed esegui il backup</string>
|
||||
<string name="onboarding_backup_skip_button">Non ora</string>
|
||||
<string name="onboarding_view_title">Cosa viene aperto per primo?</string>
|
||||
<string name="onboarding_month_style_title">Seleziona la vista Mese</string>
|
||||
<string name="onboarding_view_continue_button">Continua</string>
|
||||
<string name="onboarding_back">Indietro</string>
|
||||
<string name="onboarding_visibility_button">Capito</string>
|
||||
<string name="onboarding_done_title">Tutto fatto</string>
|
||||
<string name="onboarding_done_body">I tuoi calendari sono pronti. Tutto ciò che hai scelto può essere modificato più tardi nelle Impostazioni.</string>
|
||||
<string name="onboarding_done_button">Apri i miei calendari</string>
|
||||
<string name="agenda_span_starts">Inizia alle %1$s</string>
|
||||
<string name="agenda_span_ends">Finisce alle %1$s</string>
|
||||
<string name="today_jump_action">Vai ad oggi</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selezionato</item>
|
||||
<item quantity="many">%d selezionati</item>
|
||||
<item quantity="other">%d selezionati</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Trovato nelle descrizioni</string>
|
||||
<string name="search_selection_close">Cancella selezione</string>
|
||||
<string name="search_select_all">Seleziona tutti</string>
|
||||
<string name="search_delete_selected">Elimina i selezionati</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Eliminare l\'evento?</item>
|
||||
<item quantity="other">Eliminare l\'evento?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">Evento eliminato</item>
|
||||
<item quantity="other">Evento eliminato</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
||||
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
||||
<string name="event_move_occurrence_only">Ripianificare l\'intera serie cambia i giorni in cui cade, quindi puoi ripianificare solo questo evento.</string>
|
||||
<string name="settings_theme_hint">Tema chiaro o scuro. La modifica viene applicata immediatamente.</string>
|
||||
<string name="settings_theme_system_summary">Al momento %1$s</string>
|
||||
<string name="settings_font_specimen_heading">Giovedì, 14 Maggio</string>
|
||||
<string name="settings_font_specimen_body">Team review alle 10:00, poi pranzo con Ugo al bar a Piazza di Spagna.</string>
|
||||
<string name="settings_week_start_auto_summary">Al momento %1$s</string>
|
||||
<string name="settings_today_toolbar">Pulsante Oggi nella toolbar</string>
|
||||
<string name="settings_today_toolbar_summary">Mostra il pulsante \"vai alla data\" nella toolbar invece che come pulsante flottante</string>
|
||||
<string name="settings_app_name">Nome app</string>
|
||||
<string name="settings_app_name_summary">Mostra Calendula come \"Calendario\" nel launcher. Il nome cambia solo nel launcher; l\'icona potrebbe spostarsi dopo la modifica.</string>
|
||||
<string name="settings_time_format_auto_summary">Da sistema: %1$s</string>
|
||||
<string name="settings_timeline_scale">Altezza ore</string>
|
||||
<string name="settings_timeline_scale_hint">Spazio occupato verticalmente da un\'ora nella vista Giorno e Settimana. Puoi anche modificare l\'altezza come preverisci pizzicando la timeline.</string>
|
||||
<string name="timeline_scale_fit_day">Occupa la giornata intera</string>
|
||||
<string name="timeline_scale_fit_day_summary">Tutte le 24 ore in una schermata, senza scrolling</string>
|
||||
<string name="timeline_scale_compact">Compatto</string>
|
||||
<string name="timeline_scale_compact_summary">Più ore per schermata, blocchi più piccoli</string>
|
||||
<string name="timeline_scale_regular">Regolare</string>
|
||||
<string name="timeline_scale_regular_summary">La spaziatura standard</string>
|
||||
<string name="timeline_scale_comfortable">Comodo</string>
|
||||
<string name="timeline_scale_comfortable_summary">Blocchi più ampi, più scrolling</string>
|
||||
<string name="timeline_scale_custom">Personalizza</string>
|
||||
<string name="timeline_scale_custom_summary">L\'altezza a cui hai portato la timeline</string>
|
||||
<string name="settings_drag_to_reschedule">Trascina per ripianificare</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Ripianifica un evento trascinandolo in un altro giorno o ora</string>
|
||||
<string name="settings_past_events_sample_morning">Team review</string>
|
||||
<string name="settings_past_events_sample_midday">Pranzo con Ugo</string>
|
||||
<string name="settings_past_events_sample_evening">Allenamento di scacchi</string>
|
||||
<string name="settings_widget_size">Dimensioni del widget Agenda</string>
|
||||
<string name="settings_widget_size_hint">Dimensioni del testo del widget agenda. Il widget mensile non ha impostazioni, si adatta da solo alla dimensione data.</string>
|
||||
<string name="settings_widget_size_small">Piccolo</string>
|
||||
<string name="settings_widget_size_medium">Medio</string>
|
||||
<string name="settings_widget_size_large">Grande</string>
|
||||
<string name="settings_widget_size_extra_large">Molto grande</string>
|
||||
<string name="settings_widget_size_summary">Carattere %1$d%%</string>
|
||||
<string name="settings_month_header">Vista Mese</string>
|
||||
<string name="settings_month_view_style">Stile vista Mese</string>
|
||||
<string name="month_style_paged">Pagine</string>
|
||||
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
|
||||
<string name="month_style_continuous">Scorrimento</string>
|
||||
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
||||
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
||||
<string name="settings_about_privacy">Politica per la Privacy</string>
|
||||
<string name="calendars_visibility_hint">Deseleziona un calendario per non mostrarlo sul dispositivo: scompariranno gli eventi e non riceverai più promemoria. Questa impostazione agisce anche su altre app calendario. Nulla viene eliminato e il calendario non scompare da nessun altro dispositivo. Puoi riselezionarlo in ogni momento.</string>
|
||||
<string name="calendars_visibility_a11y">Mostra \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Alcuni calendari non sono attivi</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula mostra solo i calendari che sono attivi sul dispositivo, e alcuni non lo sono. Per vedere gli eventi di quest\'ultimi, attivali dalle Impostazioni.</string>
|
||||
<string name="calendar_picker_missing_title">Manca un calendario?</string>
|
||||
<string name="calendar_picker_missing_summary">Può essere disattivato, in sola lettura o compilato in base ai tuoi contatti. Gestisci qui i tuoi calendari.</string>
|
||||
<string name="calendars_state_read_only">Sola lettura</string>
|
||||
<string name="calendars_state_not_synced">Non sincronizzato su questo dispositivo</string>
|
||||
<string name="calendars_state_managed">Compilato dai tuoi contatti</string>
|
||||
<string name="calendars_managed_delete_locked">Questo calendario è compilato in base ai tuoi contatti, quindi Calendula lo creerà di nuovo alla prossima sincronizzazione. Per eliminarlo disattiva l\'opzione \"Date Speciali\" nelle Impostazioni.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="month_style_continuous_summary">Ogni mese è sotto la sua testata, con un piccolo spazio a separarlo dal mese successivo.</string>
|
||||
<string name="month_style_dense">Settimane continue</string>
|
||||
<string name="month_style_dense_summary">Le settimane scorrono senza interruzione, ogni mese succede a quello precedente.</string>
|
||||
<string name="month_style_split">Diviso</string>
|
||||
<string name="month_style_split_summary">Una griglia compatta indica i giorni con eventi. Gli eventi del giorno selezionato sono elencati sotto.</string>
|
||||
<string name="month_split_no_events">Nulla in programma</string>
|
||||
<string name="month_split_expand">Mostra il mese completo</string>
|
||||
<string name="month_split_collapse">Mostra gli eventi del giorno</string>
|
||||
<string name="settings_event_duration">Durata standard</string>
|
||||
<string name="settings_event_duration_hint">Quanto durano gli eventi se non modifichi l\'orario di fine. Non ha effetto sugli eventi giornalieri.</string>
|
||||
<string name="settings_calendar_durations_title">Durata per calendario</string>
|
||||
<string name="settings_calendar_durations_hint">Assegna a ciascun calendario la sua durata standard (e.g. 8 ore per il calendario dei turni lavorativi).</string>
|
||||
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Usa la durata standard (%1$s)</string>
|
||||
<string name="settings_allday_reminder_fires_at">Promemoria alle %1$s</string>
|
||||
<string name="settings_group_look">Aspetto e Comportamento</string>
|
||||
<string name="settings_group_data">Dati</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<string name="settings_group_about">A proposito</string>
|
||||
<string name="settings_widgets_subtitle">Widget Agenda e riquadro Impostazioni rapide</string>
|
||||
<string name="settings_backup_subtitle">Export, Import, backup automatici</string>
|
||||
<string name="settings_section_widgets">Widget & Riquardi</string>
|
||||
<string name="settings_widgets_hint">Impostazioni per i widget e il riquardo delle Impostazioni rapide. Aggiungi i widget tenendo premuto sulla home.</string>
|
||||
<string name="settings_section_backup">Backup e ripristino</string>
|
||||
<string name="settings_views_all_header">Tutte le viste</string>
|
||||
<string name="settings_week_day_header">Giorno e settimana</string>
|
||||
<string name="settings_default_view_hint">La vista di default all\'apertura dell\'app.</string>
|
||||
<string name="settings_week_start_hint">Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget.</string>
|
||||
<string name="settings_time_format_hint">Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema.</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
|
||||
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
|
||||
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
|
||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваше устройство.</string>
|
||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваш девайс.</string>
|
||||
<string name="permission_request_button">Дать доступ к календарю</string>
|
||||
<string name="permission_denied_title">В доступе к календарю отказано</string>
|
||||
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
|
||||
<string name="permission_open_settings_button">Открыть настройки телефона</string>
|
||||
<string name="permission_retry_button">Попытайтесь снова</string>
|
||||
<string name="permission_benefit_private_title">Приватность по умолчанию</string>
|
||||
<string name="permission_benefit_private_body">Нет доступа к интернету, нету телеметрии — ничто не покидает ваше устройство.</string>
|
||||
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
|
||||
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
|
||||
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается.</string>
|
||||
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
|
||||
@@ -62,75 +62,4 @@
|
||||
<string name="event_edit_close">Закрыть</string>
|
||||
<string name="event_edit_save">Сохранить</string>
|
||||
<string name="event_edit_title_hint">Добавить имя</string>
|
||||
<string name="event_move_action">Перенести…</string>
|
||||
<string name="event_move_recurring_title">Перенести повторяющиеся события</string>
|
||||
<string name="event_move_done">Перенесено на %1$s</string>
|
||||
<string name="event_move_undo">Отменить</string>
|
||||
<string name="event_move_occurrence_only">Перемещение всей серии изменит дни на которые она попадёт, поэтому можно переместить только это событие.</string>
|
||||
<string name="event_move_undone">Перемещение отменено</string>
|
||||
<string name="event_move_failed">Не удалось переместить событие</string>
|
||||
<string name="event_move_write_denied">Приложению Calendula требуется доступ на запись, чтобы перемещать события</string>
|
||||
<string name="event_move_gone">Это событие больше не существует</string>
|
||||
<string name="event_move_blocked_series_end">Нельзя переместить событие дальше за пределы его серии</string>
|
||||
<string name="event_edit_managed_hint">Управляется «%1$s» — название, дата и повторение синхронизируются с ваших контактов. Напоминания, местоположения и заметки могут быть редактированы вами.</string>
|
||||
<string name="event_edit_starts">Начинается</string>
|
||||
<string name="event_edit_ends">Заканчивается</string>
|
||||
<string name="event_edit_error_end_before_start">Заканчивается раньше, чем начинается</string>
|
||||
<string name="event_edit_error_no_calendar">Нет календарей с правами на запись</string>
|
||||
<string name="event_edit_save_failed">Не удалось сохранить событие</string>
|
||||
<string name="event_edit_write_denied">Приложению Calendula требуется доступ на запись, чтобы создавать события</string>
|
||||
<string name="event_edit_more_fields">Больше полей</string>
|
||||
<string name="event_edit_add">Добавить</string>
|
||||
<string name="event_edit_add_reminder">Добавить напоминание</string>
|
||||
<string name="event_edit_remove_reminder">Удалить напоминание</string>
|
||||
<string name="event_edit_attendees">Гости</string>
|
||||
<string name="event_edit_add_guest">Добавить гостя</string>
|
||||
<string name="event_edit_add_guest_hint">Добавить гостя по почте…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Добавить из контактов</string>
|
||||
<string name="event_edit_location_from_contacts">Выбрать адрес из контактов</string>
|
||||
<string name="event_edit_remove_guest">Удалить гостя</string>
|
||||
<string name="event_edit_attendee_required">Обязательный</string>
|
||||
<string name="event_edit_attendee_optional">Необязательный</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula не отправляет приглашения. Ваша учётная запись календаря может отправить письма гостям при синхронизации.</string>
|
||||
<string name="event_edit_attendees_note_local">Хранится на этом устройстве. Никто не получит уведомление.</string>
|
||||
<string name="event_edit_reminder_custom">Другое</string>
|
||||
<string name="reminder_unit_minutes">минуты</string>
|
||||
<string name="reminder_unit_hours">часы</string>
|
||||
<string name="reminder_unit_days">дни</string>
|
||||
<string name="reminder_unit_weeks">недели</string>
|
||||
<string name="event_edit_availability">Занятость</string>
|
||||
<string name="event_edit_visibility">Видимость</string>
|
||||
<string name="event_edit_timezone_device">Часовой пояс устройства</string>
|
||||
<string name="event_edit_timezone_device_summary">Следует за вами, где бы вы ни были</string>
|
||||
<string name="event_edit_timezone_search">Поиск часовых поясов</string>
|
||||
<string name="event_edit_timezone_recent">Недавние</string>
|
||||
<string name="event_edit_timezone_all">Все часовые пояса</string>
|
||||
<string name="event_edit_timezone_none">Часовые пояса, соответсвующие «%1$s», не найдены</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s по вашему времени</string>
|
||||
<string name="event_edit_color">Цвет</string>
|
||||
<string name="event_edit_color_default">Цвет календаря</string>
|
||||
<string name="event_edit_color_custom">Свой цвет</string>
|
||||
<string name="event_edit_color_reset">Сбросить</string>
|
||||
<string name="event_edit_color_unsupported">Недоступно для этого календаря</string>
|
||||
<string name="event_edit_color_unsupported_hint">Этот календарь не публикует набор цветов. Вы можете разрешить пользовательские цвета для таких календарей в Настройках.</string>
|
||||
<string name="event_edit_color_sync_warning">Этот календарь может потерять или перезаписать цвет при следующей синхронизации.</string>
|
||||
<string name="event_edit_conflict_title">Событие изменено в другом месте</string>
|
||||
<string name="event_edit_conflict_body">Пока вы редактировали, это событие было изменено — синхронизацией или другим приложением. Что вы хотите сделать с вашими изменениями?</string>
|
||||
<string name="event_edit_conflict_overwrite">Сохранить мои изменения</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Только изменённые вами поля, перезаписывают внешние изменения</string>
|
||||
<string name="event_edit_conflict_discard">Отменить мои изменения</string>
|
||||
<string name="event_edit_conflict_discard_hint">Событие остаётся таким, как сейчас</string>
|
||||
<string name="event_edit_gone_title">Событие удалено</string>
|
||||
<string name="event_edit_gone_body">Это событие было удалено, пока вы его редактировали, например на другом устройстве. Ваши изменения больше не могут быть сохранены.</string>
|
||||
<string name="import_reminder_prompt_title">Применить ваше стандартное напоминание?</string>
|
||||
<string name="import_reminder_prompt_body_none">Это событие было импортировано без каких-либо напоминаний.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Это событие было импортировано с %1$d напоминанием.</item>
|
||||
<item quantity="few">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
<item quantity="many">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
<item quantity="other">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Применить стандартное</string>
|
||||
<string name="import_reminder_prompt_keep">Оставить как есть</string>
|
||||
<string name="event_edit_recurrence_none">Не повторяется</string>
|
||||
</resources>
|
||||
|
||||
@@ -693,15 +693,11 @@
|
||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||
<string name="import_done_added_label">Added</string>
|
||||
<string name="import_done_skipped_label">Duplicates</string>
|
||||
<string name="import_done_failed_label">Not added</string>
|
||||
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
|
||||
<string name="import_close">Close</string>
|
||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
|
||||
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
|
||||
<string name="import_button">Import</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importing %d event</item>
|
||||
@@ -723,10 +719,6 @@
|
||||
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||
<item quantity="other">Skipped %d already in this calendar.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_failed">
|
||||
<item quantity="one">%d event couldn\'t be added.</item>
|
||||
<item quantity="other">%d events couldn\'t be added.</item>
|
||||
</plurals>
|
||||
<!-- Launcher long-press shortcuts -->
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
@@ -801,42 +801,6 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents carries on past an event the provider rejects`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
// A foreign export can hold a row the provider throws on outright; that
|
||||
// must cost the user one event, not the whole file (Codeberg #225).
|
||||
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
val events = listOf(
|
||||
parsedEvent("a@x", summary = "good"),
|
||||
parsedEvent("b@x", summary = "bad"),
|
||||
parsedEvent("c@x", summary = "good"),
|
||||
)
|
||||
|
||||
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||
|
||||
assertThat(summary.imported).isEqualTo(2)
|
||||
assertThat(summary.failed).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importEvents looks the target palette up once, not per event`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
var lookups = 0
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventColorPaletteResult = { lookups++; emptyList() }
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
|
||||
|
||||
assertThat(lookups).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||
@TempDir tempDir: Path,
|
||||
@@ -861,10 +825,9 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
summary = "E",
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
|
||||
@@ -81,10 +81,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
lastExportableEventsCalendarIds = calendarIds
|
||||
return exportableEventsResult
|
||||
}
|
||||
@@ -94,17 +91,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||
|
||||
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||
val failingImportSummaries = mutableSetOf<String>()
|
||||
|
||||
override fun insertImportedEvent(
|
||||
event: ParsedIcsEvent,
|
||||
calendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
colorPalette: List<EventColorOption>,
|
||||
): Long {
|
||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||
writeError?.let { throw it }
|
||||
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||
importedEvents += event to calendarId
|
||||
return nextInsertId
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZoneId
|
||||
|
||||
class IcsExportMapperTest {
|
||||
|
||||
@@ -21,11 +20,7 @@ class IcsExportMapperTest {
|
||||
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
||||
)
|
||||
|
||||
val event = reader.toIcsEvent(
|
||||
reminderMinutes = listOf(10),
|
||||
calendarName = "Personal",
|
||||
allDayReminderTimeMinutes = NINE_AM,
|
||||
)
|
||||
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
|
||||
|
||||
assertThat(event.uid).isEqualTo("abc@host")
|
||||
assertThat(event.summary).isEqualTo("Standup")
|
||||
@@ -52,11 +47,7 @@ class IcsExportMapperTest {
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||
)
|
||||
|
||||
val event = reader.toIcsEvent(
|
||||
reminderMinutes = emptyList(),
|
||||
calendarName = null,
|
||||
allDayReminderTimeMinutes = NINE_AM,
|
||||
)
|
||||
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
|
||||
|
||||
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
||||
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||
@@ -74,32 +65,6 @@ class IcsExportMapperTest {
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||
)
|
||||
|
||||
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder is exported as its whole-day lead time`() {
|
||||
// The raw offset encodes the firing time and is normally negative; left
|
||||
// raw it would be written as a trigger after the event and dropped, so
|
||||
// the backup would come back with no reminder at all.
|
||||
val reader = MapColumnReader(
|
||||
EventExportProjection.IDX_ID to 1L,
|
||||
EventExportProjection.IDX_TITLE to "Anna Birthday",
|
||||
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
|
||||
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
|
||||
EventExportProjection.IDX_ALL_DAY to 1,
|
||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||
)
|
||||
|
||||
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
|
||||
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
|
||||
|
||||
assertThat(onTheDay.reminderMinutes).containsExactly(0)
|
||||
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NINE_AM = 9 * 60
|
||||
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
|
||||
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ImportedEventValuesTest {
|
||||
|
||||
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
|
||||
|
||||
private fun allDay(
|
||||
rrule: String? = null,
|
||||
exDates: List<String> = emptyList(),
|
||||
color: Int? = null,
|
||||
days: Int = 1,
|
||||
) = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "Anna Birthday",
|
||||
start = dayStart,
|
||||
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
|
||||
isAllDay = true,
|
||||
zoneId = "UTC",
|
||||
recurrenceRule = rrule,
|
||||
exDates = exDates,
|
||||
color = color,
|
||||
)
|
||||
|
||||
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
|
||||
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
|
||||
|
||||
@Test
|
||||
fun `a one-off carries DTEND and no recurrence`() {
|
||||
val v = values(allDay())
|
||||
assertThat(v[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring row carries DURATION instead of DTEND`() {
|
||||
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series is never zero days long`() {
|
||||
// A degenerate span would expand into no instances at all — the series
|
||||
// would simply not exist (Codeberg #225).
|
||||
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATEs ride along with the recurrence`() {
|
||||
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
|
||||
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATEs are not written without a recurrence to exclude from`() {
|
||||
val v = values(allDay(exDates = listOf("20260820")))
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported colour is written raw when the account publishes no palette`() {
|
||||
val v = values(allDay(color = -2818048))
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported colour snaps to the nearest published palette key`() {
|
||||
val palette = listOf(
|
||||
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
|
||||
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
|
||||
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
|
||||
)
|
||||
// Tomato — visually an orangered, nothing like the blue or the green.
|
||||
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
|
||||
|
||||
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
|
||||
// A raw colour alongside a key is what a palette calendar rejects.
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event with no colour touches neither colour column`() {
|
||||
val v = values(allDay())
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed event keeps its own zone and a seconds duration`() {
|
||||
val start = Instant.parse("2026-08-19T08:00:00Z")
|
||||
val event = ParsedIcsEvent(
|
||||
uid = "u@x",
|
||||
summary = "Standup",
|
||||
start = start,
|
||||
end = Instant.parse("2026-08-19T08:15:00Z"),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = "FREQ=WEEKLY",
|
||||
)
|
||||
|
||||
val v = values(event)
|
||||
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -18,7 +17,6 @@ class InstanceMapperTest {
|
||||
eventColor: Any? = null,
|
||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||
location: String? = null,
|
||||
selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
InstanceProjection.IDX_INSTANCE_ID to instanceId,
|
||||
InstanceProjection.IDX_EVENT_ID to eventId,
|
||||
@@ -30,7 +28,6 @@ class InstanceMapperTest {
|
||||
InstanceProjection.IDX_EVENT_COLOR to eventColor,
|
||||
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
|
||||
InstanceProjection.IDX_LOCATION to location,
|
||||
InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -93,20 +90,4 @@ class InstanceMapperTest {
|
||||
val inst = reader(location = "Berlin").toEventInstance()
|
||||
assertThat(inst!!.location).isEqualTo("Berlin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined invitation is marked, any other answer is not`() {
|
||||
assertThat(reader().toEventInstance()!!.isDeclined).isFalse()
|
||||
assertThat(
|
||||
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED)
|
||||
.toEventInstance()!!.isDeclined,
|
||||
).isTrue()
|
||||
listOf(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||
).forEach { status ->
|
||||
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsColorTest {
|
||||
|
||||
@Test
|
||||
fun `a raw Android colour int round-trips`() {
|
||||
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CSS3 name resolves opaque`() {
|
||||
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
|
||||
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex forms are accepted with and without alpha`() {
|
||||
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
|
||||
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a colour with no alpha byte is made opaque`() {
|
||||
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
|
||||
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusable values resolve to null`() {
|
||||
assertThat(parseIcsColorValue(null)).isNull()
|
||||
assertThat(parseIcsColorValue("")).isNull()
|
||||
// Fossify interpolates a nullable calendar straight into the property.
|
||||
assertThat(parseIcsColorValue("null")).isNull()
|
||||
assertThat(parseIcsColorValue("chartreusey")).isNull()
|
||||
assertThat(parseIcsColorValue("#abc")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
|
||||
*
|
||||
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
|
||||
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
|
||||
*
|
||||
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
|
||||
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
|
||||
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
|
||||
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
|
||||
* following midnight. Shifting those by a day would corrupt them.
|
||||
*
|
||||
* The exception — and the whole of #225 — is events mirrored from Contacts.
|
||||
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
|
||||
* lands back on the starting day and they export zero length.
|
||||
*/
|
||||
class IcsFossifyImportTest {
|
||||
|
||||
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||
|
||||
private fun fossify(vararg body: String) = (
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
|
||||
"VERSION:2.0",
|
||||
) + body + listOf("END:VCALENDAR")
|
||||
).joinToString("\r\n")
|
||||
|
||||
private fun days(event: ParsedIcsEvent): Long =
|
||||
(event.end - event.start).inWholeMilliseconds / 86_400_000L
|
||||
|
||||
@Test
|
||||
fun `an all-day event keeps the exact span the file gives it`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"UID:abc123",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"DTSTART;VALUE=DATE:19900412",
|
||||
"DTEND;VALUE=DATE:19900413",
|
||||
"X-FOSSIFY-MISSING-YEAR:0",
|
||||
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.isAllDay).isTrue()
|
||||
assertThat(days(event)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contact-mirrored birthdays export zero length and must not vanish`() {
|
||||
// The reported failure (#225). Fossify mirrors Contacts birthdays and
|
||||
// anniversaries with startTS == endTS, so the exporter's +12h rounds
|
||||
// back to the starting day: DTSTART == DTEND. Read literally these are
|
||||
// yearly series of zero-length occurrences, which the provider expands
|
||||
// into nothing at all — the birthdays import "successfully" and are
|
||||
// then nowhere to be seen.
|
||||
val text = checkNotNull(
|
||||
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
|
||||
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
|
||||
val result = parser.parse(text)
|
||||
|
||||
assertThat(result.events).hasSize(3)
|
||||
result.events.forEach {
|
||||
assertThat(it.isAllDay).isTrue()
|
||||
assertThat(days(it)).isEqualTo(1)
|
||||
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
|
||||
// Their all-day reminder encoding: "on the day", not midnight UTC.
|
||||
assertThat(it.semanticReminderMinutes()).containsExactly(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real Fossify holiday file imports unchanged`() {
|
||||
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
|
||||
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
|
||||
// it — the file is plain, conformant iCalendar.
|
||||
val text = checkNotNull(
|
||||
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
|
||||
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
|
||||
val result = parser.parse(text)
|
||||
|
||||
assertThat(result.events.map { it.summary })
|
||||
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
|
||||
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
|
||||
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event with no DTEND lasts one day`() {
|
||||
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length all-day event is widened rather than left to vanish`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260819",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(days(result.events.single())).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task is imported as an event and reported`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:Pay rent",
|
||||
"DTSTART;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
val task = result.events.single()
|
||||
assertThat(task.isTask).isTrue()
|
||||
assertThat(task.summary).isEqualTo("Pay rent")
|
||||
assertThat(days(task)).isEqualTo(1)
|
||||
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a task dated only by DUE still imports`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:File taxes",
|
||||
"DUE;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().summary).isEqualTo("File taxes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the per-event colour wins over the calendar colour`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"COLOR:tomato",
|
||||
"X-FOSSIFY-EVENT-COLOR:-2818048",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().color).isEqualTo(-2818048)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the calendar colour stands in when the event has none of its own`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the legacy Simple Calendar colour spellings are read too`() {
|
||||
val result = parser.parse(
|
||||
listOf(
|
||||
"BEGIN:VCALENDAR",
|
||||
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-SMT-CATEGORY-COLOR:-1155931",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
).joinToString("\r\n"),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
|
||||
// Both properties interpolate a nullable calendar straight into the value.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Holiday",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:null",
|
||||
"CATEGORIES:null",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T090000Z",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.color).isNull()
|
||||
assertThat(event.calendarName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CATEGORIES names the source calendar`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"CATEGORIES:Birthdays",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder written as a positive trigger is not read as a lead time`() {
|
||||
// The exporter flips the sign for reminders stored below -1
|
||||
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
|
||||
// point after the start. On an all-day event that means a time of day.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"TRIGGER:P0DT9H0M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.reminderMinutes).containsExactly(-540)
|
||||
assertThat(event.semanticReminderMinutes()).containsExactly(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
|
||||
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
|
||||
// time puts "1 day before" only six hours ahead of the UTC midnight.
|
||||
// Rounding to the nearest day would read that as "on the day".
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"TRIGGER:-P0DT6H0M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day reminder rounds to whole days before`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
// 15h before midnight == "1 day before at 09:00".
|
||||
"TRIGGER:-P0DT15H0M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed reminder keeps its exact lead time`() {
|
||||
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
"BEGIN:VALARM",
|
||||
"ACTION:DISPLAY",
|
||||
"TRIGGER:-P0DT0H10M0S",
|
||||
"END:VALARM",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series' EXDATE day codes carry over`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART;VALUE=DATE:20260819",
|
||||
"DTEND;VALUE=DATE:20260820",
|
||||
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"EXDATE:20260822",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
|
||||
// They store excluded occurrences as day codes and write them out that
|
||||
// way even for a timed series, where the property alone is ambiguous.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Gym",
|
||||
"DTSTART:20260819T170000Z",
|
||||
"DTEND:20260819T180000Z",
|
||||
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a floating EXDATE is read in the series' zone, not the device's`() {
|
||||
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
|
||||
// value is New York wall time. Reading it in the device's Berlin zone
|
||||
// would land on an instant no occurrence has, and the excluded
|
||||
// occurrence would come back.
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"DTSTART;TZID=America/New_York:20260819T100000",
|
||||
"DTEND;TZID=America/New_York:20260819T103000",
|
||||
"RRULE:FREQ=WEEKLY",
|
||||
"EXDATE:20260826T100000",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EXDATE is dropped along with an unusable recurrence rule`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Gym",
|
||||
"DTSTART:20260819T170000Z",
|
||||
"DTEND:20260819T180000Z",
|
||||
"RRULE:INTERVAL=1",
|
||||
"EXDATE:20260820",
|
||||
"END:VEVENT",
|
||||
),
|
||||
)
|
||||
|
||||
val event = result.events.single()
|
||||
assertThat(event.recurrenceRule).isNull()
|
||||
assertThat(event.exDates).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole export imports every component`() {
|
||||
val result = parser.parse(
|
||||
fossify(
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Anna Birthday",
|
||||
"UID:abc123",
|
||||
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||
"CATEGORIES:Birthdays",
|
||||
"LAST-MODIFIED:20260101T120000Z",
|
||||
"TRANSP:TRANSPARENT",
|
||||
"DTSTART;VALUE=DATE:19900412",
|
||||
"DTEND;VALUE=DATE:19900413",
|
||||
"X-FOSSIFY-MISSING-YEAR:0",
|
||||
"DTSTAMP:20260819T100000Z",
|
||||
"CLASS:PUBLIC",
|
||||
"STATUS:CONFIRMED",
|
||||
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||
"END:VEVENT",
|
||||
"BEGIN:VEVENT",
|
||||
"SUMMARY:Standup",
|
||||
"UID:def456",
|
||||
"DTSTART:20260819T080000Z",
|
||||
"DTEND:20260819T081500Z",
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
|
||||
"END:VEVENT",
|
||||
"BEGIN:VTODO",
|
||||
"SUMMARY:Pay rent",
|
||||
"UID:ghi789",
|
||||
"DTSTART;VALUE=DATE:20260901",
|
||||
"END:VTODO",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.events.map { it.summary })
|
||||
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
|
||||
// Nothing may reach the provider as a zero-length all-day row.
|
||||
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class IcsRecurrenceTest {
|
||||
|
||||
@Test
|
||||
fun `a well-formed rule is unchanged`() {
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE"))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the RRULE prefix is stripped`() {
|
||||
assertThat(sanitizeRrule("RRULE:FREQ=DAILY")).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty list part is dropped`() {
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY="))
|
||||
.isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH="))
|
||||
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nonsensical INTERVAL is dropped, not fatal`() {
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0")).isEqualTo("FREQ=DAILY")
|
||||
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every")).isEqualTo("FREQ=DAILY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule without a usable FREQ is unsalvageable`() {
|
||||
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO")).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1")).isNull()
|
||||
assertThat(sanitizeRrule("FREQ=;INTERVAL=1")).isNull()
|
||||
assertThat(sanitizeRrule("")).isNull()
|
||||
assertThat(sanitizeRrule(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FREQ is normalised to upper case`() {
|
||||
assertThat(sanitizeRrule("freq=daily;count=3")).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown parts pass through untouched`() {
|
||||
assertThat(sanitizeRrule("FREQ=YEARLY;WKST=SU;X-THING=7"))
|
||||
.isEqualTo("FREQ=YEARLY;WKST=SU;X-THING=7")
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Re-opening an occurrence must re-read it (#196): the view model outlives the
|
||||
* sheet, so an edit that changed no time would otherwise show the pre-save row.
|
||||
* The re-read stays silent — the loaded content must not blink back to the
|
||||
* skeleton on the way.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class EventDetailViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun detail(description: String?) = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = description, organizer = null, attendees = emptyList(), rrule = null,
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
|
||||
val prefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val settings = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher)
|
||||
}
|
||||
|
||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(
|
||||
CalendarSource(
|
||||
id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||
),
|
||||
)
|
||||
eventDetailResult = { detail(description()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
var stored: String? = null
|
||||
val vm = viewModel(tempDir, fakeSource { stored })
|
||||
val collector = launch(Job()) { vm.state.collect {} }
|
||||
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull()
|
||||
|
||||
// The edit screen saved a description; the tapped occurrence is unchanged.
|
||||
stored = "Bring the roadmap"
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat((vm.state.value as EventDetailUiState.Success).detail.description)
|
||||
.isEqualTo("Bring the roadmap")
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val vm = viewModel(tempDir, fakeSource { null })
|
||||
val seen = mutableListOf<EventDetailUiState>()
|
||||
val collector = launch(Job()) { vm.state.collect { seen += it } }
|
||||
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java)
|
||||
|
||||
seen.clear()
|
||||
vm.open(42L, beginMillis, endMillis)
|
||||
advanceUntilIdle()
|
||||
assertThat(seen).doesNotContain(EventDetailUiState.Loading)
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Which calendar a bulk import lands on before the user touches the picker. */
|
||||
class ImportTargetTest {
|
||||
|
||||
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = "acc@local",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
isLocal = local,
|
||||
)
|
||||
|
||||
private val calendars = listOf(
|
||||
cal(1L, "Work"),
|
||||
cal(2L, "Personal", local = true),
|
||||
cal(3L, "Birthdays", local = true),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a calendar named like the file's wins`() {
|
||||
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the name match ignores case`() {
|
||||
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unmatched name falls back to the first local calendar`() {
|
||||
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no name at all falls back to the first local calendar`() {
|
||||
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no local calendars the first of any kind is taken`() {
|
||||
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty list preselects nothing`() {
|
||||
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Which chip a tap in a month cell lands on (#187) — the geometry the tap layer
|
||||
* uses to tell "open this event" from "open this day", given that the chips take
|
||||
* no pointer input of their own.
|
||||
*/
|
||||
class ChipAtCellYTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
/** Band starts 40px down the cell; each lane is 20px tall. */
|
||||
private val bandTop = 40f
|
||||
private val laneHeight = 20f
|
||||
|
||||
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(zone),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = 0xFF2196F3.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = 0xFFF44336.toInt(),
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
|
||||
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
|
||||
|
||||
@Test
|
||||
fun `a tap on a lane resolves to the chip seated there`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight + 5f)?.eventId).isEqualTo(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day bar answers on every column it covers`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
assertThat(week.chipAt(col = col, cellY = bandTop + 5f)?.eventId).isEqualTo(1L)
|
||||
}
|
||||
// Jul 10 is past the bar's last day.
|
||||
assertThat(week.chipAt(col = 4, cellY = bandTop + 5f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap above the band is the day number, not a chip`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop - 1f)).isNull()
|
||||
assertThat(week.chipAt(col = 1, cellY = 0f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on an empty lane of a day that has chips falls through to the day`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight * 2 + 5f)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap on the overflow row opens the day rather than a hidden event`() {
|
||||
val events = (1..MAX_EVENT_ROWS + 2).map {
|
||||
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
|
||||
}
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
// The dots sit one lane below the last one the row draws.
|
||||
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
|
||||
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmeasured geometry resolves to no chip`() {
|
||||
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
|
||||
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
|
||||
).isNull()
|
||||
assertThat(
|
||||
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
|
||||
).isNull()
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Fossify//NONSGML Event Calendar//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Anna Schmidt
|
||||
UID:101
|
||||
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||
CATEGORIES:Birthdays
|
||||
LAST-MODIFIED:20250615T150640Z
|
||||
TRANSP:TRANSPARENT
|
||||
DTSTART;VALUE=DATE:19900412
|
||||
DTEND;VALUE=DATE:19900412
|
||||
X-FOSSIFY-MISSING-YEAR:0
|
||||
DTSTAMP:20260819T120000Z
|
||||
CLASS:PUBLIC
|
||||
STATUS:CONFIRMED
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
|
||||
BEGIN:VALARM
|
||||
DESCRIPTION:Reminder
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:P0DT9H0M0S
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Opa
|
||||
UID:102
|
||||
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||
CATEGORIES:Birthdays
|
||||
LAST-MODIFIED:20250615T150640Z
|
||||
TRANSP:TRANSPARENT
|
||||
DTSTART;VALUE=DATE:19700603
|
||||
DTEND;VALUE=DATE:19700603
|
||||
X-FOSSIFY-MISSING-YEAR:1
|
||||
DTSTAMP:20260819T120000Z
|
||||
CLASS:PUBLIC
|
||||
STATUS:CONFIRMED
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
|
||||
BEGIN:VALARM
|
||||
DESCRIPTION:Reminder
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:P0DT9H0M0S
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Hochzeitstag
|
||||
UID:103
|
||||
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||
CATEGORIES:Anniversaries
|
||||
LAST-MODIFIED:20250615T150640Z
|
||||
TRANSP:TRANSPARENT
|
||||
DTSTART;VALUE=DATE:20050917
|
||||
DTEND;VALUE=DATE:20050917
|
||||
X-FOSSIFY-MISSING-YEAR:0
|
||||
DTSTAMP:20260819T120000Z
|
||||
CLASS:PUBLIC
|
||||
STATUS:CONFIRMED
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
|
||||
BEGIN:VALARM
|
||||
DESCRIPTION:Reminder
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:P0DT9H0M0S
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -1,25 +0,0 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
CALSCALE:GREGORIAN
|
||||
PRODID:Fossify Calendar Holiday Generator
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT1H
|
||||
BEGIN:VEVENT
|
||||
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
|
||||
SUMMARY:Neujahr
|
||||
DTSTAMP:20260526T132315Z
|
||||
DTSTART;VALUE=DATE:19800101
|
||||
DTEND;VALUE=DATE:19800102
|
||||
STATUS:CONFIRMED
|
||||
RRULE:FREQ=YEARLY
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
|
||||
SUMMARY:Heilige Drei Könige
|
||||
DTSTAMP:20260526T132315Z
|
||||
DTSTART;VALUE=DATE:19800106
|
||||
DTEND;VALUE=DATE:19800107
|
||||
STATUS:CONFIRMED
|
||||
RRULE:FREQ=YEARLY
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -346,69 +346,6 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
|
||||
be copied, because our failure mode includes a broadcast that arrives with no
|
||||
alert row behind it.
|
||||
|
||||
## Importing foreign `.ics`
|
||||
|
||||
`IcsParser` is deliberately liberal, because the files it is handed were written
|
||||
by other people's calendars. What that costs us, learned from reading the Simple
|
||||
Calendar / Fossify exporter (#225):
|
||||
|
||||
**An all-day event may never reach the provider zero days long.** This is what
|
||||
#225 was: Fossify mirrors Contacts birthdays and anniversaries with
|
||||
`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)`
|
||||
rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally
|
||||
that is a yearly series of zero-length occurrences, which the provider expands
|
||||
into no instances at all — the import reports success and the birthdays are
|
||||
nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an
|
||||
all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with
|
||||
no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day
|
||||
`DURATION` at `P1D`.
|
||||
|
||||
Do **not** generalise that into correcting the producer's `DTEND` by sniffing its
|
||||
`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a
|
||||
UI-created all-day event at *noon* of its last day
|
||||
(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row
|
||||
came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight.
|
||||
Shifting those would push every one of them out by a day. The holiday files
|
||||
bundled inside Fossify are a second trap: their `PRODID` reads
|
||||
`Fossify Calendar Holiday Generator` and their content is plain conformant
|
||||
iCalendar. Both that file and a contact-birthday export are kept as fixtures in
|
||||
`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the
|
||||
conformant one.
|
||||
|
||||
**One bad event must not cost the file.** The provider validates `RRULE` through
|
||||
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
|
||||
read. `sanitizeRrule` drops empty and malformed parts before the write, and
|
||||
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
||||
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
|
||||
"couldn't read this file".
|
||||
|
||||
Smaller dialect handling: `VTODO` components import as events (Calendula models
|
||||
no tasks; dropping them silently lost half of some exports), `CATEGORIES` stands
|
||||
in for the `X-WR-CALNAME` Fossify never writes and preselects a target calendar
|
||||
of that name, bare `EXDATE` day codes on a timed series are resolved against the
|
||||
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
|
||||
to `DTSTART`'s zone, not the device's), and a `VALARM` trigger pointing *after*
|
||||
the start — which is how that family encodes "on the day at 09:00" — is read as
|
||||
zero days before rather than clamped to a lead time of zero.
|
||||
|
||||
All-day reminder offsets are **whole days, rounded up** in both directions. A
|
||||
file's raw offset is `days × 1440 − timeOfDay`, so rounding to the nearest day
|
||||
would drop a day for every producer whose all-day notifications fire after noon
|
||||
("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
|
||||
mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
|
||||
into it and is normally *negative*, which the writer used to discard as a trigger
|
||||
after the event — so `toIcsEvent` decodes it back through
|
||||
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||
its own setting.
|
||||
|
||||
Colour arrives as a raw ARGB from an app with no idea which account it is landing
|
||||
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
|
||||
`buildImportedEventValues` snaps the imported colour to the nearest published key
|
||||
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
|
||||
palette. The single-event review form deliberately keeps no colour at all: its
|
||||
first question is which calendar to use, and answering it clears the colour
|
||||
anyway.
|
||||
|
||||
## Testing
|
||||
|
||||
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
إصلاحات
|
||||
• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه.
|
||||
• التعديل يظهر فور إعادة فتح الحدث.
|
||||
• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل.
|
||||
• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع.
|
||||
|
||||
تغييرات
|
||||
• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به.
|
||||
@@ -1,8 +0,0 @@
|
||||
Behoben
|
||||
• Ein Tippen auf einen Termin in der Monatsansicht öffnet ihn statt des Tages.
|
||||
• Eine Änderung ist sofort sichtbar, wenn der Termin neu geöffnet wird.
|
||||
• Die Pfeile des Monats-Widgets sterben nicht mehr nach wenigen Tipps – eine zu große Aktualisierung riss den Widget-Host mit.
|
||||
• „Heute“ springt in nahtlosen Wochen genau auf die aktuelle Woche; Wochen- und Tagesansicht haben rechts wieder Abstand.
|
||||
|
||||
Geändert
|
||||
• Ein abgelehnter Termin ist überall durchgestrichen und erinnert nicht mehr.
|
||||
@@ -1,8 +0,0 @@
|
||||
Fixed
|
||||
• Tapping an event in month view now opens the event instead of its day.
|
||||
• An edit shows straight away when you re-open the event.
|
||||
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
||||
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
||||
|
||||
Changed
|
||||
• A meeting you declined is struck through everywhere and no longer reminds you.
|
||||
@@ -1,8 +0,0 @@
|
||||
Fixed
|
||||
• Tapping an event in month view now opens the event instead of its day.
|
||||
• An edit shows straight away when you re-open the event.
|
||||
• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host.
|
||||
• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge.
|
||||
|
||||
Changed
|
||||
• A meeting you declined is struck through everywhere and no longer reminds you.
|
||||
@@ -1,8 +0,0 @@
|
||||
Corregido
|
||||
• Tocar un evento en la vista de mes abre el evento, no su día.
|
||||
• Un cambio se ve en cuanto vuelves a abrir el evento.
|
||||
• Las flechas del widget de mes ya no se quedan muertas tras unos toques: una actualización demasiado grande tumbaba el host de widgets del launcher.
|
||||
• «Hoy» en semanas continuas llega justo a la semana actual, y las vistas de día y semana vuelven a tener margen a la derecha.
|
||||
|
||||
Cambios
|
||||
• Un evento que has rechazado aparece tachado en todas partes y ya no avisa.
|
||||
@@ -1,8 +0,0 @@
|
||||
Corrigé
|
||||
• Appuyer sur un événement en vue mois ouvre l'événement, pas sa journée.
|
||||
• Une modification apparaît dès que l'événement est rouvert.
|
||||
• Les flèches du widget mois ne meurent plus après quelques appuis : une mise à jour trop lourde emportait l'hôte de widgets du lanceur.
|
||||
• « Aujourd'hui » en semaines continues arrive pile sur la semaine en cours ; les vues jour et semaine ont de nouveau une marge à droite.
|
||||
|
||||
Modifié
|
||||
• Un événement refusé est barré partout et ne vous rappelle plus rien.
|
||||
@@ -1,8 +0,0 @@
|
||||
Corretto
|
||||
• Toccare un evento nella vista mese apre l'evento, non il suo giorno.
|
||||
• Una modifica si vede subito riaprendo l'evento.
|
||||
• Le frecce del widget mese non muoiono più dopo pochi tocchi: un aggiornamento troppo grande abbatteva l'host dei widget del launcher.
|
||||
• «Oggi» nelle settimane continue arriva esattamente sulla settimana corrente, e le viste giorno e settimana hanno di nuovo un margine a destra.
|
||||
|
||||
Modifiche
|
||||
• Un evento rifiutato è barrato ovunque e non invia più promemoria.
|
||||
@@ -1,8 +0,0 @@
|
||||
Poprawki
|
||||
• Dotknięcie wydarzenia w widoku miesiąca otwiera wydarzenie, a nie jego dzień.
|
||||
• Zmiana jest widoczna od razu po ponownym otwarciu wydarzenia.
|
||||
• Strzałki widżetu miesiąca nie zamierają już po kilku dotknięciach – zbyt duża aktualizacja kładła host widżetów launchera.
|
||||
• „Dziś” w ciągłych tygodniach trafia dokładnie w bieżący tydzień, a widoki dnia i tygodnia znów mają margines z prawej.
|
||||
|
||||
Zmiany
|
||||
• Odrzucone wydarzenie jest wszędzie przekreślone i nie przypomina o sobie.
|
||||
@@ -1,8 +0,0 @@
|
||||
Corrigido
|
||||
• Tocar em um evento na visualização de mês abre o evento, e não o seu dia.
|
||||
• Uma alteração aparece assim que o evento é reaberto.
|
||||
• As setas do widget de mês não morrem mais depois de alguns toques: uma atualização grande demais derrubava o host de widgets do launcher.
|
||||
• "Hoje" nas semanas contínuas chega exatamente na semana atual, e as visualizações de dia e semana voltaram a ter margem à direita.
|
||||
|
||||
Alterações
|
||||
• Um evento recusado fica riscado em todo lugar e não lembra mais você.
|
||||
@@ -1,8 +0,0 @@
|
||||
Corrigido
|
||||
• Tocar num evento na vista de mês abre o evento e não o seu dia.
|
||||
• Uma alteração aparece assim que o evento é reaberto.
|
||||
• As setas do widget de mês já não morrem ao fim de alguns toques: uma atualização demasiado grande derrubava o anfitrião de widgets do launcher.
|
||||
• «Hoje» nas semanas contínuas chega mesmo à semana atual, e as vistas de dia e semana voltam a ter margem à direita.
|
||||
|
||||
Alterações
|
||||
• Um evento recusado fica riscado em todo o lado e deixa de lembrar.
|
||||
@@ -1,8 +0,0 @@
|
||||
Исправлено
|
||||
• Нажатие на событие в виде месяца открывает событие, а не его день.
|
||||
• Изменение видно сразу при повторном открытии события.
|
||||
• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера.
|
||||
• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа.
|
||||
|
||||
Изменения
|
||||
• Отклонённое событие везде зачёркнуто и больше не напоминает о себе.
|
||||
@@ -1,8 +0,0 @@
|
||||
修复
|
||||
• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。
|
||||
• 编辑后再次打开事件,改动会立即显示。
|
||||
• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。
|
||||
• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。
|
||||
|
||||
变更
|
||||
• 已拒绝的事件在各处均以删除线标记,并且不再提醒。
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
ksp = "2.3.11"
|
||||
ksp = "2.3.10"
|
||||
hilt = "2.60.1"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
|
||||
Reference in New Issue
Block a user