Compare commits

...
Author SHA1 Message Date
Jean-Luc Makiola 2e4a428a9c Record what #304's device actually looked like (#324) 2026-09-23 17:43:24 +02:00
Jean-Luc Makiola 50ac6d758b Seat as many month chips as the row has height for (#323) 2026-09-23 17:15:35 +02:00
Jean-Luc Makiola 4f74f36bf5 Collapse the split grid to the rows its month spans (#320) 2026-09-23 17:15:27 +02:00
Jean-Luc Makiola a75e1a8c2f Give a month chip back some of its width (#319) 2026-09-23 17:15:19 +02:00
Jean-Luc Makiola 4c560a9aa4 Size the week-number gutter to the number it seats (#318) 2026-09-23 17:15:08 +02:00
Jean-Luc Makiola 2dc22dc480 Show the title on short week and day blocks (#317) 2026-09-23 17:14:29 +02:00
Jean-Luc Makiola 714f45d869 Tighten label tracking so the grids fit more text (#316) 2026-09-23 17:11:10 +02:00
Jean-Luc Makiolaandmakiolaj 2670269776 Give Backup & restore its loading and failure states (#314)
Backup & restore built its rows straight from the calendar list. That list
starts empty, catches to empty on a provider error, and can legitimately load
with nothing exportable and nothing importable — all three rendered as a blank
page with no buttons at all.

- `BackupUiState` (Loading / Failure / Ready) on `CalendarsViewModel`, mirroring
  the `FilterUiState` pattern already used elsewhere.
- The restore row stays on screen whenever the screen shows anything at all.
  When no calendar can receive events it says so and routes to the calendar
  manager — whose row labels already explain per calendar *why* (read-only,
  managed, not synced) — instead of disappearing.
- New `FailureReason.NoImportTarget` for the case the list cannot explain on its
  own: calendars exist and are visible, but none can take an event.
- Export stays available on an all-hidden device. It reads the provider
  directly, so `calendarListFailure` on its own would have hidden a working
  action; the failure is raised only when both halves are dead.
- `LocalManageCalendars` is now provided around `BackupScreen` (it sits directly
  over the manager, so the way out is popping back).

Also adds `IcsGoogleImportTest` over a Google Calendar export fixture:
`VTIMEZONE` with `X-LIC-LOCATION`, `TZID`-qualified `DTSTART`/`EXDATE`, folded
`DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID` overrides as separate
`VEVENT`s, escaped commas, all-day alarms. All 13 pass against the parser
unchanged — the read side needed no fix.

**Does not close #304.** The blank screen this fixes is real and reachable, but
it needs the device to have no usable calendar at all; a working Google calendar
satisfies `isEventTarget`, so that reporter would have seen the restore row. The
actual cause there is still open — questions posted on the issue. This stands on
its own as the missing loading and failure states.

Refs #304

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/314
2026-09-22 10:42:57 +02:00
Jean-Luc Makiolaandmakiolaj bdcf2b3823 Stop a fully zoomed-out timeline from scrolling (#315)
The pinch clamped its lower bound to `ceil(fillPx)`. Rounding a fill height up
by the fraction of a pixel that 24 hours don't divide the viewport into makes
the timeline up to one pixel per hour taller than the viewport those hours are
supposed to fill — roughly 24px of leftover scroll, enough to bounce off
Android's overscroll stretch. `FitDay` resolves to the unrounded height and sits
still, so the two ways of reaching "the whole day on one screen" disagreed.
That inconsistency is what #290 is about.

Clamps to `fillPx` itself. The whole-pixel rounding exists so the hour gutter's
24 stacked boxes share a grid with the lines and blocks drawn at the fractional
height, and it still applies everywhere the pinch is free to move. The floor is
the one height where matching `FitDay` matters more — and because it is the
clamp *result* rather than a bound the gesture is merely held against, the pinch
lands on it exactly, so the next frame reads it back unchanged and the focal
anchor gets no correction to apply.

Week and Day both measure their viewport inside a `BoxWithConstraints` below the
all-day strip, so the strip appearing only changes the height both paths agree
on — that half of the report needed no change.

Tests: the two cases that encoded the old rounding are updated (the dead-space
invariant still holds, now exactly rather than by a pixel), plus one for the
reported symptom and one asserting the pinch floor equals what `FitDay`
resolves to at the same viewport.

Closes #290

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/315
2026-09-20 22:28:35 +02:00
Jean-Luc Makiolaandmakiolaj 5c20c5f6eb Release v2.20.4 (#313)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 13m14s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m26s
Patch release rounding up the three issues in the 2.20.4 milestone.

- An event opened from a reminder or a home-screen widget came back on every
  later launch — an error screen once that event had been deleted. Android
  hands back the intent that started the task each time the activity is
  rebuilt, and the event channels acted on it every time instead of once
  (#309).
- "Today" did nothing in the Agenda once you had scrolled ahead: the agenda is
  browsed by scrolling, not by moving its date, so resetting the date alone
  changed nothing. It now scrolls the list back, and the button stays offered
  while the list is scrolled away (#305).
- The self-hosted F-Droid repo filed the app under "Time" instead of
  "Calendar & Agenda" (#294).

Plus the release commit: CHANGELOG section, versionName 2.20.4 / versionCode
22004, and the en-US 22004 changelog. Merging this to main triggers the release
pipeline.

Closes #309
Closes #305
Closes #294

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/313
2026-09-20 21:16:11 +02:00
Jean-Luc Makiola c4bb7ae0b2 Translations update from Weblate (#300)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/300
2026-09-20 21:11:38 +02:00
Julie 0c985a280c Translated using Weblate (Slovak)
Currently translated at 42.3% (250 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-17 08:36:18 +00:00
Weblate 17ebb9fd42 Translated using Weblate (Slovak)
Currently translated at 42.3% (250 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-17 08:36:18 +00:00
James Trivette cadc2b14db Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Weblate 1fdca903be Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
James Trivette 5e7658aa80 Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Weblate bea31032fb Translated using Weblate (Czech)
Currently translated at 100.0% (590 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
James Trivette f7c1dbdbc4 Translated using Weblate (Czech)
Currently translated at 84.9% (501 of 590 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-15 17:38:23 +00:00
Jean-Luc Makiolaandmakiolaj a1ed010963 docs(privacy): point the controller contact at support@ (#307)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
The Controller section of `docs/PRIVACY.md` names the address a reader writes
to in order to exercise their rights under Art. 15-21 GDPR. That is a different
kind of mail from a project enquiry, so it now points at
`support@jeanlucmakiola.de` instead of `business@jeanlucmakiola.de`.

Same change is going into Agendula, and the website's own Datenschutzerklärung
already names `support@` in its § 1. `business@` stays reachable — it is just no
longer the published address anywhere.

`updated:` bumped to 2026-09-15, since the published policy changes.

Two notes for review:

- **Target branch.** CONTRIBUTING says to target the release branch named by the
  issue's milestone. The open milestones (2.21.0 and later) have no branches
  yet, so per the same section this targets `main` and says so — retarget it if
  you would rather it waited for one.
- **No changelog entry.** The entries in `CHANGELOG.md` describe what changed
  for the person using the app, and nothing about the app changed here. Happy to
  add one under a new `## [Unreleased]` if you want the contact change to show
  up in the release notes.

`business@` needs to keep accepting mail until this is live on
jeanlucmakiola.de/calendula/privacy and the Play Console listing has caught up.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/307
2026-09-15 16:10:29 +02:00
Jean-Luc Makiola 7be6b7ac31 Release v2.20.3 (#302)
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 57s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 12m49s
2026-09-12 17:09:15 +02:00
56 changed files with 1935 additions and 315 deletions
+31
View File
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12
### Fixed
- **A custom agenda range crashed the app in French.** Choosing a custom range
for the agenda — or simply typing a number into that field — closed the app on
any phone set to French. The French translation of the "%d days" label was
missing the wording French uses for counts of two and above, and Android has
nothing to fall back on when that particular form is absent, so every value
except one day was affected ([#297], [#298]).
## [2.20.2] — 2026-09-12 ## [2.20.2] — 2026-09-12
### Fixed ### Fixed
@@ -1683,3 +1711,6 @@ automatically, with zero telemetry and no internet permission.
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239 [#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297 [#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298 [#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
+12 -8
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22002 versionCode = 22004
versionName = "2.20.2" versionName = "2.20.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -113,12 +113,16 @@ android {
lint { lint {
// Community translations are expected to be partial — a missing string // Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on // falls back to the English base at runtime — so don't fail the build on
// it. Likewise a translated <plurals> may not fill every CLDR quantity // it. A translated <plurals> may likewise skip a CLDR quantity form its
// form its locale defines (e.g. Arabic needs "zero"); the missing form // locale defines (e.g. Arabic "zero"): Android falls back to "other" for
// falls back to "other" at runtime, so MissingQuantity is informational // any form it cannot find, so MissingQuantity is informational too.
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/ // What a translation must NOT skip is "other" itself — nothing falls back
// check_translations.py guards the same invariants with clearer, // for that one, not even the base locale, so it throws
// translator-facing messages. // Resources$NotFoundException at runtime (Codeberg #297/#298).
// MissingQuantity doesn't tell the two cases apart, so that invariant is
// enforced by scripts/check_translations.py instead, with a clearer
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
// fatal.
informational += listOf("MissingTranslation", "MissingQuantity") informational += listOf("MissingTranslation", "MissingQuantity")
} }
@@ -113,11 +113,26 @@ class MainActivity : AppCompatActivity() {
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge() applyEdgeToEdge()
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull() requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -242,6 +257,16 @@ class MainActivity : AppCompatActivity() {
intent.editEventKeyOrNull()?.let { requestedEditKey = it } intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/** /**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`) * The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the * or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -253,6 +253,13 @@ enum class FailureReason {
PermissionRevoked, PermissionRevoked,
NoCalendarsConfigured, NoCalendarsConfigured,
AllCalendarsHidden, AllCalendarsHidden,
/**
* Calendars exist and are switched on, but none can receive an event: every
* one is read-only, app-managed, or not synced to this device. Distinct from
* [AllCalendarsHidden], which a visibility switch fixes.
*/
NoImportTarget,
ProviderUnavailable, ProviderUnavailable,
EventNotFound, EventNotFound,
Unknown, Unknown,
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
} }
when { when {
component != null -> { component != null -> {
// A component whose END line never arrives ends at the next
// one's BEGIN, not at that one's END: reading to the far END
// would fold two events into one body, where the later
// properties overwrite the earlier and one event is lost.
val end = indexOfEnd(lines, i + 1, component) val end = indexOfEnd(lines, i + 1, component)
val body = indexOfNextComponent(lines, i + 1, end)
parseComponent( parseComponent(
body = lines.subList(i + 1, end), body = lines.subList(i + 1, body),
fileCalendarName = calendarName, fileCalendarName = calendarName,
warnings = warnings, warnings = warnings,
isTask = component == "VTODO", isTask = component == "VTODO",
)?.let(events::add) )?.let(events::add)
i = end + 1 i = if (body < end) body else end + 1
} }
line.isBegin("VTIMEZONE") -> { line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database. // Skipped wholesale; TZIDs resolve against the OS tz database.
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
} }
/** Index of the matching `END:<component>` at/after [from], or list end. */ /** Index of the matching `END:<component>` at/after [from], or list end. */
/**
* Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary.
*/
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
var i = from
while (i < end) {
val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
return i
}
i++
}
return end
}
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from var i = from
while (i < lines.size) { while (i < lines.size) {
@@ -197,10 +197,21 @@ fun CalendarHost(
// is "restore a backup", not "add this one event". An externally opened .ics // is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form. // keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) } var importForceMany by remember { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
// calendar and the next file means walking in through Settings again.
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
// One import run. The import VM lives in the Activity's store, so this is
// what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) }
LaunchedEffect(requestedImportUri) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++
onImportConsumed() onImportConsumed()
} }
} }
@@ -510,11 +521,28 @@ fun CalendarHost(
importUri?.let { uri -> importUri?.let { uri ->
ImportScreen( ImportScreen(
uri = uri, uri = uri,
session = importSession,
forceMany = importForceMany, forceMany = importForceMany,
onClose = { importUri = null }, onClose = {
onManageCalendars = { showCalendars = true }, importUri = null
// Back to the surface the restore started from, ready for
// the next file.
showCalendars = calendarsAfterImport
showBackup = backupAfterImport
calendarsAfterImport = false
backupAfterImport = false
},
onManageCalendars = {
// The manager is where this leads, so it must not be
// reopened underneath on close.
calendarsAfterImport = false
backupAfterImport = false
showCalendars = true
},
onOpenSingle = { form -> onOpenSingle = { form ->
importUri = null importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File importFormSource = ImportSource.File
importForm = form importForm = form
}, },
@@ -549,15 +577,29 @@ fun CalendarHost(
visible = showBackup, visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
// Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings
// (#304). Coming from the manager the flag is already set, so this
// is the same pop-back there.
CompositionLocalProvider(
LocalManageCalendars provides {
showCalendars = true
showBackup = false
},
) { ) {
BackupScreen( BackupScreen(
onBack = { showBackup = false }, onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen and // Restore runs the normal .ics import, and both this screen
// the manager that can have opened it are declared above the // and the manager that can have opened it are declared above
// import overlays — so both have to step aside. // the import overlays — so both have to step aside.
onImport = { onImport = {
importUri = it importUri = it
importForceMany = true importForceMany = true
importSession++
backupAfterImport = true
calendarsAfterImport = showCalendars
showBackup = false showBackup = false
showCalendars = false showCalendars = false
}, },
@@ -565,6 +607,7 @@ fun CalendarHost(
} }
} }
} }
}
/** Persists the view back stack across config change / process death by ordinal. */ /** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>( private val viewStackSaver = listSaver<List<CalendarView>, Int>(
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@@ -32,6 +34,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -106,6 +110,27 @@ fun AgendaScreen(
} }
val successState = state as? AgendaUiState.Success val successState = state as? AgendaUiState.Success
// The agenda window always starts at the anchor, so moving through it is a
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@@ -139,14 +164,15 @@ fun AgendaScreen(
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = viewModel::goToToday, onToday = jumpToToday,
) )
}, },
floatingActionButton = { floatingActionButton = {
CalendarFabColumn( CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar, todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action), todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday, onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) }, onCreate = { onCreateEvent(anchor, null) },
) )
}, },
@@ -195,6 +221,7 @@ fun AgendaScreen(
state = state, state = state,
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday, showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -298,6 +325,7 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) { ) {
when (state) { when (state) {
AgendaUiState.Loading -> Box(modifier) AgendaUiState.Loading -> Box(modifier)
@@ -326,12 +354,16 @@ internal fun AgendaContent(
enabled = showToday && state.anchor == state.today, enabled = showToday && state.anchor == state.today,
) )
if (days.isEmpty()) { if (days.isEmpty()) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM, dimPast = pastDisplay == PastEventDisplay.DIM,
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -349,6 +381,7 @@ private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
listState: LazyListState,
dimPast: Boolean, dimPast: Boolean,
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -357,6 +390,7 @@ private fun AgendaList(
) { ) {
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
@@ -4,9 +4,11 @@ import android.net.Uri
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -80,18 +84,13 @@ fun BackupScreen(
onImport: (Uri) -> Unit, onImport: (Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val state by viewModel.backupState.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its // Exports everything eligible (null); the per-calendar selector owns its
// own launcher. // own launcher.
val createBackup = rememberLauncherForActivityResult( val createBackup = rememberLauncherForActivityResult(
@@ -132,31 +131,120 @@ fun BackupScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_backup), title = stringResource(R.string.settings_section_backup),
onBack = onBack, onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
) { ) {
HintText(stringResource(R.string.calendars_backup_hint)) when (val s = state) {
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
autoBackup = autoBackup,
viewModel = viewModel,
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
onPickFolder = { runCatching { pickFolder.launch(null) } },
onExportAll = {
runCatching { createBackup.launch(defaultBackupName()) }
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
)
}
}
if (exportable.isNotEmpty()) { // Gated on Ready rather than resetting the flag when the state leaves it:
// flipping it here would be a write during composition.
(state as? BackupUiState.Ready)?.let { ready ->
if (showExportPicker) {
ExportCalendarPicker(
calendars = ready.exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = viewModel::setAutoBackupIntervalMinutes,
onDismiss = { showInterval = false },
)
}
}
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
/** Default file name for a one-shot export. */
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when there are local calendars to export), restore,
* and the automatic-backup block. Restore is always on screen — when nothing can
* receive the events it says so and routes to the calendar manager rather than
* disappearing, which is what #304 reported as an invisible button.
*/
@Composable
private fun BackupContent(
exportable: List<CalendarSource>,
canImport: Boolean,
autoBackup: AutoBackupUiState,
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
// calendar can receive events it explains that and offers the way to fix it.
val restoreSummary = if (canImport) {
stringResource(R.string.calendars_restore_hint)
} else {
stringResource(R.string.calendars_restore_unavailable)
}
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
return
}
HintText(stringResource(R.string.calendars_backup_hint))
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_backup_action), title = stringResource(R.string.calendars_backup_action),
position = Position.Top, position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) }, leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
// A single exportable calendar skips the selector. // A single exportable calendar skips the selector.
if (exportable.size == 1) { onClick = if (exportable.size == 1) onExportAll else onExportPick,
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_restore_action), title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint), summary = restoreSummary,
position = Position.Middle, position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) }, leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, onClick = onRestoreClick,
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_auto_backup), title = stringResource(R.string.calendars_auto_backup),
@@ -174,44 +262,16 @@ fun BackupScreen(
summary = rememberFolderName(autoBackup.folderUri) summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset), ?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle, position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } }, onClick = onPickFolder,
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval), title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes), summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom, position = Position.Bottom,
onClick = { showInterval = true }, onClick = onEditInterval,
) )
HintText(backupStatusText(autoBackup.status)) HintText(backupStatusText(autoBackup.status))
} }
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = exportable,
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = viewModel::setAutoBackupIntervalMinutes,
onDismiss = { showInterval = false },
)
}
} }
/** /**
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.calendars
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isEventTarget
/**
* State of the Backup & restore screen (#69). Three states, because the calendar
* list it is derived from has all three: it arrives empty, it can throw, and
* "loaded but nothing to offer" is a real outcome the screen used to render as a
* blank page (#304).
*/
sealed interface BackupUiState {
data object Loading : BackupUiState
data class Failure(val reason: FailureReason) : BackupUiState
/**
* At least one half of the screen works. [exportable] may be empty (restore
* only) and [canImport] may be false (export only), but never both — that is
* a [Failure].
*/
data class Ready(
val exportable: List<CalendarSource>,
val canImport: Boolean,
) : BackupUiState
}
/**
* What the screen can offer for this calendar list.
*
* Export covers the app's own local calendars; managed special-dates mirrors are
* rebuilt from contacts, so they are excluded. Restore can target anything the
* import picker offers ([isEventTarget]).
*
* A failure is raised only when *neither* is possible — deliberately not
* [calendarListFailure] on its own, which would call an all-hidden device a
* failure while its local calendars are still perfectly exportable. When both
* halves are dead the list itself usually says why (no calendars, everything
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
* calendars exist and are visible but every one of them is read-only, managed or
* not synced to the device.
*/
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
if (exportable.isEmpty() && !canImport) {
return BackupUiState.Failure(
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
)
}
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
}
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* The Backup & restore screen's own view of that list, with the loading and
* failure states [calendars] flattens away — it starts empty and catches to
* empty, which that screen used to render as a blank page (#304).
*/
val backupState: StateFlow<BackupUiState> =
repository.calendars()
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading,
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */ /** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine( val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled, settingsPrefs.autoBackupEnabled,
@@ -32,6 +32,68 @@ val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */ /** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp val BLOCK_TEXT_INSET = 2.dp
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberTrimmedLineHeight(titleStyle)
// Packed, so a second line costs what the first did rather than a whole
// Material line box — the gap between two lines of a wrapped title is the
// one place a block pays that leading twice (#190).
val titleLeading = titleLine
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/** Most lines a time label may wrap over before it is worth more than a title line. */ /** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2 const val MAX_TIME_LINES = 2
@@ -70,9 +132,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
*/ */
@Composable @Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int { fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) { val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.asEventTime(),
} )
return if (spare >= timeLineHeight) { return if (spare >= timeLineHeight) {
blockTextLines( blockTextLines(
text = label, text = label,
@@ -108,8 +170,10 @@ fun BlockTitle(
Text( Text(
text = title, text = title,
modifier = modifier, modifier = modifier,
style = MaterialTheme.typography.labelMedium style = rememberPackedLines(
MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }, .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
),
maxLines = maxLines, maxLines = maxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -141,6 +205,8 @@ fun BlockTimeLabel(
MaterialTheme.motionScheme.fastEffectsSpec() MaterialTheme.motionScheme.fastEffectsSpec()
} }
val overflow = eventTitleOverflow(singleLine = maxLines == 1) val overflow = eventTitleOverflow(singleLine = maxLines == 1)
// Regular weight against the title's medium above it (#219).
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
Crossfade( Crossfade(
targetState = label, targetState = label,
animationSpec = spec, animationSpec = spec,
@@ -149,8 +215,7 @@ fun BlockTimeLabel(
) { text -> ) { text ->
Text( Text(
text = text, text = text,
// Regular weight against the title's medium above it (#219). style = style,
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = maxLines, maxLines = maxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -4,19 +4,36 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.CalendarContract import android.provider.CalendarContract
import android.provider.Settings import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.EditOff
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.EventBusy
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.SyncProblem
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -26,52 +43,150 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
/** /**
* Full-screen failure state shared by every calendar screen (spec §7). * Full-screen failure state shared by every calendar screen (spec §7).
* One explanation line + one recovery action, never a toast. * A tonal icon, one headline, one supporting line and one recovery action
* never a toast.
*/ */
@Composable @Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { fun CalendarFailure(
reason: FailureReason,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current val manageCalendars = LocalManageCalendars.current
val titleRes = when (reason) { val copy = failureCopy(reason)
FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown
}
val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry
}
val onAction: () -> Unit = when (reason) { val onAction: () -> Unit = when (reason) {
FailureReason.NoCalendarsConfigured -> { FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() } { context.startCalendarSetup() }
} }
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
-> manageCalendars ?: onRetry
else -> onRetry else -> onRetry
} }
Column( Column(
modifier = Modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp), .padding(horizontal = 32.dp, vertical = 24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
FailureIcon(icon = copy.icon, isError = copy.isError)
Spacer(Modifier.height(24.dp))
Text( Text(
text = stringResource(titleRes), text = stringResource(copy.title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
) )
Spacer(Modifier.height(24.dp)) copy.body?.let { body ->
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
}
Spacer(Modifier.height(32.dp))
FilledTonalButton(onClick = onAction) { FilledTonalButton(onClick = onAction) {
Text(stringResource(actionRes)) Text(stringResource(copy.action))
} }
} }
} }
/** The state's mark: the reason's icon in a tonal circle above the headline. */
@Composable
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
val container: Color
val content: Color
if (isError) {
container = MaterialTheme.colorScheme.errorContainer
content = MaterialTheme.colorScheme.onErrorContainer
} else {
container = MaterialTheme.colorScheme.secondaryContainer
content = MaterialTheme.colorScheme.onSecondaryContainer
}
Box(
modifier = Modifier
.size(72.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = content,
modifier = Modifier.size(36.dp),
)
}
}
/**
* What a reason says and offers. Headline and supporting line are separate so
* the explanation reads as body text instead of a headline that runs over three
* lines; [isError] tints the mark for the two reasons that are an actual fault
* rather than a calendar the user can switch back on.
*/
private data class FailureCopy(
val icon: ImageVector,
@StringRes val title: Int,
@StringRes val body: Int?,
@StringRes val action: Int,
val isError: Boolean = false,
)
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
FailureReason.PermissionRevoked -> FailureCopy(
icon = Icons.Outlined.Lock,
title = R.string.state_failure_permission,
body = R.string.state_failure_permission_body,
action = R.string.state_failure_permission_action,
)
FailureReason.NoCalendarsConfigured -> FailureCopy(
icon = Icons.Outlined.CalendarMonth,
title = R.string.state_failure_no_calendars,
body = R.string.state_failure_no_calendars_body,
action = R.string.state_failure_no_calendars_action,
)
FailureReason.AllCalendarsHidden -> FailureCopy(
icon = Icons.Outlined.VisibilityOff,
title = R.string.state_failure_all_hidden,
body = R.string.state_failure_all_hidden_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.NoImportTarget -> FailureCopy(
icon = Icons.Outlined.EditOff,
title = R.string.state_failure_no_import_target,
body = R.string.state_failure_no_import_target_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.EventNotFound -> FailureCopy(
icon = Icons.Outlined.EventBusy,
title = R.string.state_failure_event_not_found,
body = R.string.state_failure_event_not_found_body,
action = R.string.state_retry,
)
FailureReason.ProviderUnavailable -> FailureCopy(
icon = Icons.Outlined.SyncProblem,
title = R.string.state_failure_provider,
body = R.string.state_failure_provider_body,
action = R.string.state_retry,
isError = true,
)
FailureReason.Unknown -> FailureCopy(
icon = Icons.Outlined.ErrorOutline,
title = R.string.state_failure_unknown,
body = null,
action = R.string.state_retry,
isError = true,
)
}
/** Keeps the headline and its supporting line at a readable measure. */
private val TEXT_MAX_WIDTH = 320.dp
/** /**
* Opens Settings → Calendars, the only screen that can switch a calendar back * Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the * on. Null outside the calendar host — screens without it never raise the
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
* a 16sp line box — room a label wants when it stands on its own, and close to
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
* title keeps its interior line spacing (#190).
*
* Trimmed rather than set to a smaller line height: the font picker can load a
* serif, a monospace or a file of the user's own, and a line height under a
* face's own ascent and descent overlaps its lines. There is nothing to trim
* below that, so this is safe whatever font is chosen.
*/
private val TrimmedLines = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
/**
* [style] with its wrapped lines packed onto the face's own extent instead of
* Material's line box.
*
* [trimmedLines] takes the leading off the outer edges of a run of text; this
* takes it from between the lines as well, which is the half a wrapped event
* title pays for twice over. The line height is *measured* from the font rather
* than picked, so it lands exactly on the face's ascent-plus-descent and can
* never be short enough to overlap — whatever the font picker has loaded (#190).
*/
@Composable
fun rememberPackedLines(style: TextStyle): TextStyle {
val line = rememberTrimmedLineHeight(style)
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
}
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
@@ -83,8 +83,10 @@ fun calendarSlideTransition(
initialContentExit = initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec), slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which // AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no // shears the pages against the viewport edge as they pass. Left off even
// size change here to contain — both pages are the same grid. // where the two pages differ in height — the split grid stands as many
// rows as its month spans (#162) — since a page sliding out over the row
// below it reads as travel, and the shear reads as a fault.
sizeTransform = SizeTransform(clip = false), sizeTransform = SizeTransform(clip = false),
) )
} }
@@ -790,20 +790,15 @@ private fun DragCopy(
val width = with(density) { sizePx.width.toDp() } val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() } val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// The block's size, spent the block's way — text sits at the top as it does // The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267). // on the block, so the copy hands back to the grid without shifting (#267),
// and it squeezes its inset on the same terms so a short block's title does
// not vanish the moment it is lifted (#289).
// The title is served in full first and the range lives off what is left: // The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the // the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go. // range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2 val metrics = rememberBlockTextMetrics(height)
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0) val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
val allowed = titleLines.coerceAtMost(titleBudget)
// Re-measured at the copy's own width rather than spent on the source's // Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the // count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never // copy is a whole column, so the source's second line is one the copy never
@@ -818,10 +813,10 @@ private fun DragCopy(
max = allowed, max = allowed,
) )
} }
val left = available - titleLineHeight * lines val left = metrics.available - metrics.titleHeight(lines)
val showTime = label != null && left >= timeLineHeight val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) { val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight) blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else { } else {
1 1
} }
@@ -857,7 +852,7 @@ private fun DragCopy(
clip = false clip = false
} }
.eventSurface(paint, shape, cuts) .eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) { ) {
Column { Column {
if (lines > 0) { if (lines > 0) {
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom(
* names, jumping the whole column as a pinch drifts across each half pixel. * names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid. * Pinning the hour to whole pixels keeps every part of the timeline on one grid.
* *
* The bounds themselves are pulled onto that grid too, each in the direction * The ceiling is pulled onto that grid too, downwards, so it stays a height the
* that keeps its own promise — up for the fill floor, so no dead space opens * pinch can actually land on.
* under midnight, down for the ceiling. A fractional bound would be a height the *
* pinch can be held against but never actually land on, and the difference feeds * [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
* the focal anchor a scroll correction on every frame the fingers sit still. * day exactly fills the viewport at, and it is the same value
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
* hour taller than its own viewport, so a pinched-all-the-way-out day still
* scrolls a hair and bounces off Android's overscroll stretch, while the
* identical FitDay preset sits still. Being the clamp result rather than a bound
* the gesture is merely held against, it is a height the pinch does land on: the
* next frame reads it back unchanged and the focal anchor is handed nothing to
* correct.
*/ */
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float = internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for // Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures. // the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat() target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx)) .coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx)) .coerceAtLeast(fillPx)
/** /**
* The scroll offset that keeps the moment under [centroidY] under it after the * The scroll offset that keeps the moment under [centroidY] under it after the
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -115,6 +115,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -140,7 +141,8 @@ import kotlin.time.Clock
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val ALL_DAY_ROW_HEIGHT = 24.dp /** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */ /** Total all-day strip height for the day (0 when there are no all-day events). */
@@ -529,7 +531,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow() val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall.trimmedLines(),
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
@@ -763,30 +765,24 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current val density = LocalDensity.current
val titleLineHeight = with(density) { val metrics = rememberBlockTextMetrics(height)
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// A block that cannot afford both lines spends its space on the title, and // A block that cannot afford both lines spends its space on the title, and
// one too short even for that drops the title rather than serving a sliced one. // one too short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a // Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs. // half-hour block the user has pinched open to three times the room it needs.
val available = height - BLOCK_TEXT_INSET * 2 val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTime = available >= titleLineHeight + timeLineHeight val showTitle = metrics.fitsTitle
val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is // Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced // too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the // one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267). // title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1) val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2) val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:3011:00" several times over — the // On a day column — wide enough for "09:3011:00" several times over — the
// range never needs the second line, until lanes cut the column down. // range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines - val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) timeLineHeight else 0.dp if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
@@ -827,7 +823,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -302,6 +302,7 @@ fun EventDetailScreen(
is EventDetailUiState.Failure -> CalendarFailure( is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason, reason = s.reason,
onRetry = viewModel::retry, onRetry = viewModel::retry,
modifier = contentModifier,
) )
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
} }
@@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable @Composable
fun ImportScreen( fun ImportScreen(
uri: Uri, uri: Uri,
session: Int,
onClose: () -> Unit, onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit, onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false, forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
// Key the VM by the file uri. This screen has no nav backstack, so an viewModel: ImportViewModel = hiltViewModel(),
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
// file's parsed state on the next import (a second restore, export→restore,
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
// state), while the same uri (rotation) reuses it and holds the result.
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
) { ) {
LaunchedEffect(uri) { viewModel.load(uri, forceMany) } // hiltViewModel() resolves to the Activity's store and is retained across
// imports, so the reload is driven by [session] rather than by a fresh VM:
// keying per uri looked right but handed a *re-import of the same file* the
// previous run's finished state, importing nothing (#304). A rotation keeps
// the session and so keeps the result.
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review. // A single event isn't shown here — it opens the create form for review.
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
private val parser = IcsParser() private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading) private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow() val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false private var loadedSession: Int? = null
private var loadJob: Job? = null
/** /**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored. * Read + parse [uri] once per [session]; recomposition (and a rotation,
* which keeps the session) re-calls this and keeps the result. A new import
* of the *same* file is a new session, and has to parse and run again —
* keying on the uri alone showed the previous run's summary and imported
* nothing (#304).
*
* When [forceMany] is set (an in-app restore), a single-event file still goes * When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form — * through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event". * a restore is "bring back a backup", not "add this one event".
*/ */
fun load(uri: Uri, forceMany: Boolean = false) { fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
if (started) return if (loadedSession == session) return
started = true loadedSession = session
viewModelScope.launch { loadJob?.cancel()
_state.value = ImportUiState.Loading
loadJob = viewModelScope.launch {
val parsed = withContext(io) { val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse) importer.readText(uri)?.let(parser::parse)
} }
@@ -10,10 +10,16 @@ import androidx.compose.ui.unit.dp
internal val CELL_GAP = 2.dp internal val CELL_GAP = 2.dp
/** Padding between a month chip's edge and its text. */ /** Padding between a month chip's edge and its text. */
internal val MONTH_CHIP_TEXT_PADDING = 4.dp internal val MONTH_CHIP_TEXT_PADDING = 3.dp
/** A chip's own inset inside its day cell, on top of the cell's gap. */ /**
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp * A chip's own inset inside its day cell. The cell's gap and no more: a day
* column on a phone is around fifty dp, and the chip was spending a quarter of
* it on chrome before a single glyph. The cells keep their full separation from
* each other — what the grid reads as breathing room — and only the chip inside
* one takes the width back (#212).
*/
internal val MONTH_CHIP_INSET = CELL_GAP
/** Horizontal space a chip spends on chrome rather than on text, both sides. */ /** Horizontal space a chip spends on chrome rather than on text, both sides. */
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -153,6 +154,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -744,7 +746,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) { ) {
// Reserve the gutter so the weekday labels stay over their day columns. // Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow -> days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -762,9 +764,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
private val EVENT_ROW_HEIGHT = 20.dp private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only /** Padding between the week-number pill's edge and the number inside it. */
* seats a one- or two-digit week number in a full-height tonal pill. */ private val WEEK_NUMBER_PADDING = 6.dp
private val WEEK_NUMBER_GUTTER = 40.dp
/** The widest week number an ISO year reaches; digits are tabular, so one
* measurement of it prices every week in the grid. */
private const val WEEK_NUMBER_SAMPLE = "53"
/**
* Width of the optional left calendar-week gutter (#25), measured rather than
* fixed: it is sized to the number it seats at the style the pill draws it in,
* so it follows the font scale instead of reserving slack for it, and spends
* nothing more on a column the grid would rather hand to the seven days (#213).
*/
@Composable
private fun rememberWeekNumberGutter(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
}
}
/** The week number's own style — a step down from the day numbers beside it. */
@Composable
private fun weekNumberStyle() =
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp private val CELL_TOP_PADDING = 6.dp
/** Named separately because the split style's selection outline draws its own /** Named separately because the split style's selection outline draws its own
@@ -774,14 +801,79 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */ /** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp 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 * Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
* the split rows are a fixed height whatever the screen is.
*
* Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws
* there (#53) — [MonthWeek.laneEvents] seats the same events in the same order
* at any cap, so a larger one only appends. Where the expanded grid seats fewer
* lanes than this — a six-row month in a short landscape viewport — the dots
* past its cap have no bar to become and simply fade instead of travelling.
*/
internal const val SPLIT_DOT_LANES = 3
/** Diameter of a single overflow dot. */
private val OVERFLOW_DOT_SIZE = 6.dp
/**
* Height of the overflow row: the "+N" beside the dots, measured at the style it
* is drawn in.
*
* It was taking a whole event lane, which is more than a dot and a label line
* need and one lane fewer for the chips. Measured rather than picked, so it
* still holds the label at a large font scale — a fixed height clipped the "+N"
* at anything above the default.
*/
@Composable
private fun rememberOverflowRowHeight(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.labelSmall
return remember(style, density, measurer) {
with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() }
.coerceAtLeast(OVERFLOW_DOT_SIZE)
}
}
/** The tallest the counter gets; digits are tabular, so one is as wide as any. */
private const val OVERFLOW_SAMPLE = "+9"
/**
* Lanes of chips this week's cells draw in a row [rowHeight] tall, before the
* rest become dots.
*
* The cap used to be a flat three at every size, so a tall five-row month threw
* away lanes it had the room for while a cramped six-row one drew a third chip
* its band could not hold and put the dots below the clip, where nothing showed
* that the day held more at all.
*
* Measured instead, with no ceiling: a cell spends whatever height it was given.
* The overflow row is only charged for when some day in the week actually
* overflows — a week that fits gets that space as another lane rather than
* reserving room for a marker it will not draw.
*/
internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int {
val band = monthBandHeight(rowHeight)
val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
if (!overflowsAt(full)) return full
return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
}
/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */
internal fun monthBandHeight(rowHeight: Dp): Dp =
rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP
/** Whether any day in this week holds more than [lanes] lanes can seat. */
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
/** /**
* Row height in the continuous grid. The paged grid divides the viewport between * Row height in the continuous grid. The paged grid divides the viewport between
* however many rows the month has; a scrolling stream has no such bound, so it * however many rows the month has; a scrolling stream has no such bound, so it
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows — * fixes one — close to what a five-row month gets on a typical phone. How many
* close to what a five-row month gets on a typical phone. * chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
* week that does not overflow fills the band rather than holding a lane back.
*/ */
private val CONTINUOUS_ROW_HEIGHT = 112.dp private val CONTINUOUS_ROW_HEIGHT = 112.dp
@@ -793,6 +885,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
*/ */
private val CONTINUOUS_MONTH_GAP = 20.dp private val CONTINUOUS_MONTH_GAP = 20.dp
/** The paged grid's own vertical padding, and the gap between its week rows —
* named because [monthLaneCap] has to take them off the viewport first. */
private val GRID_VERTICAL_PADDING = 4.dp
private val GRID_ROW_GAP = 2.dp
/** Gap between the weekday header and the seamless stream's first week row. */ /** Gap between the weekday header and the seamless stream's first week row. */
private val DENSE_HEADER_GAP = 4.dp private val DENSE_HEADER_GAP = 4.dp
@@ -805,14 +902,21 @@ internal fun MonthGrid(
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
BoxWithConstraints(Modifier.fillMaxSize()) {
// The rows divide whatever the viewport leaves once the Column's own
// padding and the gaps between them are paid, so how many chips a cell
// can seat is only knowable here.
val rows = state.weeks.size.coerceAtLeast(1)
val rowHeight =
(maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Match the weekday header's inset so day cells sit under their // Match the weekday header's inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the // labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button). // top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
) { ) {
val month = state.month val month = state.month
// Once per grid: the value depends on the typography, the density, the // Once per grid: the value depends on the typography, the density, the
@@ -824,6 +928,7 @@ internal fun MonthGrid(
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeChipWidth = timeChipWidth,
rowHeight = rowHeight,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -836,6 +941,7 @@ internal fun MonthGrid(
} }
} }
} }
}
/** /**
* The continuous grid (#38): months stacked into one vertical scroll, each a * The continuous grid (#38): months stacked into one vertical scroll, each a
@@ -930,6 +1036,7 @@ private fun ContinuousMonthBlock(
today = today, today = today,
zone = zone, zone = zone,
timeChipWidth = timeChipWidth, timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either // The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed. // neighbour is left out entirely rather than dimmed.
@@ -1024,6 +1131,7 @@ internal fun DenseMonthGrid(
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
timeChipWidth = timeChipWidth, timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
// Every day in the stream belongs to a month equally — there // Every day in the stream belongs to a month equally — there
// is no "other month" to recede here. // is no "other month" to recede here.
inMonth = { true }, inMonth = { true },
@@ -1141,10 +1249,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
* lists whatever day is selected — and a downward drag trades the pane away for * lists whatever day is selected — and a downward drag trades the pane away for
* the full paged grid, an upward one brings it back (#53). * the full paged grid, an upward one brings it back (#53).
* *
* The grid slides between months like the paged style, which it can only do * The grid slides between months like the paged style, and stands only as many
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it * rows tall as its own month spans (#162). A swipe between a five-row month and
* stood 46 rows tall, so every swipe shunted the pane up or down by a row on * a six-row one therefore moves the pane by a row as well as swapping the grid;
* top of swapping the grid — the pane now holds still and only the grid moves. * the row it hands back is worth more to the pane than a still edge is.
* *
* Expansion is deliberately **not** a stored preference. It is a way to look at * Expansion is deliberately **not** a stored preference. It is a way to look at
* the month you are on, not a fourth style; persisted, someone would expand it * the month you are on, not a fourth style; persisted, someone would expand it
@@ -1437,17 +1545,10 @@ private fun SplitExpandHandle(
*/ */
private val SPLIT_ROW_HEIGHT = 46.dp private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_DOT_SIZE = 5.dp private val SPLIT_DOT_SIZE = 5.dp
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for // Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot // the paged grid's lanes, so the two caps have to be the same number or a dot
// would have no bar to become (#53). // would have no bar to become (#53).
/**
* Rows the split grid always reserves — the most any month needs. A month that
* fits in fewer pads the remainder with blank rows rather than shrinking, which
* is what lets the pane below hold still from month to month.
*/
private const val SPLIT_GRID_ROWS = 6
/** /**
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a * The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
* comfortable tap target on its own. * comfortable tap target on its own.
@@ -1460,6 +1561,11 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
* The split style's grid (#53): the month compressed to day numbers and event * The split style's grid (#53): the month compressed to day numbers and event
* dots, with the selected day listed underneath by [SplitDayPane]. * dots, with the selected day listed underneath by [SplitDayPane].
* *
* Only the rows the month actually spans. It used to pad every month out to six
* so the pane below held still from page to page, but a row is a sixth of the
* grid and a third of what the pane gets to show — too much to leave blank on
* the months that don't need it (#162).
*
* Tapping selects rather than drilling into the Day view — the pane is the * Tapping selects rather than drilling into the Day view — the pane is the
* answer to "what's on this day", so opening a whole screen for it would defeat * answer to "what's on this day", so opening a whole screen for it would defeat
* the layout. The full Day view stays one tap away on the pane's date header. * the layout. The full Day view stays one tap away on the pane's date header.
@@ -1486,7 +1592,7 @@ internal fun SplitMonthGrid(
WeekNumberGutter( WeekNumberGutter(
weekStart = week.days.first(), weekStart = week.days.first(),
modifier = Modifier modifier = Modifier
.width(WEEK_NUMBER_GUTTER) .width(rememberWeekNumberGutter())
.fillMaxHeight(), .fillMaxHeight(),
) )
} }
@@ -1494,11 +1600,11 @@ internal fun SplitMonthGrid(
val inMonth = day.month == month.month && day.year == month.year val inMonth = day.month == month.month && day.year == month.year
// Seated by lane rather than gathered by colour, so each dot // Seated by lane rather than gathered by colour, so each dot
// is the event the expanded grid draws in that same lane. // is the event the expanded grid draws in that same lane.
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS) val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
SplitDayCell( SplitDayCell(
date = day, date = day,
events = seated, events = seated,
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS), hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
isToday = day == state.today, isToday = day == state.today,
// A page marks only the days its own month owns. Paging // A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement // moves the selection before this month's replacement
@@ -1518,17 +1624,11 @@ internal fun SplitMonthGrid(
} }
} }
} }
// Hold the grid at a constant height whatever shape the month is, so the
// pane beneath it doesn't move as you page and one month can slide over
// another without a height change under it.
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
}
} }
} }
/** /**
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots. * One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots.
* *
* Selection and today are deliberately different signals — a tinted, outlined * Selection and today are deliberately different signals — a tinted, outlined
* cell versus the filled circle the other views already use for today — so the * cell versus the filled circle the other views already use for today — so the
@@ -1902,7 +2002,7 @@ private fun rememberSkeletonPulse(): Float {
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so * One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed * a multi-day event is one connected bar across the columns; single-day timed
* events sit beneath them as filled pills in their own cell. The cap is * events sit beneath them as filled pills in their own cell. The cap is
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day. * [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
* A transparent per-day layer on top turns a tap into "open that day". * A transparent per-day layer on top turns a tap into "open that day".
*/ */
@Composable @Composable
@@ -1913,6 +2013,8 @@ private fun MonthWeekRow(
zone: TimeZone, zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */ /** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp, timeChipWidth: Dp,
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
rowHeight: Dp,
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1929,8 +2031,10 @@ private fun MonthWeekRow(
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(laneCap)
val morphing = morphInFlight() val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own // Every chip's start time for this row at once, and only when the row's own
// inputs change: formatting is a parsed pattern per call, and the dim cutoff // inputs change: formatting is a parsed pattern per call, and the dim cutoff
@@ -1983,9 +2087,9 @@ private fun MonthWeekRow(
band = bandCoordinates[0], band = bandCoordinates[0],
columnWidthPx = cell.size.width / 7f, columnWidthPx = cell.size.width / 7f,
laneHeightPx = rowHeightPx, laneHeightPx = rowHeightPx,
laneCount = MAX_EVENT_ROWS, laneCount = laneCap,
isRtl = isRtl, isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
chipStart = { col, lane -> week.chipStartCol(col, lane) }, chipStart = { col, lane -> week.chipStartCol(col, lane) },
), ),
) )
@@ -2007,7 +2111,7 @@ private fun MonthWeekRow(
WeekNumberGutter( WeekNumberGutter(
weekStart = week.days.first(), weekStart = week.days.first(),
modifier = Modifier modifier = Modifier
.width(WEEK_NUMBER_GUTTER) .width(rememberWeekNumberGutter())
.fillMaxHeight(), .fillMaxHeight(),
) )
} }
@@ -2026,6 +2130,7 @@ private fun MonthWeekRow(
controller = dragController, controller = dragController,
band = bandCoordinates, band = bandCoordinates,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
laneCap = laneCap,
isRtl = isRtl, isRtl = isRtl,
chipTimes = chipTimes, chipTimes = chipTimes,
), ),
@@ -2034,6 +2139,11 @@ private fun MonthWeekRow(
// What a chip has to spend, against the [timeChipWidth] a start time // What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219). // costs it (#219).
val colW = maxWidth / 7 val colW = maxWidth / 7
// Held here rather than read at the offset: the dots are placed
// inside a plain lambda, which is no longer in this scope. The box
// is the whole row, so the day number's share comes off it — the
// dots are positioned inside the band, not inside this.
val bandHeight = monthBandHeight(maxHeight)
// Per-day background pills — same surfaceContainer rounded surface the // Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language. // week/day views use, so the three views share one visual language.
@@ -2176,7 +2286,7 @@ private fun MonthWeekRow(
.filter { it.lane < shownLanes && col in it.startCol..it.endCol } .filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane } .map { it.lane }
.toSet() .toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied } val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size) val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev -> pillsShown.forEachIndexed { i, ev ->
MonthBar( MonthBar(
@@ -2210,8 +2320,20 @@ private fun MonthWeekRow(
events = hiddenEvents, events = hiddenEvents,
total = hidden, total = hidden,
dark = dark, dark = dark,
rowHeight = overflowRow,
modifier = Modifier modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS) // After the chip lanes, but never past the
// band: on a row too short for the lanes it
// is holding, dots placed below it are
// clipped away entirely and the day looks
// like it has nothing more to show.
.offset(
x = colW * col,
y = minOf(
EVENT_ROW_HEIGHT * laneCap,
bandHeight - overflowRow,
),
)
.morphBounds(MonthMorphKey.Overflow(d)) .morphBounds(MonthMorphKey.Overflow(d))
.width(colW) .width(colW)
.padding(horizontal = 3.dp), .padding(horizontal = 3.dp),
@@ -2286,6 +2408,7 @@ private fun MonthWeekRow(
bandCoordinates, bandCoordinates,
), ),
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
laneCap = laneCap,
) )
if (chip != null) onEventClick(chip) else onOpenDay(d) if (chip != null) onEventClick(chip) else onOpenDay(d)
}, },
@@ -2327,11 +2450,12 @@ internal fun MonthWeek.chipAtCellY(
cellY: Float, cellY: Float,
bandTopInCell: Float?, bandTopInCell: Float?,
rowHeightPx: Float, rowHeightPx: Float,
laneCap: Int,
): EventInstance? { ): EventInstance? {
if (bandTopInCell == null || rowHeightPx <= 0f) return null if (bandTopInCell == null || rowHeightPx <= 0f) return null
val bandY = cellY - bandTopInCell val bandY = cellY - bandTopInCell
if (bandY < 0f) return null if (bandY < 0f) return null
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS) return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
} }
/** /**
@@ -2346,6 +2470,7 @@ private fun monthChipDragModifier(
controller: MonthDragController?, controller: MonthDragController?,
band: Array<LayoutCoordinates?>, band: Array<LayoutCoordinates?>,
rowHeightPx: Float, rowHeightPx: Float,
laneCap: Int,
isRtl: Boolean, isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the /** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */ * one its source chip had rather than deriving another (#219). */
@@ -2364,7 +2489,7 @@ private fun monthChipDragModifier(
val event = if (bandY < 0f || columnPx <= 0f) { val event = if (bandY < 0f || columnPx <= 0f) {
null null
} else { } else {
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS) week.chipAt(dayIndex, lane, laneCap)
} }
if (event == null || moveScope?.allows(event) != true) { if (event == null || moveScope?.allows(event) != true) {
false false
@@ -2422,8 +2547,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
) { ) {
Text( Text(
text = weekNumber.toString(), text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall, style = weekNumberStyle(),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer, color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
@@ -2544,7 +2668,7 @@ private fun MonthBar(
val titleOverflow = eventTitleOverflow() val titleOverflow = eventTitleOverflow()
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall.trimmedLines(),
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
@@ -2565,6 +2689,7 @@ private fun OverflowDots(
events: List<EventInstance>, events: List<EventInstance>,
total: Int, total: Int,
dark: Boolean, dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
@@ -2572,14 +2697,14 @@ private fun OverflowDots(
val byColor = events.groupBy { it.color } val byColor = events.groupBy { it.color }
val dots = byColor.keys.take(3) val dots = byColor.keys.take(3)
Row( Row(
modifier = modifier.height(EVENT_ROW_HEIGHT), modifier = modifier.height(rowHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
dots.forEach { argb -> dots.forEach { argb ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(6.dp) .size(OVERFLOW_DOT_SIZE)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f) .alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(argb, dark, soften), CircleShape), .background(eventAccent(argb, dark, soften), CircleShape),
) )
@@ -1,10 +1,24 @@
package de.jeanlucmakiola.calendula.ui.theme package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.unit.sp
/** /**
* Default Material 3 Expressive typography. Custom font + tuned scale will * Tracking the two label roles the calendar grids are set in. Material gives
* land in a later UI-design iteration; the defaults are intentional for V1 * both 0.5sp, tuned for isolated UI labels with room around them; a month chip
* scaffolding to keep the foundation lean. * is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
* so the label family stays coherent (#190).
*/ */
val CalendulaTypography = Typography() private val LabelTracking = 0.1.sp
/**
* Material 3 Expressive typography with the label roles' tracking tightened.
* Everything else is the default scale.
*/
val CalendulaTypography: Typography = Typography().let { base ->
base.copy(
labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking),
labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking),
)
}
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -118,6 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -155,7 +156,8 @@ import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale import java.util.Locale
private val ALL_DAY_ROW_HEIGHT = 24.dp /** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Gap between day columns; part of the column pitch a drag maps positions through. */ /** Gap between day columns; part of the column pitch a drag maps positions through. */
private val COLUMN_GAP = 2.dp private val COLUMN_GAP = 2.dp
@@ -571,7 +573,7 @@ private fun WeekDayHeader(
} }
/** Calendar-week badge shown in the header gutter, deliberately set apart with a /** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */ * filled box and bold number at the month grid's size, so the two agree (#213). */
@Composable @Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label) val label = stringResource(R.string.week_number_label)
@@ -583,9 +585,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
) { ) {
Text( Text(
text = weekNumber.toString(), text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
) )
} }
} }
@@ -664,7 +666,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow() val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall.trimmedLines(),
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = titleOverflow.softWrap,
@@ -909,13 +911,7 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current val density = LocalDensity.current
val titleLineHeight = with(density) { val metrics = rememberBlockTextMetrics(height)
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val available = height - BLOCK_TEXT_INSET * 2
// Only full-width (non-overlapping) blocks that are tall enough show the // Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to // time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot // fill the whole block, mirroring Google Calendar — and a block that cannot
@@ -923,19 +919,19 @@ private fun EventBlock(
// its own: a duration threshold would keep hiding the time on a half-hour // its own: a duration threshold would keep hiding the time on a half-hour
// block the user has pinched open to three times the room it needs. // block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 && val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight metrics.available >= metrics.titleLine + metrics.timeLine
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced // A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip // one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and // reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way. // the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight val showTitle = metrics.fitsTitle
// The title is served first, out of everything the block has left once the // The title is served first, out of everything the block has left once the
// time is down to one line — but only takes the lines it will actually use, // time is down to one line — but only takes the lines it will actually use,
// and only wraps at all once a line is wide enough to hold more than a // and only wraps at all once a line is wide enough to hold more than a
// syllable. Below that the extra lines just stack fragments of the word. // syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
val paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
// Every line the height affords, however narrow the lane: two events side by // Every line the height affords, however narrow the lane: two events side by
// side leave columns well under a word wide, and cutting the title to one // side leave columns well under a word wide, and cutting the title to one
@@ -948,8 +944,8 @@ private fun EventBlock(
textWidth = textWidth, textWidth = textWidth,
max = titleBudget, max = titleBudget,
) )
val spare = available - titleLineHeight * titleMaxLines - val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) timeLineHeight else 0.dp if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
@@ -991,7 +987,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -167,7 +167,7 @@ internal fun layoutAllDay(
// in non-decreasing start order, which the declined-last rule above breaks: // in non-decreasing start order, which the declined-last rule above breaks:
// a Monday bar seated after a Wednesday one would be refused a lane it is // a Monday bar seated after a Wednesday one would be refused a lane it is
// nowhere near, and each wasted lane costs the all-day strip a whole row and // nowhere near, and each wasted lane costs the all-day strip a whole row and
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns // pushes a bar closer to the month grid's lane cap. Seven columns
// and a handful of bars, so the scan is cheaper than the sort above it. // and a handful of bars, so the scan is cheaper than the sort above it.
val laneCols = ArrayList<MutableList<IntRange>>() val laneCols = ArrayList<MutableList<IntRange>>()
return raw.map { r -> return raw.map { r ->
-3
View File
@@ -655,12 +655,9 @@
<string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string> <string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string>
<string name="crash_dialog_report">Справаздача</string> <string name="crash_dialog_report">Справаздача</string>
<string name="crash_dialog_dismiss">Ня зараз</string> <string name="crash_dialog_dismiss">Ня зараз</string>
<string name="crash_report_issue_title">Справаздача аб краху</string>
<string name="crash_report_clip_label">Справаздача аб краху %1$s</string> <string name="crash_report_clip_label">Справаздача аб краху %1$s</string>
<string name="crash_report_copied">Справаздача скапіявана ў буфэр абмену</string> <string name="crash_report_copied">Справаздача скапіявана ў буфэр абмену</string>
<string name="crash_report_open_failed">Не атрымалася адкрыць сыстэму адсочваньня праблемаў. Справаздача знаходзіцца ў буфэры абмену.</string> <string name="crash_report_open_failed">Не атрымалася адкрыць сыстэму адсочваньня праблемаў. Справаздача знаходзіцца ў буфэры абмену.</string>
<string name="crash_report_body_template">Дзякуй за справаздачу аб краху ў %1$s. Дадайце ўсё, што памятаеце пра тое, што рабілі, а затым адпраўце.\n\n### Што здарылася\n\n\n### Справаздача аб краху\n%2$s\n</string>
<string name="crash_report_body_paste">_(Справаздача задоўгая для гэтай спасылкі — устаўце яе з буфэра абмену сюды.)_</string>
<string name="special_dates_calendar_birthday">Дні нараджэньня</string> <string name="special_dates_calendar_birthday">Дні нараджэньня</string>
<string name="special_dates_calendar_anniversary">Юбілейныя даты</string> <string name="special_dates_calendar_anniversary">Юбілейныя даты</string>
<string name="special_dates_calendar_custom">Асаблівыя даты</string> <string name="special_dates_calendar_custom">Асаблівыя даты</string>
+213 -5
View File
@@ -106,7 +106,7 @@
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string> <string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávné</string> <string name="event_edit_timezone_recent">Nedávné</string>
<string name="event_edit_timezone_all">Všechna časová pásma</string> <string name="event_edit_timezone_all">Všechna časová pásma</string>
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá %1$s</string> <string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá %1$s</string>
<string name="event_edit_timezone_local_time">%1$s vašeho času</string> <string name="event_edit_timezone_local_time">%1$s vašeho času</string>
<string name="event_edit_color">Barva</string> <string name="event_edit_color">Barva</string>
<string name="event_edit_color_default">Barva kalendáře</string> <string name="event_edit_color_default">Barva kalendáře</string>
@@ -115,7 +115,7 @@
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string> <string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string> <string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string> <string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string> <string name="event_edit_conflict_title">Ke změně události došlo na jiném místě</string>
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string> <string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
<string name="event_edit_conflict_overwrite">Uložit změny</string> <string name="event_edit_conflict_overwrite">Uložit změny</string>
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string> <string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
@@ -190,7 +190,7 @@
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string> <string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
<string name="event_attendee_organizer">Pořadatel</string> <string name="event_attendee_organizer">Pořadatel</string>
<string name="event_attendee_optional">Volitelné</string> <string name="event_attendee_optional">Volitelné</string>
<string name="event_attendee_resource">Zdroj</string> <string name="event_attendee_resource">Prostředky</string>
<string name="event_detail_self_response">Vaše odpověď: %1$s</string> <string name="event_detail_self_response">Vaše odpověď: %1$s</string>
<string name="reminder_at_time">V době konání události</string> <string name="reminder_at_time">V době konání události</string>
<string name="reminder_default">Výchozí připomínka</string> <string name="reminder_default">Výchozí připomínka</string>
@@ -296,7 +296,7 @@
<string name="search_back">Zpět</string> <string name="search_back">Zpět</string>
<string name="search_clear">Vymazat</string> <string name="search_clear">Vymazat</string>
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string> <string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
<string name="search_empty">Žádná událost neodpovídá %1$s.</string> <string name="search_empty">Žádná událost neodpovídá %1$s.</string>
<plurals name="search_selected_count"> <plurals name="search_selected_count">
<item quantity="one">%d označený</item> <item quantity="one">%d označený</item>
<item quantity="few">%d označené</item> <item quantity="few">%d označené</item>
@@ -435,7 +435,7 @@
<item quantity="many">%d dnů</item> <item quantity="many">%d dnů</item>
<item quantity="other">%d dnů</item> <item quantity="other">%d dnů</item>
</plurals> </plurals>
<string name="settings_section_views">Pohledy</string> <string name="settings_section_views">Zobrazení</string>
<string name="settings_month_header">Měsíční zobrazení</string> <string name="settings_month_header">Měsíční zobrazení</string>
<string name="settings_month_view_style">Styl měsíčního zobrazení</string> <string name="settings_month_view_style">Styl měsíčního zobrazení</string>
<string name="month_style_paged">Stránky</string> <string name="month_style_paged">Stránky</string>
@@ -485,4 +485,212 @@
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie připomínky chodí bez prodlení.</string> <string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie připomínky chodí bez prodlení.</string>
<string name="settings_notifications_subtitle">Připomínky a doručení</string> <string name="settings_notifications_subtitle">Připomínky a doručení</string>
<string name="settings_special_dates_reminders">Připomínky</string> <string name="settings_special_dates_reminders">Připomínky</string>
<string name="settings_reliable_delivery">Spolehlivé doručení</string>
<string name="settings_snooze_duration">Doba odložení</string>
<string name="settings_section_calendars">Kalendáře</string>
<string name="settings_manage_calendars">Správa kalendářů</string>
<string name="settings_manage_calendars_hint">Vytvářet místní kalendáře; spravovat synchronizované kalendáře</string>
<string name="settings_section_language">Jazyk</string>
<string name="settings_language">Jazyk aplikace</string>
<string name="settings_language_auto">Výchozí nastavení systému</string>
<string name="settings_translate">Pomozte s překladem</string>
<string name="settings_translate_hint">Přidat nebo vylepšit jazyk na Weblate</string>
<string name="settings_group_look">Vzhled a chování</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">Aplikace</string>
<string name="settings_group_about">O aplikaci</string>
<string name="settings_appearance_subtitle">Motiv, barvy, písma</string>
<string name="settings_views_subtitle">Výchozí zobrazení, rozložení a pořadí</string>
<string name="settings_event_form_subtitle">Výchozí pole a chování</string>
<string name="settings_special_dates_subtitle">Narozeniny a výročí kontaktů</string>
<string name="settings_widgets_subtitle">Widget s agendou, dlaždice rychlého nastavení</string>
<string name="settings_backup_subtitle">Export, import, automatické zálohování</string>
<string name="settings_section_widgets">Widgety a dlaždice</string>
<string name="settings_widgets_hint">Nastavení widgetů na domovské obrazovce a dlaždice rychlého nastavení. Widget přidáte dlouhým stisknutím domovské obrazovky.</string>
<string name="settings_section_backup">Záloha a obnovení</string>
<string name="settings_views_all_header">Všechny pohledy</string>
<string name="settings_week_day_header">Týden a den</string>
<string name="settings_default_view_hint">Pohled, který se otevře při spuštění aplikace.</string>
<string name="settings_week_start_hint">Počáteční den týdne ve všech zobrazeních a widgetech.</string>
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
<string name="settings_section_special_dates">Speciální data kontaktů</string>
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
<string name="settings_special_dates_type_birthday">Narozeniny</string>
<string name="settings_special_dates_type_anniversary">Výročí</string>
<string name="settings_special_dates_type_custom">Ostatní data</string>
<string name="settings_special_dates_template">Formát názvu</string>
<string name="settings_special_dates_template_hint">Pro kontakt použijte {name} a pro rok {year} (rok narození nebo rok zahájení výročí; pokud není znám, údaj se skryje).</string>
<string name="settings_special_dates_show_year">Zobrazit rok</string>
<string name="settings_special_dates_show_year_hint">Uvést {year} v názvu, je-li znám</string>
<string name="settings_special_dates_sync_now">Synchronizovat nyní</string>
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
<string name="settings_special_dates_paused_title">Pozastaveno</string>
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
<string name="settings_special_dates_grant">Udělit přístup</string>
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
<string name="settings_special_dates_disable_confirm">Vypnout</string>
<string name="dialog_save">Uložit</string>
<string name="settings_section_about">O aplikaci</string>
<string name="settings_license">Licence</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">od Jean-Luca Makioly</string>
<string name="settings_about_source">Zdroj</string>
<string name="settings_licences">Licence open source</string>
<string name="settings_licences_subtitle">Projekty, na kterých je Calendula postavena</string>
<string name="settings_about_privacy">Zásady ochrany osobních údajů</string>
<string name="settings_about_support">Podpořte vývoj</string>
<string name="settings_about_version">Verze %1$s</string>
<string name="settings_about_logo_desc">Ikona aplikace</string>
<string name="settings_report_problem">Nahlásit problém</string>
<string name="settings_report_problem_hint">Odeslat hlášení o pádu nebo otevřít systém pro sledování chyb</string>
<string name="licences_title">Licence open source</string>
<string name="licences_intro">Calendula zahrnuje níže uvedené projekty. Klepnutím na libovolnou položku zobrazíte jeho zdrojový kód.</string>
<string name="licences_footer">Každý projekt je zde zahrnut v nezměněné podobě a pod uvedenou licencí.</string>
<string name="calendars_title">Kalendáře</string>
<string name="calendars_local_header">Vaše kalendáře</string>
<string name="calendars_local_empty">Zatím nejsou k dispozici žádné místní kalendáře. Vytvořte si kalendář, chcete-li události ukládat pouze do tohoto zařízení.</string>
<string name="calendars_add">Přidat kalendář</string>
<string name="calendars_visibility_hint">Vypnutím kalendáře jej na tomto zařízení skryjete jeho události zmizí z aplikace a přestanou se zobrazovat připomínky. Jde o stejný přepínač, jaký používají i ostatní aplikace kalendáře, takže se kalendář skryje i v nich. Nic se nemaže, další zařízení to neovlivní a kalendář můžete kdykoli znovu zapnout.</string>
<string name="calendars_visibility_a11y">Zobrazit „%1$s“</string>
<string name="calendars_visibility_notice_title">Některé kalendáře jsou vypnuté</string>
<string name="calendars_visibility_notice_message">Calendula zobrazuje kalendáře, které jsou pro toto zařízení zapnuté; některé z vašich kalendářů jsou však vypnuté. Chcete-li zobrazit jejich události, zapněte je v nabídce Nastavení → Kalendáře.</string>
<string name="calendar_picker_missing_title">Chybí vám kalendář?</string>
<string name="calendar_picker_missing_summary">Může být vypnutý, nastavený na režim „pouze pro čtení“ nebo naplněný daty z vašich kontaktů své kalendáře spravujte zde.</string>
<string name="calendars_state_read_only">Pouze pro čtení</string>
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
<string name="calendars_synced_header">Synchronizované kalendáře</string>
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">Více možností pro %1$s</string>
<string name="calendars_enable_all">Povolit vše</string>
<string name="calendars_disable_all">Zakázat vše</string>
<string name="calendars_add_account">Přidat účet</string>
<string name="calendars_new_title">Nový kalendář</string>
<string name="calendars_edit_title">Upravit kalendář</string>
<string name="calendars_name_label">Jméno</string>
<string name="calendars_color_label">Barva</string>
<string name="calendars_description_hint">Přidat popis</string>
<string name="calendars_delete_confirm_title">Vymazat kalendář?</string>
<string name="calendars_delete_confirm_message">„%1$s“ a všechny jeho události budou z tohoto zařízení trvale odstraněny.</string>
<string name="calendars_write_error">Změnu se nepodařilo uložit.</string>
<string name="calendars_backup_header">Záloha</string>
<string name="calendars_backup_hint">Místní kalendáře nejsou nikde synchronizovány, proto je pro uchování kopie exportujte do souboru ve formátu .ics.</string>
<string name="calendars_backup_action">Exportovat jako soubor .ics</string>
<string name="calendars_export_title">Exportovat kalendáře</string>
<string name="calendars_export_hint">Vyberte, které kalendáře chcete zahrnout do souboru .ics.</string>
<string name="calendars_export_action">Exportovat</string>
<string name="calendars_restore_header">Obnovit</string>
<string name="calendars_restore_action">Obnovit ze souboru .ics</string>
<string name="calendars_restore_hint">Importuovat události ze zálohy nebo z jiné aplikace kalendáře.</string>
<string name="calendars_auto_backup">Automatické zálohování</string>
<string name="calendars_auto_backup_hint">Pravidelně exportujte své místní kalendáře do složky jako soubor .ics.</string>
<string name="calendars_auto_backup_folder">Složka pro zálohování</string>
<string name="calendars_auto_backup_folder_unset">Klepnutím vyberte složku</string>
<string name="calendars_auto_backup_interval">Interval</string>
<string name="calendars_auto_backup_every">Každý %1$s</string>
<string name="calendars_auto_backup_interval_min">Minimálně 30 minut.</string>
<string name="calendars_auto_backup_status_never">Zatím není nastaveno žádné automatické zálohování</string>
<string name="calendars_auto_backup_status_ok">Poslední záloha: %1$s</string>
<string name="calendars_auto_backup_status_failed">Poslední zálohování selhalo: %1$s</string>
<string name="backup_channel_name">Záloha</string>
<string name="backup_channel_description">Upozorní, pokud automatické zálohování opakovaně selže.</string>
<string name="backup_failed_title">Automatické zálohování selhalo</string>
<string name="backup_failed_text">Calendula nemohla zapsat záložní soubor. Zkontrolujte složku pro zálohování v nastavení.</string>
<string name="calendars_backup_failed">Zálohu se nepodařilo exportovat.</string>
<plurals name="calendars_backup_done">
<item quantity="one">%d událost exportována.</item>
<item quantity="few">%d události exportovány.</item>
<item quantity="many">%d událostí exportováno.</item>
<item quantity="other">%d událostí exportováno.</item>
</plurals>
<string name="import_title">Importovat události</string>
<string name="import_target_header">Přidat do kalendáře</string>
<string name="import_empty">V souboru nenalezeny žádné události.</string>
<string name="import_failed">Tento soubor se nepodařilo přečíst.</string>
<string name="import_no_calendar">Neexistuje žádný kalendář s právem zápisu, do kterého by bylo možné importovat. Nejprve vytvořte místní kalendář.</string>
<string name="import_done_title">Import dokončen</string>
<string name="import_done_dedup_note">Události, které již byly v kalendáři, byly přeskočeny.</string>
<string name="import_done_added_label">Přidáno</string>
<string name="import_done_skipped_label">Duplicity</string>
<string name="import_done_failed_label">Nepřidáno</string>
<string name="import_done_failed_note">Některé události se nepodařilo přidat, a proto byly vynechány.</string>
<string name="import_done_stopped_title">Import byl zastaven</string>
<string name="import_close">Zavřít</string>
<string name="import_warning_recurrence">Některé změněné výskyty opakujících se událostí byly přeskočeny.</string>
<string name="import_warning_no_start">Událost bez času zahájení byla přeskočena.</string>
<string name="import_warning_attendees">Seznamy hostů nebyly importovány.</string>
<string name="import_warning_timezone">Neznámé časové pásmo se přepnulo na časové pásmo vašeho zařízení.</string>
<string name="import_warning_tasks">Úkoly v tomto souboru byly přidány jako události.</string>
<string name="import_warning_recurrence_repaired">Chybné pravidlo pro opakování bylo opraveno.</string>
<string name="import_button">Importovat</string>
<plurals name="import_title_count">
<item quantity="one">Importována %d událost</item>
<item quantity="few">Importovány %d události</item>
<item quantity="many">Importováno %d událostí</item>
<item quantity="other">Importováno %d událostí</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">V tomto souboru je %d událost.</item>
<item quantity="few">V tomto souboru jsou %d události.</item>
<item quantity="many">V tomto souboru je %d událostí.</item>
<item quantity="other">V tomto souboru je %d událostí.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importovat %d událost</item>
<item quantity="few">Importovat %d události</item>
<item quantity="many">Importovat %d událostí</item>
<item quantity="other">Importovat %d událostí</item>
</plurals>
<plurals name="import_done_stopped">
<item quantity="one">Kalendář přestal v průběhu procesu zpracovávat události. %d událost ze souboru nebyla přidána.</item>
<item quantity="few">Kalendář přestal v průběhu procesu zpracovávat události. %d události ze souboru nebyly přidány.</item>
<item quantity="many">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
<item quantity="other">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">%d událost importována.</item>
<item quantity="few">%d události importovány.</item>
<item quantity="many">%d událostí importováno.</item>
<item quantity="other">%d událostí importováno.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Přeskočena %d událost, která již v tomto kalendáři existuje.</item>
<item quantity="few">Přeskočeny %d události, která již v tomto kalendáři existují.</item>
<item quantity="many">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
<item quantity="other">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="one">%d událost nebylo možné přidat.</item>
<item quantity="few">%d události nebylo možné přidat.</item>
<item quantity="many">%d událostí nebylo možné přidat.</item>
<item quantity="other">%d událostí nebylo možné přidat.</item>
</plurals>
<string name="shortcut_new_event_short">Nová událost</string>
<string name="shortcut_new_event_long">Vytvořit novou událost</string>
<string name="qs_tile_new_event_label">Nová událost</string>
<string name="settings_qs_tile">Přidat dlaždici rychlého nastavení</string>
<string name="settings_qs_tile_hint">Přidat dlaždici „Nová událost“ do panelu rychlého nastavení.</string>
<string name="crash_dialog_title">%1$s selhalo</string>
<string name="crash_dialog_message">%1$s se minule neočekávaně ukončilo. Můžete pomoci problém vyřešit odesláním tohoto hlášení. Hlášení zůstane ve vašem zařízení, dokud se jej nerozhodnete odeslat, a neobsahuje žádné osobní údaje ani obsah kalendáře pouze níže uvedené technické detaily.</string>
<string name="crash_dialog_report">Odeslat hlášení o selhání</string>
<string name="crash_dialog_dismiss">Později</string>
<string name="crash_report_clip_label">Hlášení o selhání %1$s</string>
<string name="crash_report_copied">Hlášení bylo zkopírováno do schránky</string>
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
<string name="special_dates_calendar_birthday">Narozeniny</string>
<string name="special_dates_calendar_anniversary">Výročí</string>
<string name="special_dates_calendar_custom">Speciální data</string>
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources> </resources>
+1
View File
@@ -323,6 +323,7 @@
<plurals name="agenda_range_days"> <plurals name="agenda_range_days">
<item quantity="one">%d jour</item> <item quantity="one">%d jour</item>
<item quantity="many">%d jours</item> <item quantity="many">%d jours</item>
<item quantity="other">%d jours</item>
</plurals> </plurals>
<string name="settings_section_views">Vues</string> <string name="settings_section_views">Vues</string>
<string name="settings_quick_switch_header">Bouton dinterrupteur rapide</string> <string name="settings_quick_switch_header">Bouton dinterrupteur rapide</string>
+40
View File
@@ -263,4 +263,44 @@
<string name="onboarding_done_button">Otvoriť môj kalendár</string> <string name="onboarding_done_button">Otvoriť môj kalendár</string>
<string name="reminder_channel_name">Pripomienky udalostí</string> <string name="reminder_channel_name">Pripomienky udalostí</string>
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string> <string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
<string name="agenda_today_action">Dnes</string>
<string name="agenda_header_today">Dnes</string>
<string name="search_back">Späť</string>
<string name="widget_new_event">Nová udalosť</string>
<string name="widget_prev_month">Predchádzajúci mesiac</string>
<string name="widget_next_month">Nasledujúci mesiac</string>
<string name="widget_today">Dnes</string>
<string name="filter_title">Kalendáre</string>
<string name="settings_title">Nastavenia</string>
<string name="settings_back">Späť</string>
<string name="back">Späť</string>
<string name="timeline_scale_custom">Vlastné</string>
<string name="agenda_range_day">Dnes</string>
<string name="state_failure_all_hidden">Všetky vaše kalendáre sú vypnuté.</string>
<string name="state_failure_all_hidden_action">Správa kalendárov</string>
<string name="field_copy_action">Kopírovať</string>
<string name="field_copied">Skopirovane do schránky</string>
<string name="field_copy_failed">Toto sa nepodarilo skopírovať</string>
<string name="event_detail_title">Názov</string>
<string name="reminder_after">%1$s po</string>
<string name="reminder_onboarding_title">Nikdy nezmeškajte udalosť</string>
<string name="reminder_onboarding_body">Samotný Android nezobrazuje pripomienky udalostí - to musí zabezpečiť aplikácia kalendáru. Nechajte túto prácu na Calendule.</string>
<string name="reminder_benefit_delivery_title">Pripomienky, doručené</string>
<string name="reminder_benefit_delivery_body">Pripomienky k vašim udalostiam vám prídu ako notifikácia, vždy a načas.</string>
<string name="reminder_benefit_duplicates_title">Používate ďalšiu aplikáciu s kalendárom?</string>
<string name="reminder_onboarding_skip_button">Neskôr</string>
<plurals name="search_delete_title">
<item quantity="one">Odstrániť %d udalosť?</item>
<item quantity="few">Odstrániť %d udalosti?</item>
<item quantity="many">Odstrániť %d udalostí?</item>
<item quantity="other">Odstrániť %d udalostí?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d udalosť odstránená</item>
<item quantity="few">%d udalosti odstránené</item>
<item quantity="many">%d odstránených udalostí</item>
<item quantity="other">%d odstránených udalostí</item>
</plurals>
<string name="agenda_range_this_week">Tento týždeň</string>
<string name="agenda_range_custom">Vlastné…</string>
</resources> </resources>
+9
View File
@@ -11,12 +11,20 @@
<string name="state_retry">Retry</string> <string name="state_retry">Retry</string>
<string name="state_failure_unknown">Something went wrong.</string> <string name="state_failure_unknown">Something went wrong.</string>
<string name="state_failure_permission">Calendar access is required.</string> <string name="state_failure_permission">Calendar access is required.</string>
<string name="state_failure_permission_body">Calendula reads your events straight from the system calendar, so it needs calendar access to show anything.</string>
<string name="state_failure_permission_action">Grant access</string> <string name="state_failure_permission_action">Grant access</string>
<string name="state_failure_no_calendars">No calendars configured.</string> <string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_body">Add a calendar account to this device and its calendars show up here.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string> <string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_all_hidden">All your calendars are switched off.</string> <string name="state_failure_all_hidden">All your calendars are switched off.</string>
<string name="state_failure_all_hidden_body">Switch at least one calendar back on to see its events.</string>
<string name="state_failure_all_hidden_action">Manage calendars</string> <string name="state_failure_all_hidden_action">Manage calendars</string>
<string name="state_failure_no_import_target">No calendar can take new events.</string>
<string name="state_failure_no_import_target_body">The calendars that are switched on are read-only, managed by another app, or not synced to this device. Switch on a calendar that can take events, or add a local one.</string>
<string name="state_failure_event_not_found">This event is no longer there.</string>
<string name="state_failure_event_not_found_body">It may have been deleted, or moved to a calendar that is switched off.</string>
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<string name="state_failure_provider_body">The system calendar did not answer. Try again in a moment.</string>
<!-- Long-press a field to copy it (#195) --> <!-- Long-press a field to copy it (#195) -->
<string name="field_copy_action">Copy</string> <string name="field_copy_action">Copy</string>
@@ -679,6 +687,7 @@
<string name="calendars_restore_header">Restore</string> <string name="calendars_restore_header">Restore</string>
<string name="calendars_restore_action">Restore from .ics file</string> <string name="calendars_restore_action">Restore from .ics file</string>
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string> <string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
<string name="calendars_auto_backup">Automatic backup</string> <string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string> <string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string> <string name="calendars_auto_backup_folder">Backup folder</string>
@@ -36,12 +36,13 @@ class ModelsTest {
} }
@Test @Test
fun `FailureReason enum has all six variants`() { fun `FailureReason enum has all seven variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo( assertThat(FailureReason.values().toSet()).isEqualTo(
setOf( setOf(
FailureReason.PermissionRevoked, FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured, FailureReason.NoCalendarsConfigured,
FailureReason.AllCalendarsHidden, FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
FailureReason.ProviderUnavailable, FailureReason.ProviderUnavailable,
FailureReason.EventNotFound, FailureReason.EventNotFound,
FailureReason.Unknown, FailureReason.Unknown,
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.Availability
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
/**
* Reading a Google Calendar export (Codeberg #304), whose dialect the parser had
* never been held to: `VTIMEZONE` blocks with `X-LIC-LOCATION`, `TZID`-qualified
* `DTSTART`/`EXDATE`, folded `DESCRIPTION` and `ATTENDEE` lines, `RECURRENCE-ID`
* overrides written as separate `VEVENT`s, and `@google.com` UIDs.
*
* The fixture is a trimmed copy of what "Settings → Import & export → Export"
* produces, CRLF and all. Note that Google hands that export out as a **zip**
* containing one `.ics` per calendar the file here is one member of it.
*/
class IcsGoogleImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private val result: IcsParseResult = parser.parse(
checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/google-calendar-export.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) },
)
private fun event(summary: String) = result.events.single { it.summary == summary }
@Test
fun `every master event imports and the VTIMEZONE block is not mistaken for one`() {
assertThat(result.events.map { it.summary })
.containsExactly("Team standup", "Day off", "Christmas break")
}
@Test
fun `X-WR-CALNAME names the calendar for all of them`() {
assertThat(result.events.map { it.calendarName }.distinct())
.containsExactly("jane@example.com")
}
@Test
fun `a TZID-qualified start resolves against the device tz database`() {
val standup = event("Team standup")
assertThat(standup.isAllDay).isFalse()
assertThat(standup.zoneId).isEqualTo("Europe/Berlin")
// 10:00 CEST is 08:00 UTC.
assertThat(standup.start.toString()).isEqualTo("2026-09-15T08:00:00Z")
assertThat((standup.end - standup.start).inWholeMinutes).isEqualTo(90)
}
@Test
fun `the recurrence rule and its TZID-qualified EXDATE survive`() {
val standup = event("Team standup")
assertThat(standup.recurrenceRule).isEqualTo("FREQ=WEEKLY;BYDAY=TU")
assertThat(standup.exDates).containsExactly("20260929T080000Z")
}
@Test
fun `the moved occurrence is skipped rather than imported as a duplicate`() {
// Google writes a RECURRENCE-ID override as its own VEVENT carrying the
// master's UID. Importing it would put a second "standup" in the
// calendar; Calendula models no overrides, so it is reported instead.
assertThat(result.events.map { it.summary }).doesNotContain("Team standup (moved)")
assertThat(result.warnings).contains(IcsParseWarning.ModifiedOccurrenceSkipped)
}
@Test
fun `a folded DESCRIPTION is unfolded and unescaped`() {
assertThat(event("Team standup").description).isEqualTo(
"Weekly sync with the team.\nAgenda lives in the shared doc, see the link below.",
)
}
@Test
fun `an escaped comma in LOCATION comes back as a comma`() {
assertThat(event("Team standup").location).isEqualTo("Meeting room 2, 3rd floor")
}
@Test
fun `attendees are reported rather than silently dropped`() {
assertThat(result.warnings).contains(IcsParseWarning.AttendeesIgnored)
}
@Test
fun `a timed VALARM becomes its lead time in minutes`() {
assertThat(event("Team standup").semanticReminderMinutes()).containsExactly(30)
}
@Test
fun `an all-day event keeps the single day the file gives it`() {
val dayOff = event("Day off")
assertThat(dayOff.isAllDay).isTrue()
assertThat((dayOff.end - dayOff.start).inWholeDays).isEqualTo(1)
assertThat(dayOff.availability).isEqualTo(Availability.Free)
}
@Test
fun `an all-day alarm comes back as whole days before`() {
// Google writes an all-day reminder as a whole-day offset from the
// event's UTC midnight; a day has to survive as a day.
assertThat(event("Day off").semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a multi-day all-day event keeps its exclusive DTEND span`() {
val christmas = event("Christmas break")
assertThat(christmas.isAllDay).isTrue()
assertThat((christmas.end - christmas.start).inWholeDays).isEqualTo(3)
}
@Test
fun `no recurrence rule is repaired - Google writes them well-formed`() {
assertThat(result.warnings).doesNotContain(IcsParseWarning.RecurrenceRuleRepaired)
}
}
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* A component whose `END:` line never arrives (a download cut short, a producer
* that dropped a line). `indexOfEnd` falls back to the end of the file, so the
* block is read as if it had been closed.
*/
class IcsUnterminatedComponentTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
@Test
fun `file truncated mid-property keeps the event it had started`() {
val result = parser.parse(
"""
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:truncated-1@fixture
SUMMARY:Cut off mid-file
DTSTART:20260924T100000Z
DTEND:20260924T1
""".trimIndent(),
)
assertEquals(1, result.events.size)
val event = result.events.single()
assertEquals("Cut off mid-file", event.summary)
// The truncated DTEND does not parse, so the event is zero length.
assertEquals(event.start, event.end)
assertEquals(emptySet<IcsParseWarning>(), result.warnings)
}
@Test
fun `a missing END between two events does not merge them`() {
val result = parser.parse(
"""
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:first@fixture
SUMMARY:First event
DTSTART:20260924T100000Z
DTEND:20260924T110000Z
BEGIN:VEVENT
UID:second@fixture
SUMMARY:Second event
DTSTART:20260925T100000Z
DTEND:20260925T110000Z
END:VEVENT
END:VCALENDAR
""".trimIndent(),
)
assertEquals(
listOf("First event", "Second event"),
result.events.map { it.summary },
)
assertEquals(
listOf("first@fixture", "second@fixture"),
result.events.map { it.uid },
)
}
}
@@ -0,0 +1,98 @@
package de.jeanlucmakiola.calendula.ui.calendars
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import org.junit.jupiter.api.Test
/** What the Backup & restore screen offers for a given calendar list (#304). */
class BackupUiStateTest {
private fun cal(
id: Long,
local: Boolean = false,
writable: Boolean = true,
visible: Boolean = true,
managed: Boolean = false,
syncs: Boolean = true,
) = CalendarSource(
id = id,
displayName = "Calendar $id",
accountName = "acc@example.com",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = visible,
canModifyContents = writable,
isLocal = local,
isManaged = managed,
syncsEvents = syncs,
)
private fun failure(state: BackupUiState) = (state as BackupUiState.Failure).reason
private fun ready(state: BackupUiState) = state as BackupUiState.Ready
@Test
fun `no calendars at all reports the empty device`() {
// This is #304's device. The screenshots on the issue show Calendula's
// own calendar list with no local calendar and an empty "synced
// calendars" section — the app saw nothing at all, so both halves of
// Backup & restore had nothing to draw and the screen came up blank.
// The reporter's "it's synced, I can see it in settings" meant Android's
// settings, not this app's.
assertThat(failure(backupUiState(emptyList())))
.isEqualTo(FailureReason.NoCalendarsConfigured)
}
@Test
fun `a synced writable calendar can receive a restore`() {
val state = ready(backupUiState(listOf(cal(1L))))
assertThat(state.canImport).isTrue()
assertThat(state.exportable).isEmpty()
}
@Test
fun `an account that has stopped syncing is no import target`() {
// A calendar the provider no longer keeps events for: nothing to export
// and nowhere to import to, which used to render a blank screen. Not
// #304's device — that one had no calendars whatsoever — but the same
// dead end, and reachable on its own.
assertThat(failure(backupUiState(listOf(cal(1L, syncs = false)))))
.isEqualTo(FailureReason.NoImportTarget)
}
@Test
fun `a read-only subscription is no import target either`() {
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
.isEqualTo(FailureReason.NoImportTarget)
}
@Test
fun `everything switched off is reported as hidden, not as a missing target`() {
// The remedy differs: a visibility switch fixes this one.
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
.isEqualTo(FailureReason.AllCalendarsHidden)
}
@Test
fun `a hidden local calendar still exports`() {
// Export reads the provider directly, so system visibility is irrelevant
// to it — calling this a failure would hide a working action.
val state = ready(backupUiState(listOf(cal(1L, local = true, visible = false))))
assertThat(state.exportable).hasSize(1)
assertThat(state.canImport).isFalse()
}
@Test
fun `a managed special-dates mirror is neither exportable nor a target`() {
assertThat(failure(backupUiState(listOf(cal(1L, local = true, managed = true)))))
.isEqualTo(FailureReason.NoImportTarget)
}
@Test
fun `a local calendar alongside a synced one offers both halves`() {
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
assertThat(state.exportable.map { it.id }).containsExactly(1L)
assertThat(state.canImport).isTrue()
}
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class BlockTextMetricsTest {
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
private val titleLine = 14.dp
/** A block with room to spare, for the line arithmetic. */
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
inset = blockTextInset(height, titleLine),
available = height - blockTextInset(height, titleLine) * 2,
titleLine = titleLine,
titleLeading = 16.dp,
timeLine = 13.dp,
)
@Test
fun `a block with room keeps the full inset`() {
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
.isEqualTo(BLOCK_TEXT_INSET)
}
@Test
fun `the inset tapers instead of snapping as the block shrinks`() {
// Half the inset left over is half the inset kept, so a pinch closes the
// gap frame by frame rather than dropping it in one.
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
.isEqualTo(1.dp)
}
@Test
fun `a block exactly one title tall spends nothing on padding`() {
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `a block shorter than a line never insets negatively`() {
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `the title survives a block that used to be too short for it`() {
// 18dp is under the old floor — a title line plus 2dp of inset at each
// edge — and over the new one, which is the line on its own (#289).
val m = metrics(height = 18.dp)
assertThat(m.fitsTitle).isTrue()
}
@Test
fun `a block under one line still drops the title`() {
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
}
@Test
fun `every title line after the first costs a whole line box`() {
// The trim reaches the outer edges only, so the leading between two
// lines is still there to pay for.
val m = metrics()
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
}
@Test
fun `the budget is what the block can actually draw, not what divides into it`() {
val m = metrics()
// Two lines cost 30dp: 29 buys one, 30 buys the second.
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
}
@Test
fun `a budget line is always one the block can pay for`() {
val m = metrics()
(0..80).forEach { dp ->
val within = dp.dp
val budget = m.titleBudget(within)
assertThat(m.titleHeight(budget)).isAtMost(within)
}
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -75,17 +76,16 @@ class TimelineZoomTest {
} }
@Test @Test
fun `a pinch held against a fractional bound stays put`() { fun `a pinch held against either bound stays put`() {
// A bound that is not a whole pixel is a height the pinch can be pushed
// against but never land on, so every frame of a held gesture would look
// like a scale change and hand the focal anchor a scroll correction.
val fillPx = 62.083f val fillPx = 62.083f
val maxPx = 616.5f val maxPx = 616.5f
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx) val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx) val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
assertThat(floor).isEqualTo(63f) // The ceiling is pulled onto the pixel grid so it stays landable; the
// fill floor is landable as it is, being the clamp result itself.
assertThat(floor).isEqualTo(fillPx)
assertThat(ceiling).isEqualTo(616f) assertThat(ceiling).isEqualTo(616f)
// Landing there and being pushed further must not move them again. // Landing there and being pushed further must not move them again.
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor) assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
@@ -100,6 +100,35 @@ class TimelineZoomTest {
assertThat(floor * 24).isAtLeast(viewport) assertThat(floor * 24).isAtLeast(viewport)
} }
@Test
fun `pinching all the way out leaves nothing to scroll`() {
// #290: rounding the fill floor up to a whole pixel made the day one
// pixel per hour taller than the viewport it was supposed to fill, so a
// fully zoomed-out timeline still scrolled a hair and bounced off
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
// still. 1490 is deliberately not divisible by 24.
val viewport = 1490f
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
assertThat(floor * 24).isWithin(0.01f).of(viewport)
}
@Test
fun `the pinch floor is the height FitDay resolves to`() {
// The inconsistency the issue is about: the two ways to reach "the whole
// day on one screen" have to arrive at the same height.
val density = Density(2.5f)
val viewport = 596.dp
with(density) {
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
val pinched = pinchedHourHeightPx(
target = 1f,
fillPx = fillHourHeight(viewport).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
assertThat(pinched).isWithin(0.01f).of(fitDay)
}
}
@Test @Test
fun `a settled pinch is what gets persisted`() { fun `a settled pinch is what gets persisted`() {
var persisted: TimelineScale? = null var persisted: TimelineScale? = null
@@ -56,7 +56,13 @@ class ChipAtCellYTest {
) )
private fun MonthWeek.chipAt(col: Int, cellY: Float) = private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight) chipAtCellY(
col = col,
cellY = cellY,
bandTopInCell = bandTop,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
)
@Test @Test
fun `a tap on a lane resolves to the chip seated there`() { fun `a tap on a lane resolves to the chip seated there`() {
@@ -98,13 +104,13 @@ class ChipAtCellYTest {
@Test @Test
fun `a tap on the overflow row opens the day rather than a hidden event`() { fun `a tap on the overflow row opens the day rather than a hidden event`() {
val events = (1..MAX_EVENT_ROWS + 2).map { val events = (1..SPLIT_DOT_LANES + 2).map {
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong()) timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
} }
val week = rowOfJuly6(events) val week = rowOfJuly6(events)
// The dots sit one lane below the last one the row draws. // The dots sit one lane below the last one the row draws.
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull() assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
} }
@@ -113,10 +119,22 @@ class ChipAtCellYTest {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat( assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight), week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = null,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
),
).isNull() ).isNull()
assertThat( assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f), week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = bandTop,
rowHeightPx = 0f,
laneCap = SPLIT_DOT_LANES,
),
).isNull() ).isNull()
} }
} }
@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
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.toInstant
import org.junit.jupiter.api.Test
/**
* How many chip lanes a week row seats at a given height (#190) the cap that
* used to be a flat three whatever the device had.
*/
class MonthLaneCapTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private val monday = LocalDate(2026, 7, 6)
/** Cell chrome above the band: 6 + 22 + 4. */
private val header = 32.dp
private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = day.atTime(hour, 0).toInstant(zone),
end = day.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
/** Jul 6-12, wholly inside July 2026. */
private fun week(eventsOnMonday: Int) = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
(1..eventsOnMonday).map { timed(monday, hour = it, id = it.toLong()) },
zone,
)[1]
private fun rowFor(bandHeight: Int) = header + bandHeight.dp
/** A labelSmall line at font scale 1 — what the overflow row measures to. */
private val overflowRow = 16.dp
private fun MonthWeek.capAt(rowHeight: androidx.compose.ui.unit.Dp) =
laneCapFor(rowHeight, overflowRow)
@Test
fun `a week that fits spends the overflow row on another lane`() {
// 80dp of band is four 20dp lanes. Nothing overflows at four, so no
// room is set aside for a marker that would never be drawn.
assertThat(week(eventsOnMonday = 4).capAt(rowFor(80))).isEqualTo(4)
}
@Test
fun `a week that overflows pays for the dots out of its own lanes`() {
// The same 80dp, but a fifth event means the dots have to be drawn, and
// their 14dp comes off the band before it is divided.
assertThat(week(eventsOnMonday = 5).capAt(rowFor(80))).isEqualTo(3)
}
@Test
fun `a taller row seats more, with no ceiling`() {
assertThat(week(eventsOnMonday = 20).capAt(rowFor(120))).isEqualTo(5)
assertThat(week(eventsOnMonday = 20).capAt(rowFor(220))).isEqualTo(10)
}
@Test
fun `a cramped row seats fewer rather than drawing past its band`() {
// 58dp was three clipped lanes and dots nobody could see. It is two
// lanes and a visible marker.
assertThat(week(eventsOnMonday = 6).capAt(rowFor(58))).isEqualTo(2)
}
@Test
fun `a row with no height to give still seats one lane`() {
assertThat(week(eventsOnMonday = 6).capAt(rowFor(0))).isEqualTo(1)
assertThat(week(eventsOnMonday = 6).capAt(10.dp)).isEqualTo(1)
}
@Test
fun `an empty week never charges itself for dots`() {
assertThat(week(eventsOnMonday = 0).capAt(rowFor(80))).isEqualTo(4)
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.theme package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -48,6 +49,19 @@ class FontsTest {
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain) assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
} }
@Test
fun `picking a font keeps the tightened label tracking`() {
// The font picker only swaps the family; the tracking the grids are laid
// out against has to survive it (#190).
val typography = calendulaTypography(
brand = BundledFont.Lora.family,
plain = BundledFont.AtkinsonHyperlegible.family,
)
assertThat(typography.labelSmall.letterSpacing).isEqualTo(0.1.sp)
assertThat(typography.labelMedium.letterSpacing).isEqualTo(0.1.sp)
}
@Test @Test
fun `a null role keeps that role's default family while the other is applied`() { fun `a null role keeps that role's default family while the other is applied`() {
val plain = BundledFont.Lora.family val plain = BundledFont.Lora.family
@@ -0,0 +1,91 @@
BEGIN:VCALENDAR
PRODID:-//Google Inc//Google Calendar 70.9054//EN
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:jane@example.com
X-WR-TIMEZONE:Europe/Berlin
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20260915T100000
DTEND;TZID=Europe/Berlin:20260915T113000
RRULE:FREQ=WEEKLY;BYDAY=TU
EXDATE;TZID=Europe/Berlin:20260929T100000
DTSTAMP:20260912T183000Z
UID:0abcdef1234567890abcdef12345@google.com
CREATED:20260901T120000Z
DESCRIPTION:Weekly sync with the team.\nAgenda lives in the shared doc\, see
the link below.
LAST-MODIFIED:20260902T081500Z
LOCATION:Meeting room 2\, 3rd floor
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Team standup
TRANSP:OPAQUE
ORGANIZER;CN=Jane Doe:mailto:jane@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=John:m
ailto:john@example.com
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:This is an event reminder
TRIGGER:-PT30M
END:VALARM
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Europe/Berlin:20260929T140000
DTEND;TZID=Europe/Berlin:20260929T153000
DTSTAMP:20260912T183000Z
UID:0abcdef1234567890abcdef12345@google.com
RECURRENCE-ID;TZID=Europe/Berlin:20260929T100000
CREATED:20260901T120000Z
LAST-MODIFIED:20260910T091500Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Team standup (moved)
TRANSP:OPAQUE
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20261024
DTEND;VALUE=DATE:20261025
DTSTAMP:20260912T183000Z
UID:1bcdef01234567890abcdef23456@google.com
CREATED:20260820T101500Z
LAST-MODIFIED:20260820T101500Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Day off
TRANSP:TRANSPARENT
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:This is an event reminder
TRIGGER:-P1D
END:VALARM
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20261224
DTEND;VALUE=DATE:20261227
DTSTAMP:20260912T183000Z
UID:2cdef012345678901abcdef34567@google.com
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Christmas break
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR
+2 -2
View File
@@ -1,7 +1,7 @@
--- ---
title: Privacy Policy — Calendula title: Privacy Policy — Calendula
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all. description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
updated: 2026-07-28 updated: 2026-09-15
--- ---
<!-- <!--
@@ -33,7 +33,7 @@ Calendula collects nothing, sends nothing, and has no user accounts. It has **no
IT-Dienstleister | Jean-Luc Makiola IT-Dienstleister | Jean-Luc Makiola
Mahlerstraße 10 Mahlerstraße 10
14772 Brandenburg an der Havel 14772 Brandenburg an der Havel
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de) Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
## 2. No data collection ## 2. No data collection
@@ -0,0 +1,2 @@
إصلاحات
• لم يعد النطاق المخصص في الجدول يُعطِّل التطبيق بالفرنسية. كان اختيار نطاق مخصص — أو مجرّد كتابة رقم في ذلك الحقل — يُغلق التطبيق على أي هاتف مضبوط على الفرنسية، ولم ينجُ سوى نطاق يوم واحد بالضبط. أما اللغات الأخرى فلم تتأثّر قطّ.
@@ -0,0 +1,2 @@
Behoben
• Ein benutzerdefinierter Agenda-Zeitraum stürzt auf Französisch nicht mehr ab. Einen eigenen Zeitraum zu wählen oder auch nur eine Zahl in das Feld zu tippen beendete die App auf jedem Gerät mit französischer Sprache; nur genau ein Tag blieb verschont. Andere Sprachen waren nie betroffen.
@@ -0,0 +1,2 @@
Fixed
• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected.
@@ -0,0 +1,2 @@
Fixed
• A custom Agenda range no longer crashes the app in French. Choosing a custom range — or just typing a number into that field — closed the app on any phone set to French, and only a range of exactly one day escaped it. Other languages were never affected.
@@ -0,0 +1,3 @@
Fixed
• The app no longer reopens the event you last came in on. Opening an event from a reminder or a widget made it return to that event on every later launch, or show an error screen once the event was gone.
• "Today" works in the Agenda again. It now scrolls the list back to today, and stays offered while you are scrolled away from it.
@@ -0,0 +1,2 @@
Correcciones
• El intervalo personalizado de la Agenda ya no bloquea la aplicación en francés. Elegir un intervalo propio —o solo escribir un número en ese campo— cerraba la aplicación en cualquier teléfono en francés, y únicamente un intervalo de exactamente un día se salvaba. Los demás idiomas nunca se vieron afectados.
@@ -0,0 +1,2 @@
Corrections
• La plage personnalisée de lAgenda ne fait plus planter lapplication en français. Choisir une plage personnalisée — ou simplement saisir un nombre dans ce champ — fermait lapplication sur tout téléphone en français ; seule une plage dexactement un jour y échappait.
@@ -0,0 +1,2 @@
Correzioni
• Lintervallo personalizzato dellAgenda non fa più chiudere lapp in francese. Scegliere un intervallo personalizzato — o anche solo digitare un numero in quel campo — chiudeva lapp su qualsiasi telefono impostato in francese; solo un intervallo di esattamente un giorno ne era immune. Le altre lingue non sono mai state interessate.
@@ -0,0 +1,2 @@
Poprawki
• Niestandardowy zakres Agendy nie powoduje już awarii aplikacji w języku francuskim. Wybranie własnego zakresu albo samo wpisanie liczby w to pole zamykało aplikację na każdym telefonie ustawionym na francuski; bezpieczny był tylko zakres dokładnie jednego dnia. Innych języków to nigdy nie dotyczyło.
@@ -0,0 +1,2 @@
Correções
• O intervalo personalizado da Agenda não fecha mais o aplicativo em francês. Escolher um intervalo personalizado — ou apenas digitar um número nesse campo — fechava o aplicativo em qualquer telefone configurado em francês; só um intervalo de exatamente um dia escapava. Os outros idiomas nunca foram afetados.
@@ -0,0 +1,2 @@
Correções
• O intervalo personalizado da Agenda já não fecha a aplicação em francês. Escolher um intervalo personalizado — ou apenas escrever um número nesse campo — fechava a aplicação em qualquer telemóvel definido em francês; só um intervalo de exatamente um dia escapava. Os outros idiomas nunca foram afetados.
@@ -0,0 +1,2 @@
Исправлено
• Свой диапазон в Расписании больше не приводит к сбою на французском языке. Выбор своего диапазона — или даже простой ввод числа в это поле — закрывал приложение на любом телефоне с французским языком; уцелел только диапазон ровно в один день. Другие языки это никогда не затрагивало.
@@ -0,0 +1,2 @@
修复
• 法语下的日程自定义范围不再导致应用崩溃。选择自定义范围,甚至只是在该字段中输入数字,都会让应用在设置为法语的手机上关闭,只有恰好一天的范围不受影响。其他语言从未受到影响。
@@ -4,7 +4,7 @@ Name: Calendula
Summary: A modern Material 3 Expressive calendar for Android. Summary: A modern Material 3 Expressive calendar for Android.
Categories: Categories:
- Time - Calendar & Agenda
SourceCode: https://codeberg.org/jlmakiola/calendula SourceCode: https://codeberg.org/jlmakiola/calendula
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
+46 -7
View File
@@ -8,7 +8,10 @@ and are produced via Weblate. This guard keeps incoming translation PRs honest:
* a translation must not define keys absent from the base those are stale * a translation must not define keys absent from the base those are stale
keys left behind after a rename/removal upstream; keys left behind after a rename/removal upstream;
* a translation must not translate strings marked ``translatable="false"`` in * a translation must not translate strings marked ``translatable="false"`` in
the base (URLs, IDs and the like). the base (URLs, IDs and the like);
* every translated ``<plurals>`` must carry an ``other`` item, because that
is the one quantity form Android cannot fall back for (see
``plurals_missing_other``).
Missing keys are *allowed* and only reported as coverage: a missing string Missing keys are *allowed* and only reported as coverage: a missing string
falls back to the English base at runtime, so partial translations are fine falls back to the English base at runtime, so partial translations are fine
@@ -27,10 +30,13 @@ RES_DIR = Path("app/src/main/res")
BASE = RES_DIR / "values" / "strings.xml" BASE = RES_DIR / "values" / "strings.xml"
RESOURCE_TAGS = ("string", "plurals", "string-array") RESOURCE_TAGS = ("string", "plurals", "string-array")
# The quantity Android itself falls back to, and therefore the one a translated
# <plurals> may never omit.
PLURAL_FALLBACK = "other"
def entries(path: Path) -> dict[str, bool]:
"""Map resource name -> is-translatable for every entry in ``path``.""" def entries(root: ET.Element) -> dict[str, bool]:
root = ET.parse(path).getroot() """Map resource name -> is-translatable for every entry under ``root``."""
return { return {
el.attrib["name"]: el.attrib.get("translatable", "true") != "false" el.attrib["name"]: el.attrib.get("translatable", "true") != "false"
for el in root for el in root
@@ -38,12 +44,35 @@ def entries(path: Path) -> dict[str, bool]:
} }
def plurals_missing_other(root: ET.Element) -> list[str]:
"""Names of ``<plurals>`` under ``root`` that lack an ``other`` item.
Android resolves the CLDR quantity for a count and, if that specific form is
absent, falls back to ``other`` so a translation may legitimately skip
forms its locale rarely uses (Arabic ``zero``, French ``many``). There is no
fallback for ``other`` itself, and none to the base locale either: a
translated ``<plurals>`` without it raises
``Resources$NotFoundException`` as soon as a count selects the missing form.
That is Codeberg #297/#298 — ``values-fr`` translated ``agenda_range_days``
with only ``one`` and ``many``, and since French ``many`` matches only
millions, every custom agenda range from 2 days up crashed the app.
"""
return sorted(
el.attrib["name"]
for el in root
if el.tag == "plurals"
and "name" in el.attrib
and not any(item.attrib.get("quantity") == PLURAL_FALLBACK for item in el.findall("item"))
)
def main() -> int: def main() -> int:
if not BASE.exists(): if not BASE.exists():
print(f"::error::base resource file {BASE} not found", file=sys.stderr) print(f"::error::base resource file {BASE} not found", file=sys.stderr)
return 1 return 1
base = entries(BASE) base = entries(ET.parse(BASE).getroot())
base_keys = set(base) base_keys = set(base)
nontranslatable = {name for name, ok in base.items() if not ok} nontranslatable = {name for name, ok in base.items() if not ok}
translatable_total = len(base_keys - nontranslatable) translatable_total = len(base_keys - nontranslatable)
@@ -57,16 +86,18 @@ def main() -> int:
for path in files: for path in files:
locale = path.parent.name[len("values-"):] locale = path.parent.name[len("values-"):]
try: try:
translated = entries(path) root = ET.parse(path).getroot()
except ET.ParseError as exc: except ET.ParseError as exc:
print(f"::error file={path}::{locale}: malformed XML: {exc}") print(f"::error file={path}::{locale}: malformed XML: {exc}")
errors += 1 errors += 1
continue continue
translated = entries(root)
keys = set(translated) keys = set(translated)
stale = sorted(keys - base_keys) stale = sorted(keys - base_keys)
translated_fixed = sorted(keys & nontranslatable) translated_fixed = sorted(keys & nontranslatable)
missing = base_keys - nontranslatable - keys missing = base_keys - nontranslatable - keys
no_other = plurals_missing_other(root)
for name in stale: for name in stale:
print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml") print(f"::error file={path}::{locale}: stale key '{name}' is not in the base strings.xml")
@@ -77,10 +108,18 @@ def main() -> int:
"in the base and must not be translated" "in the base and must not be translated"
) )
errors += 1 errors += 1
for name in no_other:
print(
f"::error file={path}::{locale}: plurals '{name}' has no "
f"<item quantity=\"{PLURAL_FALLBACK}\"> — Android cannot fall back for it "
"and will crash when a count selects it; translate that form or drop the "
"whole plurals to inherit the base"
)
errors += 1
covered = translatable_total - len(missing) covered = translatable_total - len(missing)
pct = covered * 100 // translatable_total if translatable_total else 100 pct = covered * 100 // translatable_total if translatable_total else 100
verdict = "OK" if not (stale or translated_fixed) else "FAIL" verdict = "OK" if not (stale or translated_fixed or no_other) else "FAIL"
print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}") print(f"{locale:<10} {covered}/{translatable_total} keys ({pct}%) — {verdict}")
if errors: if errors: