Compare commits

..
19 Commits
Author SHA1 Message Date
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
Jean-Luc Makiola 20c20b71ea Release v2.20.2 (#299)
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Failing after 7m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
2026-09-12 15:49:04 +02:00
Jean-Luc Makiola 0fb3df0b3a Merge pull request 'Translations update from Weblate' (#262) from jlmakiola_weblate_bot/calendula:weblate-calendula-strings into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 15s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
2026-09-12 15:23:25 +02:00
Weblate e10b09a9b5 Translated using Weblate (Arabic)
Currently translated at 100.0% (593 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-12 02:29:58 +00:00
Sup99999 806a576380 Translated using Weblate (Arabic)
Currently translated at 100.0% (593 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-12 02:29:58 +00:00
James Trivette 3c365c6792 Translated using Weblate (Czech)
Currently translated at 71.1% (422 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
Andrei Linchik 4fb4a85ac4 Translated using Weblate (Russian)
Currently translated at 42.4% (252 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-09-11 19:34:07 +00:00
Benutzer e7312e5d6e Translated using Weblate (Italian)
Currently translated at 98.6% (585 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-09-11 19:34:07 +00:00
Sup99999 12c2de7ed8 Translated using Weblate (Arabic)
Currently translated at 99.1% (588 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-11 19:34:07 +00:00
António de Seabra 8a3cc95c66 Translated using Weblate (Portuguese)
Currently translated at 76.8% (456 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-09-11 19:34:07 +00:00
James Trivette 9c06ffdb48 Translated using Weblate (Czech)
Currently translated at 66.1% (392 of 593 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
James Trivette 870a903015 Translated using Weblate (Czech)
Currently translated at 60.6% (353 of 582 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-09-11 19:34:07 +00:00
Sup99999 b5768e3b17 Translated using Weblate (Arabic)
Currently translated at 99.1% (577 of 582 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-09-11 19:34:07 +00:00
Dmitri Gabinski 02b33dbf07 Translated using Weblate (Belarusian)
Currently translated at 98.7% (567 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-09-11 19:34:07 +00:00
Jean-Luc Makiola a0c0b875f0 Move the privacy policy into the repo (#293)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
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/293
2026-09-09 16:23:47 +02:00
makiolaj 512bf8c746 docs: shape the policy as the content entry the site renders
Frontmatter carries the title, description and date, the body starts at
the first section rather than repeating the title as an h1, and the
maintainer note is an HTML comment — a blockquote would have rendered
"edit this in a PR" onto the published privacy page.
2026-09-09 16:14:42 +02:00
makiolaj 04257e8a66 docs: the privacy policy moves into the repo
The policy lived only as prose inside the website's Astro page, so it was
the one user-facing document with no review, no history here, and no way
to change it alongside the code that makes it true.

docs/PRIVACY.md is now that text, transcribed from the published page so
the wording is unchanged, and it becomes the source: the site's build
checks this repository out beside itself and renders this file through an
Astro content collection rather than keeping its own copy. One copy of the
policy exists anywhere, so the two cannot drift.

The README links it from Documentation and gains a Privacy section beside
License. Agendula is being set up the same way.
2026-09-09 16:13:42 +02:00
Jean-Luc Makiolaandmakiolaj c04b2ecee2 Release v2.20.1 (#288)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 31m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m22s
Release 2.20.1.

**Fixed**

- "No calendars configured" offered an "Open system calendar settings" button that opened nothing — every screen wired it to the same reload that had just produced the empty state. It now opens the system's account chooser, filtered to the providers that sync calendars ([#239]).
- A device whose calendars are all switched off no longer looks like an empty month. The app only ever checked whether the device had *no* calendars, so calendars hidden at system level fell through to a blank grid with no hint. That case now says so and points at Settings → Calendars. The navigation drawer's calendar list drew the same conflation and is fixed with it ([#239]).

**Beyond the issue**

Two things in here aren't part of #239:

- `fastlane/metadata/android/en-US/changelogs/22001.txt` carries the 2.20.0 notes as well as 2.20.1's. The 2.20.0 Play upload failed on a service-account permission (it lacked "Release to production"; 2.19.4 failed the same way), so Play is still on 2.19.3 and users jump straight to 2.20.1. Left alone they would have seen only the 2.20.1 bugfixes and never heard about the hour grid, copyable event fields or invitation outlines. 402 characters, inside Play's 500 limit. The permission has since been granted, so this release should be the one that lands.
- A merge of `main` into the branch. `main` took 2.20.0 as a squash, so the two had no common ancestor past 2.19.4 and this PR would otherwise have replayed all 74 files of 2.20.0. Resolved to the branch throughout; the tree is byte-for-byte unchanged by it.

Closes #239.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/288
2026-09-07 22:29:03 +02:00
Jean-Luc Makiolaandmakiolaj e10fae0710 Release v2.20.0 (#286)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 32m28s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m21s
Release 2.20.0.

**Added**
- Long-press an event's title, location or description to copy it ([#195]).
- Settings → About lists the open-source projects Calendula ships, with
  copyright holder, licence and a link to each project's source ([#281]).

**Changed**
- Week and day view seat every hour in its own rounded cell instead of drawing a
  separator line across the column; an on-the-hour event fills its cell rather
  than overhanging the seam ([#113]).
- Event titles run to the edge of their chip in all three grid views — no
  ellipsis. RTL keeps it, where clipping would cut the start of the title
  ([#164]).
- Month events show their start time where the chip is wide enough, and event
  times across month, week and day are set in a regular weight ([#219]).
- An invitation you haven't answered is drawn as an outline; declined events
  keep their filled, struck-through chip and now sort last in the month view
  ([#230]).
- The top-bar title falls back to a shorter form instead of being cut off, and
  the bar's trailing edge lines up with the grid under it ([#165]).
- The jump-to-today button carries today's date and rolls over at midnight
  ([#220]).
- The quick-switch pill hides when there is nothing to switch between, and views
  can now be turned off down to a single one ([#150]).

**Fixed**
- A long location wraps and its card grows, instead of scrolling sideways out of
  sight ([#273]).
- The event form capitalizes sentences again; location stays uncapitalized,
  being as often a URL as an address ([#146]).
- A dragged block's floating copy wraps its time and title the way the block
  does, instead of clipping to one line while held ([#267]).

### Release preparation

`versionName`/`versionCode` → 2.20.0/22000, `CHANGELOG.md`'s Unreleased section
moved under a dated 2.20.0 heading, and a hand-written 497-character
`fastlane/metadata/android/en-US/changelogs/22000.txt`.

Six of the merged PRs had no CHANGELOG entry (#146, #150, #195, #230, #267,
#281); they are written up here. The existing #164 entry only described the
month view and did not mention the RTL exception — both corrected.

### Also here

`scripts/check_changelog_lengths.sh`, run in CI next to the reproducible-release
invariant. Play rejects a "What's New" over 500 characters, which would fail the
upload *after* the release had already shipped to F-Droid and Codeberg —
2.19.4 went out at 697. The script walks every locale, fails on the current
`versionCode` and only warns on the already-published files, of which 13 are
over the limit from when the pipeline auto-generated them from `CHANGELOG.md`.

Deviation worth naming: this does not fix those 13. They are published and Play
never re-reads them, so failing on them would paint every PR red for no gain;
`--strict` covers them if they are ever backfilled.

Follow-up filed as #285 — the store listing exists in only 4 of 12 locales and
its graphics still don't qualify for Play.

All twelve issues above are closed by their own PRs; this branch ships them.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/286
2026-09-07 21:25:35 +02:00
104 changed files with 3767 additions and 562 deletions
+6
View File
@@ -37,6 +37,12 @@ jobs:
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge.
- name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh
# Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`.
+123 -1
View File
@@ -5,7 +5,114 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.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
### Fixed
- **Crash reports are filed in English.** Reporting a crash from a phone set to
another language prefilled the issue — title and body — in that language, so
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
text that is *posted* is now English for everyone; the dialog that asks you
to send it, and everything else you see in the app, is still translated.
## [2.20.1] — 2026-09-07
### Fixed
- **An empty calendar now explains itself.** "No calendars configured" offered
an "Open system calendar settings" button that opened nothing — every screen
wired it to the same reload that had just produced the empty state. It now
opens the system's account chooser, filtered to the providers that sync
calendars ([#239]).
- **Calendars that are all switched off no longer look like an empty month.**
When every calendar on the device is hidden at system level there is nothing
to draw, but the app said nothing at all: it only ever checked whether the
device had *no* calendars, so a device that had them and hid them fell
through to a blank grid with no hint. That case now says so, and points at
Settings → Calendars — the one screen that can switch a calendar back on. The
navigation drawer's calendar list drew the same conflation and is fixed with
it ([#239]).
## [2.20.0] — 2026-09-07
### Added
- **Long-press to copy.** Holding down the title, the location or the
description on an event puts that whole field on the clipboard. Tapping still
does what it always did — the location opens maps, a link in the description
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
Android 13 a short confirmation appears; from Android 13 on the system shows
its own clipboard chip ([#195]).
- **The open-source projects Calendula ships** are now listed under Settings →
About, each with its copyright holder and licence, and each row tapping
through to that project's source ([#281]).
### Changed
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
of characters and told you nothing you couldn't already see, so the title now
simply runs to the edge of its chip in the month, week and day views — a few
more letters per event, which is often the difference between two events you
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
where clipping would cut the beginning of a title rather than its end
([#164]).
- Month-view events now show their start time before the title, where the chip
is wide enough to keep the title readable — in landscape, on a foldable and on
a tablet, and never at the cost of a narrow phone column. All-day events show
no time, and neither does a bar carried in from the previous week, whose start
is not in that row. Event times across the month, week and day views are also
set in a regular weight against the title's, so a time reads as a time rather
than as part of the name next to it ([#219]).
- **The hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- **An invitation you haven't answered now reads as an outline.** An event still
waiting on your reply is drawn with the calendar's colour on its border and
its title and nothing behind it, so it stands apart from the ones you have
accepted — on the month chips, the all-day bars and the week and day blocks
alike. Declined events keep their filled, struck-through chip and now sort
last in the month view as well, so a day with more events than fit drops the
ones you said no to first ([#230]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
- The quick-switch pill disappears when there is nothing left to switch between,
and you can now turn calendar views off all the way down to a single one — the
setting used to hold two open purely to keep the pill fed ([#150]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
- **The event form capitalizes sentences again.** The title, description and
guest fields had stopped starting a sentence with a capital letter; they
follow the keyboard's usual behaviour once more. The location is deliberately
left alone, since it is as often a URL as an address ([#146]).
- **A block you pick up no longer loses its second line.** Dragging an event
whose time or title wrapped to two lines clipped the floating copy to one line
for as long as you held it, and it snapped back on drop. The copy now measures
its text exactly as the block does ([#267]).
## [2.19.4] — 2026-09-01
@@ -1560,6 +1667,8 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
@@ -1569,5 +1678,18 @@ automatically, with zero telemetry and no internet permission.
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
+12
View File
@@ -157,6 +157,8 @@ reinstalling. Google Play is the exception — see above.</sub>
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
## 🤝 Contributing
@@ -187,6 +189,16 @@ No coding needed — register on the Weblate server, pick (or request) a languag
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## 🔒 Privacy
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party
services — and **no internet permission at all**, so the app is technically
incapable of transmitting anything anywhere. Your events stay in Android's own
calendar store, where they already were.
**→ [Privacy policy](https://jeanlucmakiola.de/calendula/privacy)** — written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## 📜 License
[MIT](LICENSE) — Jean-Luc Makiola, 2026
+12 -8
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21904
versionName = "2.19.4"
versionCode = 22003
versionName = "2.20.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -113,12 +113,16 @@ android {
lint {
// 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
// it. Likewise a translated <plurals> may not fill every CLDR quantity
// form its locale defines (e.g. Arabic needs "zero"); the missing form
// falls back to "other" at runtime, so MissingQuantity is informational
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// check_translations.py guards the same invariants with clearer,
// translator-facing messages.
// it. A translated <plurals> may likewise skip a CLDR quantity form its
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
// any form it cannot find, so MissingQuantity is informational too.
// What a translation must NOT skip is "other" itself — nothing falls back
// for that one, not even the base locale, so it throws
// 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")
}
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand). The
// capture/loop-detection/report machinery lives in floret-kit's
// core-crash; only the app label + issue-tracker URLs are app-specific.
// The prefilled issue's own title and body are the kit's, in English:
// they are posted to the tracker, so they don't follow the device
// language the way the dialog around them does.
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_choose_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
reconcileAutoBackup()
@@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
LocalShowHourGrid provides settings.showHourGrid,
LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors,
) {
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper"
@@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
/**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation —
* your own events included — falls through to [EventResponse.Going].
*/
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
else -> EventResponse.Going
}
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
@@ -212,14 +212,15 @@ class SettingsPrefs @Inject constructor(
}
/**
* Whether the week/day timeline draws a faint separator line at each hour
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
* separator line until v2.20). Defaults to OFF — the historical flat column;
* users opt in. The stored key keeps its original name.
*/
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false
}
suspend fun setShowHourLines(enabled: Boolean) {
suspend fun setShowHourGrid(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
/**
* The failure a calendar list implies, or null when there is something to show.
* Distinguishes "the device has no calendars" from "every one of them is
* switched off" (#239) — both leave the grid empty, but only the second is
* fixed from Settings → Calendars.
*
* The filter sheet's own hidden set is deliberately not folded in: emptying it
* is a deliberate act taken moments earlier, in a sheet that is still one tap
* away.
*/
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
else -> null
}
@@ -68,14 +68,35 @@ data class EventInstance(
*/
val isRecurring: Boolean = false,
/**
* This device user answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
* This device user's own answer to the invitation, as far as the grids care
* (#180, #230).
*/
val isDeclined: Boolean = false,
val response: EventResponse = EventResponse.Going,
)
/**
* How this device user stands towards an event's invitation
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
* surfaces draw differently (#230).
*/
enum class EventResponse {
/** Your own event, or one you accepted — including a tentative "maybe". */
Going,
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
Invited,
/**
* You answered "no". The event stays on the calendar — it is still an
* appointment someone expects an answer about — but every surface strikes it
* through, and it plans no reminders (#180).
*/
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
@@ -231,6 +252,7 @@ enum class RecurringWriteScope {
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,
AllCalendarsHidden,
ProviderUnavailable,
EventNotFound,
Unknown,
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -344,7 +345,12 @@ fun CalendarHost(
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = fadeThrough()
CompositionLocalProvider(LocalEventMove provides moveScope) {
CompositionLocalProvider(
LocalEventMove provides moveScope,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true },
) {
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,9 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -51,6 +50,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -95,7 +96,6 @@ fun AgendaScreen(
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
@@ -130,16 +130,16 @@ fun AgendaScreen(
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = modifier,
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -159,13 +159,24 @@ fun AgendaScreen(
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
@@ -245,6 +256,9 @@ private fun AgendaRangePill(
}
}
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
@@ -409,17 +423,22 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
@@ -440,14 +459,16 @@ private fun AgendaTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
scrollBehavior = scrollBehavior,
)
}
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor
val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}
@@ -10,24 +10,137 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
* Lets a block hand out its height by what the text actually asks for rather
* than by what would fit: a title that wants one line should not be given three
* that the time label could have used, and a week column is narrower than a
* "09:3011:00" range so the range should not be assumed to want one.
*/
@Composable
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(text, style, widthPx, max, measurer) {
if (max <= 1 || widthPx <= 0) {
1
} else {
measurer.measure(
text = text,
style = style,
constraints = Constraints(maxWidth = widthPx),
).lineCount.coerceIn(1, max)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:3011:00" range, so the label takes a
* second line rather than lose its end — but only out of a line the title
* measured itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
}
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
}
/**
* A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
fun BlockTimeLabel(
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap()
} else {
MaterialTheme.motionScheme.fastEffectsSpec()
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Crossfade(
targetState = label,
animationSpec = spec,
@@ -36,9 +149,11 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
) { text ->
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
// Regular weight against the title's medium above it (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
)
}
@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -10,8 +14,10 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -24,18 +30,29 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
*/
@Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current
val titleRes = when (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) {
FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() }
}
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
else -> onRetry
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -49,8 +66,30 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onRetry) {
FilledTonalButton(onClick = onAction) {
Text(stringResource(actionRes))
}
}
}
/**
* Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the
* reason that needs it, and fall back to their own retry.
*/
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
/**
* Open the system screen for putting a calendar on the device: the account
* chooser filtered to calendar providers, falling back to account settings
* where no chooser resolves. Both are system components, so nothing here needs
* a `<queries>` entry.
*/
private fun Context.startCalendarSetup() {
val intents = listOf(
Intent(Settings.ACTION_ADD_ACCOUNT)
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
Intent(Settings.ACTION_SYNC_SETTINGS),
)
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
}
@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
* [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
*/
@Composable
fun CalendarTitleButton(
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
shortTitle: String = title,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
.padding(horizontal = AppBarSpacing.TitleInset),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
)
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
Text(
text = shown,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
)
}
}
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle
@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
@@ -0,0 +1,104 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
import kotlinx.coroutines.launch
/** Puts one labelled field on the clipboard. */
fun interface FieldCopier {
operator fun invoke(label: String, text: String)
}
/**
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
*
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
* of it reads as the app having done the job twice. A failure is always worth a
* word, though: the clipboard can refuse a very long description outright.
*/
@Composable
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val confirmation = stringResource(R.string.field_copied)
val failure = stringResource(R.string.field_copy_failed)
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
FieldCopier { label, text ->
scope.launch {
val message = runCatching {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
}.fold(
onSuccess = {
confirmation.takeIf {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
}
},
onFailure = { failure },
)
if (message != null) {
// Copying twice in a row shouldn't queue two four-second
// confirmations — the newest one wins.
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(message)
}
}
}
}
}
/**
* Long-press to copy [text] whole, filed on the clipboard under [label].
*
* [onTap] carries a field's existing tap action through; a field without one
* stays unclickable rather than growing a ripple that leads nowhere, and gets
* its long press announced through semantics instead. That branch merges the
* node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused.
*/
@Composable
fun Modifier.copyOnLongPress(
label: String,
text: String,
copy: FieldCopier,
onTap: (() -> Unit)? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
},
)
}.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
}
@@ -0,0 +1,165 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
/** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp
/**
* The surface an event chip is drawn on, which an outlined one fills itself with
* so it reads as the background showing through rather than as a pale block laid
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
* columns carry; the all-day strips sit on plain `surface` and a floating drag
* copy wants a lifted one, so both provide their own.
*/
val LocalChipGround = compositionLocalOf { Color.Unspecified }
/**
* Which edges of a chip the event runs past. Those edges are squared by
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
* an outline has to leave them open for the same reason — a stroke all the way
* round would close a bar carried across a week boundary into two boxes.
*/
@Immutable
data class ChipCuts(
val start: Boolean = false,
val end: Boolean = false,
val top: Boolean = false,
val bottom: Boolean = false,
)
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
ChipCuts(start = continuesLeft, end = continuesRight)
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
ChipCuts(top = continuesBefore, bottom = continuesAfter)
/**
* How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230).
*
* An invitation you have not answered is drawn as an outline: its calendar's
* colour on the border and on the title, over the surface the chip sits on. So
* it holds a chip's shape and a chip's weight without claiming the filled
* container an event you are going to gets. A declined one keeps the fill and
* its strike-through.
*/
@Immutable
data class EventPaint(
val fill: Color,
/** Null for a filled chip; the border colour when the chip is outlined. */
val outline: Color?,
val titleInk: Color,
val secondaryInk: Color,
val decoration: TextDecoration?,
/**
* Weight to set the title at, or null to keep whatever the surface's own
* text style carries.
*/
val titleWeight: FontWeight?,
)
/** [EventPaint] for [event] on a [dark] scheme. */
@Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) {
// Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the
// lightness pinned against the surface or a pale calendar goes
// unreadable (eventTone returns the raw colour verbatim otherwise).
val accent = eventAccent(event.color, dark, soften = true)
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
?: MaterialTheme.colorScheme.surfaceContainer
return EventPaint(
fill = ground,
outline = accent,
titleInk = accent,
// A neutral token rather than the accent faded: the time is the
// smallest text on the chip, and an alpha step off an accent that is
// itself only just clear of the surface is where legibility goes.
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
decoration = null,
// The label styles' medium weight is set to carry ink on a filled
// container. In a calendar colour on a plain one it thickens into
// something harder to read, so step it back to regular.
titleWeight = FontWeight.Normal,
)
}
val fill = eventFill(event.color, dark, soften)
return EventPaint(
fill = fill,
outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined),
titleWeight = null,
)
}
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
fun Modifier.eventSurface(
paint: EventPaint,
shape: Shape,
cuts: ChipCuts = ChipCuts(),
): Modifier {
val filled = background(paint.fill, shape)
val outline = paint.outline ?: return filled
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
}
/**
* The border, stroked as one round-rect whose edges run past the chip on every
* side the event continues over. Clipped to the chip's own shape, so those
* strokes — and the corners that would have turned back in — fall outside and
* the edge stays open.
*/
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
val stroke = EVENT_OUTLINE_WIDTH.toPx()
val radius = EVENT_CHIP_CORNER.toPx()
// Far enough out that the corner arc clears the clip too, not just the edge.
val bleed = radius + stroke
val rtl = layoutDirection == LayoutDirection.Rtl
val leftCut = if (rtl) cuts.end else cuts.start
val rightCut = if (rtl) cuts.start else cuts.end
val left = if (leftCut) -bleed else stroke / 2f
val top = if (cuts.top) -bleed else stroke / 2f
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(radius),
style = Stroke(width = stroke),
)
}
@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale
private val TIME_WEIGHT = FontWeight.Normal
private val TIME_TRACKING = 0.sp
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/**
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
* beneath it. Full, so the pair separates on a typeface with no medium weight to
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
* only, and a user's imported font a single face, so [asEventTime] is a no-op
* there and the ink is the whole difference (#219).
*/
const val TITLE_INK_ALPHA = 1f
/**
* [this] set as an event's time rather than as its title: the label styles' medium
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
*/
fun TextStyle.asEventTime(): TextStyle =
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/** Text reading [time], set apart in [timeInk], before [title]. */
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
buildAnnotatedString {
if (time != null) {
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
append(" ")
}
append(title)
}
/**
* The characters of title that have to survive a time prefix for it to be worth
* its place — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
private const val TITLE_SAMPLE = "notepad"
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45
/**
* The narrowest run of [style] text that may carry a time in front of a title:
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
*
* Measured rather than a device breakpoint, so it follows the font scale, the
* 12/24-hour setting, the locale's own time format and whatever else has
* already been taken off the width.
*/
@Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
return remember(sample, style, density, measurer) {
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips instead — mid-glyph on one line, at the last whole word it
* could fit on several.
*
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
* Compose lays the line out at its full intrinsic width and clips to the node's
* left edge, which in RTL is the *end* of the string — an Arabic title would
* lose its beginning. The ellipsis truncates at the logical end in both
* directions. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Whether the week/day timeline seats each hour in its own cell, from the
* `show_hour_lines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical flat column.
*/
val LocalShowHourGrid = staticCompositionLocalOf { false }
/** Gap between two hour cells, matching the month grid's gap between day cells. */
val HOUR_CELL_GAP = 2.dp
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
/**
* The half-gap a block seated in the grid gives up at one of its ends, so an
* on-the-hour event fills its cell instead of overhanging the seam into its
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
* that edge is squared off against the column's own, and stopping a half-gap
* short of it would leave the block floating there rather than running off.
*/
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
if (show && !cut) HOUR_CELL_INSET else 0.dp
/**
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
* times the size — on an hour cell it out-rounds its own content.
*/
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
/**
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
* week column is a seventh of the screen, so the radius is held to half the
* shorter side — the point past which the corners would meet and the cell turn
* into a lozenge.
*/
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
/**
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
* so the hour boundary reads as a seam between two surfaces rather than a line
* drawn across one. Applied to a day column's content, so the cells sit over the
* column background but beneath the event blocks — blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
val inset = HOUR_CELL_INSET.toPx()
val cellHeight = hourHeightPx - inset * 2f
if (cellHeight <= 0f) return@drawBehind
val radius = CornerRadius(
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset),
size = cellSize,
cornerRadius = radius,
)
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -72,6 +73,11 @@ fun HourGutter(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha",
)
// Each label straddles the boundary it names, so it lines up with the seam
// the hour grid leaves there instead of hanging below it. Derived from the
// label's own line height, so it holds at any font scale.
val labelStyle = MaterialTheme.typography.labelSmall
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
Box(
modifier = modifier
@@ -90,12 +96,12 @@ fun HourGutter(
if (h > 0) {
Text(
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
style = labelStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha),
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
.offset(y = -labelLift),
)
}
}
@@ -1,38 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}
@@ -12,7 +12,7 @@ import kotlin.time.Instant
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -24,8 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -34,7 +36,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@@ -83,6 +84,13 @@ data class TimelineDrag(
val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
@@ -192,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
0
}
/**
* Where one dragged piece's top edge sits below its column's top, and how tall
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
* in the hour grid gives up at each end, zero with the grid off — taken off the
* copy exactly as the grid takes it off the block, or the copy is a gap taller
* than the block it lifted off and spends the difference on a time label that
* block had no room for (#267). An end cut at midnight keeps none of it, as the
* block's own squared-off edge does.
*/
internal fun dragPieceBounds(
slice: DragSlice,
hourPx: Float,
insetPx: Float,
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
/** A piece's placement within its column — see [dragPieceBounds]. */
internal data class DragPieceBounds(val top: Float, val height: Float)
/** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice(
val dayOffset: Int,
@@ -235,6 +269,13 @@ class TimelineGeometry {
var stepPx: Float = 0f
var days: List<LocalDate> = emptyList()
/**
* The half-gap a block gives up at each uncut end when the hour grid seats
* it in a cell, zero with the grid off. The floating copy gives up the same,
* so it is the size of the block it lifted off (#267).
*/
var blockInsetPx: Float = 0f
/**
* Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it.
@@ -313,6 +354,9 @@ class TimelineDragController {
*/
private var clipOffsetMin = 0
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -322,11 +366,13 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -345,6 +391,7 @@ class TimelineDragController {
fun cancel() {
source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -467,23 +514,22 @@ class TimelineDragController {
startMin = startMin,
eventStartMin = eventStartMin,
eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + slice.startMin / 60f * hourPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
maxOf(
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
@@ -604,8 +650,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
@@ -655,7 +701,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover",
)
val fill = eventFill(drag.event.color, dark, soften)
val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half
// of an event that crosses midnight names the same hours. An end past
@@ -666,22 +712,61 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
fill = fill,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
// The timeline's own bounds. The copy is drawn over the whole calendar
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
// the range's piece by raw height put it there, off the top of the
// timeline; picking by *visible* height puts it where it can be read.
val shown = drag.pieces.map { piece ->
if (port == null) {
piece.sizePx.height.toFloat()
} else {
val top = maxOf(piece.topLeftInRoot.y, port.top)
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box(
modifier = if (port == null) {
Modifier
} else {
Modifier
.absoluteOffset {
IntOffset(
(port.left - origin.x).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
)
.clipToBounds()
},
) {
val pieceOrigin = port?.topLeft ?: origin
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = pieceOrigin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
}
@@ -692,14 +777,54 @@ private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
fill: Color,
paint: EventPaint,
shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float,
alpha: Float,
title: String,
titleLines: Int,
label: String?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
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
// on the block, so the copy hands back to the grid without shifting (#267).
// 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
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
// 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
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
} else {
1
}
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
@@ -710,12 +835,20 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
.size(
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
// Required: the parent is the viewport's size, and a plain `size`
// would let it cap a piece a whole day tall at one screen — drawn
// from a midnight scrolled off the top, that ended the column a
// scroll offset short of its own bottom (#267). The parent clips it.
.requiredSize(width = width, height = height)
.padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
@@ -723,24 +856,29 @@ private fun DragCopy(
this.shape = shape
clip = false
}
.background(fill, shape)
.padding(horizontal = 4.dp, vertical = 2.dp),
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
if (label != null) {
if (lines > 0) {
BlockTitle(
title = title,
maxLines = lines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
// As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = paint.secondaryInk,
)
}
}
@@ -96,17 +96,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp
@@ -1,26 +1,81 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.Icon
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
private val GlyphShape = RoundedCornerShape(7.dp)
private val GlyphBorder = 1.5.dp
/**
* Height of the day number. Expressed in dp and converted to sp at draw time so
* it does not follow the font scale: this is an icon glyph sharing the bar with
* stroke icons, and a two-digit day that grew with the text would push the bar
* past the width #165 measured it to. The button's content description carries
* the meaning for screen readers, so nothing is lost by holding it still.
*/
private val GlyphTextHeight = 12.dp
/**
* The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition.
*
* The glyph is the current day number in an outlined box rather than a generic
* calendar icon (#220), so the bar also says what day it is. Outlined rather
* than the month grid's filled circle, which reads as "this cell is today" and
* would sit heavier here than the stroke icons beside it.
*/
@Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return
IconButton(onClick = onToday) {
Icon(
imageVector = Icons.Default.Today,
contentDescription = stringResource(R.string.today_jump_action),
)
val now = rememberCurrentMinute()
// Only the day number is read, so the bar recomposes at midnight rather
// than on every minute tick.
val day by remember {
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
}
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
modifier = Modifier
.size(AppBarSpacing.IconSize)
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
fontSize = textSize,
lineHeight = textSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = LocalContentColor.current,
)
}
}
}
@@ -1,26 +1,39 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
modifier = modifier.padding(end = trailingInset),
) {
Text(stringResource(current.labelRes))
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -40,6 +39,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -53,10 +53,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -65,7 +63,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -73,6 +70,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -81,7 +81,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
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.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -94,9 +99,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -106,25 +114,21 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
@@ -170,21 +174,9 @@ fun DayScreen(
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
@@ -242,19 +234,19 @@ fun DayScreen(
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = modifier,
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -269,7 +261,6 @@ fun DayScreen(
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -287,7 +278,6 @@ fun DayScreen(
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -348,7 +338,6 @@ private fun DayContent(
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
@@ -374,7 +363,6 @@ private fun DayContent(
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
@@ -385,16 +373,22 @@ internal fun DaySuccess(
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
)
}
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
@@ -414,20 +408,25 @@ private fun DayTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
title = title,
currentDate = date,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -448,15 +447,17 @@ private fun DayTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
scrollBehavior = scrollBehavior,
)
}
@@ -516,23 +517,25 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val paint = eventPaint(event, dark)
Box(
modifier = modifier
.background(fill, EventChipShape)
.eventSurface(paint, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
}
}
@@ -550,6 +553,12 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -603,6 +612,7 @@ private fun Timeline(
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
@@ -629,8 +639,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
@@ -646,16 +656,23 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so an hour boundary
// reads as negative space between two surfaces.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
@@ -687,19 +704,34 @@ private fun DayColumnCard(
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = place.height,
height = blockHeight,
width = place.width,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.offset(x = place.x, y = place.y + topInset)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
)
}
}
@@ -716,7 +748,10 @@ private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -734,29 +769,48 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for. 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.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
// 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.
// 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.
val available = height - BLOCK_TEXT_INSET * 2
val showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
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
// 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
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// 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.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -768,12 +822,12 @@ private fun EventBlock(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -781,19 +835,19 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
@@ -807,7 +861,12 @@ private fun DayLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
val hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
Row(
modifier = Modifier
.fillMaxSize()
@@ -820,7 +879,11 @@ private fun DayLoading() {
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
.background(
if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer,
)
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
)
}
}
@@ -829,10 +892,16 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = "EEEdMMM",
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
@@ -88,9 +89,7 @@ class DayViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -41,7 +40,6 @@ internal fun DayViewPreview(
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
@@ -9,7 +9,6 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -150,6 +152,7 @@ fun EventDetailScreen(
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val copyField = rememberFieldCopier(snackbarHostState)
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser.
@@ -300,7 +303,7 @@ fun EventDetailScreen(
reason = s.reason,
onRetry = viewModel::retry,
)
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
}
}
@@ -382,7 +385,11 @@ private fun DeleteEventDialog(
@Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
private fun EventDetailContent(
state: EventDetailUiState.Success,
copyField: FieldCopier,
modifier: Modifier = Modifier,
) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
@@ -399,6 +406,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// event, so it's left implicit — only Free is worth surfacing. A
// cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) {
val titleLabel = stringResource(R.string.event_detail_title)
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
@@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
} else {
null
},
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.then(
// Nothing to copy off an untitled event — the placeholder
// isn't the event's own text.
if (instance.title.isNotBlank()) {
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
} else {
Modifier
},
),
)
if (detail.availability == Availability.Free) {
Spacer(Modifier.width(12.dp))
@@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconContentDescription = locationLabel,
) {
Text(
text = location,
@@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable { openInMaps(context, location) }
.copyOnLongPress(
label = locationLabel,
text = location,
copy = copyField,
onTap = { openInMaps(context, location) },
)
.padding(vertical = 2.dp),
)
}
@@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
// Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = stringResource(R.string.event_detail_description),
iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
) {
Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
@@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -90,6 +91,7 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
@@ -101,6 +103,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
@@ -788,38 +791,59 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
iconAtTop = true,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.Top) {
InlineField(
value = form.location,
onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location),
// A location is as often a meeting URL as an address,
// and "Zoom.us/j/123" reads wrong (#146).
capitalization = KeyboardCapitalization.None,
// Multi-line so a long address or meeting URL wraps and
// the card grows instead of scrolling off one line
// (#273). The field holds no newlines of its own, so
// the IME's action key closes the keyboard rather than
// inserting a break setLocation would only fold away.
singleLine = false,
imeAction = ImeAction.Done,
modifier = Modifier
.weight(1f)
.animateContentSizeMotion()
.padding(vertical = 4.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
// The box is exactly one text line tall, so the button
// centres on the first line without making a single-line
// card taller than its text; requiredSize keeps the
// button's own 40dp ripple.
Box(
modifier = Modifier.height(firstLineHeight()),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
IconButton(
onClick = {
runCatching {
pickContactAddress.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.requiredSize(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
}
}
@@ -1989,8 +2013,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
/**
* Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one line so
* it sits cleanly in the single-line location field.
* The provider's formatted address is multi-line; collapse it to one comma-
* separated line, which is what the location field holds.
*/
private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query(
@@ -2174,14 +2198,19 @@ private fun EditCard(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon (24dp) centres on the ~24sp first line.
modifier = Modifier
.padding(top = if (iconAtTop) 4.dp else 0.dp)
.padding(top = iconOffset)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
@@ -2223,7 +2252,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
/**
* Borderless text input used inside the cards (and as the headline title).
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
* the app share one input style.
* the app share one input style. Sentence-case by default, as floret-kit is;
* a field holding an identifier rather than prose passes
* [KeyboardCapitalization.None].
*/
@Composable
private fun InlineField(
@@ -2235,6 +2266,8 @@ private fun InlineField(
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
@@ -2249,10 +2282,25 @@ private fun InlineField(
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
capitalization = KeyboardCapitalization.None,
capitalization = capitalization,
imeAction = imeAction,
)
}
/**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
* instead of assuming the 24sp line of a 1.0 scale (#165).
*/
@Composable
private fun firstLineHeight(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
): Dp {
val lineHeight = textStyle.lineHeight
if (!lineHeight.isSp) return 32.dp
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
}
/** One schedule row: label, then tappable date and (unless all-day) time. */
@Composable
private fun ScheduleRow(
@@ -59,6 +59,9 @@ import javax.inject.Inject
private const val TAG = "EventEdit"
/** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the
@@ -532,7 +535,11 @@ class EventEditViewModel @Inject constructor(
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
fun setLocation(value: String) = update { it.copy(location = value) }
// The location wraps too (#273) but is likewise one logical line. A pasted
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) =
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that
@@ -125,6 +125,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.PermissionRevoked -> R.string.state_failure_permission
else -> R.string.state_failure_provider
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
@@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor(
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
val failure = calendarListFailure(calendars)
if (failure != null) {
FilterUiState.Failure(failure)
} else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.ui.licences
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
/**
* Third-party attribution.
*
* Reachable from Settings rather than buried, because Apache-2.0 §4(a) is about
* what the *recipient of the binary* is told. Each row opens the project's source.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
) {
Text(
stringResource(R.string.licences_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
OpenSourceLicences.ALL.forEachIndexed { index, attribution ->
GroupedRow(
title = attribution.name,
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
position = positionOf(index, OpenSourceLicences.ALL.size),
onClick = { uriHandler.openUri(attribution.sourceUrl) },
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.licences_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.calendula.ui.licences
/**
* The third-party code Calendula ships, and what each licence obliges us to say.
*
* This screen is a **licence obligation, not an about-page nicety**:
* **Apache-2.0 §4(a)** requires the licence to travel with the work and **§4(d)**
* propagates any `NOTICE` file. What discharges that is what the recipient of the
* APK is shown a notice only visible to someone reading the repository does not
* count. Nearly everything below is Apache-2.0.
*
* Hand-maintained on purpose. Generators read POM metadata, and POM metadata is
* routinely wrong the licence name is often not an SPDX id and the licence URL
* frequently 404s, which produces confidently incomplete output. A short honest
* list beats a long generated one that omits the entry that mattered.
*
* **When adding a dependency, add it here.** There is no automated check that can
* tell you that you forgot. Test-only dependencies (JUnit, Truth, Turbine) are not
* shipped in the APK and stay off the list.
*/
data class Attribution(
val name: String,
val copyright: String,
val licence: Licence,
/** Where the source can actually be obtained; what each row links to. */
val sourceUrl: String,
)
enum class Licence(val spdxId: String, val url: String) {
APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
MIT("MIT", "https://opensource.org/license/mit"),
}
object OpenSourceLicences {
val ALL: List<Attribution> = listOf(
Attribution(
name = "AndroidX, Jetpack Compose, Glance, WorkManager, DataStore, DocumentFile",
copyright = "© The Android Open Source Project",
licence = Licence.APACHE_2,
sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support",
),
Attribution(
name = "Kotlin, kotlinx.coroutines, kotlinx.datetime",
copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/JetBrains/kotlin",
),
Attribution(
name = "Dagger and Hilt",
copyright = "© Google LLC and The Dagger Authors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/dagger",
),
Attribution(
name = "Material Design icons",
copyright = "© Google LLC",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/material-design-icons",
),
Attribution(
name = "floret-kit",
copyright = "© Jean-Luc Makiola",
licence = Licence.MIT,
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
),
)
}
@@ -0,0 +1,19 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
// The month grid's chip geometry, together in one file: these derive from each
// other, and a top-level val reading one across a file boundary would resolve
// against whichever class the JVM happened to initialise first.
/** Gap between a day cell and its neighbours. */
internal val CELL_GAP = 2.dp
/** Padding between a month chip's edge and its text. */
internal val MONTH_CHIP_TEXT_PADDING = 4.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
/** 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
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
/**
* The start time a chip shows before its title (#219), or null when it has none
* to show: an all-day chip never carries a time, and neither does a bar carried
* in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
continuesLeft: Boolean,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
}
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@Composable
internal fun rememberMonthTimeChipWidth(): Dp =
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
@@ -97,6 +97,13 @@ data class MonthChipDrag(
val targetDate: LocalDate?,
val topLeftInRoot: Offset,
val sizePx: IntSize,
/**
* The start time of the chip this lifted off, already formatted (#219).
* Carried rather than re-derived: the overlay has no row to ask whether the
* bar was carried in from the previous week, and no zone the grid laid out
* in. Null where the source chip had no time to show.
*/
val time: String? = null,
)
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
@@ -172,6 +179,7 @@ class MonthDragController {
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var time: String? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
private var sizePx = IntSize.Zero
@@ -190,9 +198,11 @@ class MonthDragController {
pointerInRoot: Offset,
chipInRoot: Offset,
size: IntSize,
time: String? = null,
) {
this.event = event
this.grabDate = grabDate
this.time = time
settling = null
isDragging = true
liftedInstanceId = event.instanceId
@@ -210,6 +220,7 @@ class MonthDragController {
fun cancel() {
event = null
grabDate = null
time = null
isDragging = false
liftedInstanceId = null
drag = null
@@ -331,6 +342,7 @@ class MonthDragController {
targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab,
sizePx = sizePx,
time = time,
)
}
}
@@ -88,9 +88,13 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -113,7 +117,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@@ -123,6 +126,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -134,6 +138,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
@@ -142,14 +147,13 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -212,7 +216,6 @@ fun MonthScreen(
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -298,10 +301,14 @@ fun MonthScreen(
// carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title.
val locale = currentLocale()
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
titleMonth.year.toString()
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year)
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
if (viewStyle == MonthViewStyle.Continuous) {
val year = titleMonth.year.toString()
year to year
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -386,19 +393,20 @@ fun MonthScreen(
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = modifier,
topBar = {
MonthTopBar(
title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -494,6 +502,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme()
val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes
@@ -551,6 +560,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
dark = dark,
continuesLeft = false,
continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
@@ -563,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
}
.width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift
@@ -664,15 +678,16 @@ private fun ContinuousMonthContent(
@Composable
private fun MonthTopBar(
title: String,
shortTitle: String,
titleDate: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
@@ -680,6 +695,7 @@ private fun MonthTopBar(
title = title,
currentDate = titleDate,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -700,11 +716,17 @@ private fun MonthTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
scrollBehavior = scrollBehavior,
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
@@ -719,7 +741,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
@@ -745,7 +767,6 @@ private val DAY_NUMBER_HEIGHT = 22.dp
private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
/** Named separately because the split style's selection outline draws its own
* rounded rect and has to match this radius exactly. */
private val CELL_CORNER = 12.dp
@@ -787,17 +808,22 @@ internal fun MonthGrid(
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's 8dp 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
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
val month = state.month
// Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth()
state.weeks.forEach { week ->
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
@@ -837,6 +863,7 @@ internal fun ContinuousMonthGrid(
) {
val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
@@ -858,6 +885,8 @@ internal fun ContinuousMonthGrid(
weeks = state.monthsByIndex[index],
weekStart = state.weekStart,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
@@ -878,6 +907,8 @@ private fun ContinuousMonthBlock(
weeks: List<MonthWeek>?,
weekStart: DayOfWeek,
today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
@@ -886,7 +917,7 @@ private fun ContinuousMonthBlock(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(horizontal = AppBarSpacing.Inset)
.padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
@@ -897,6 +928,8 @@ private fun ContinuousMonthBlock(
MonthWeekRow(
week = week,
today = today,
zone = zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed.
@@ -966,6 +999,7 @@ internal fun DenseMonthGrid(
modifier: Modifier = Modifier,
) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn(
state = listState,
modifier = modifier
@@ -974,7 +1008,7 @@ internal fun DenseMonthGrid(
// padding: a lazy list scrolls its rows *through* the before-content
// padding, so scrolling to a week landed its row that far down with
// the tail of the previous one showing above it (#191).
.padding(horizontal = 8.dp)
.padding(horizontal = AppBarSpacing.Inset)
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
@@ -988,6 +1022,8 @@ internal fun DenseMonthGrid(
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
// Every day in the stream belongs to a month equally — there
// is no "other month" to recede here.
inMonth = { true },
@@ -1441,7 +1477,7 @@ internal fun SplitMonthGrid(
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
state.weeks.forEach { week ->
@@ -1806,7 +1842,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
.padding(horizontal = AppBarSpacing.Inset)
.clipToBounds(),
) {
if (!dense) {
@@ -1873,6 +1909,10 @@ private fun rememberSkeletonPulse(): Float {
private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp,
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
@@ -1892,6 +1932,27 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight()
// 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
// ticks every minute while a drag recomposes every frame (#219).
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val chipTimes = remember(week, zone, is24Hour, locale) {
buildMap {
week.spans.forEach { span ->
put(
span.event.instanceId,
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
)
}
week.timedByDay.values.flatten().forEach { event ->
put(
event.instanceId,
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
)
}
}
}
// Drag to reschedule (#68). The chips can take no pointer input of their own
// — the full-bleed tap layer sits on top of them — so one detector on the
@@ -1966,9 +2027,12 @@ private fun MonthWeekRow(
band = bandCoordinates,
rowHeightPx = rowHeightPx,
isRtl = isRtl,
chipTimes = chipTimes,
),
),
) {
// What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219).
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
@@ -2049,6 +2113,8 @@ private fun MonthWeekRow(
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1),
time = chipTimes[span.event.instanceId],
showTime = colW * cols >= timeChipWidth,
modifier = Modifier
.offset(
x = colW * span.startCol,
@@ -2072,7 +2138,7 @@ private fun MonthWeekRow(
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
// One invisible slice of the bar per further column it
// covers. A multi-day event has a dot on every day but
@@ -2096,7 +2162,7 @@ private fun MonthWeekRow(
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
}
}
@@ -2119,6 +2185,8 @@ private fun MonthWeekRow(
continuesLeft = false,
continuesRight = false,
days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth,
modifier = Modifier
.offset(
x = colW * col,
@@ -2127,7 +2195,7 @@ private fun MonthWeekRow(
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
@@ -2279,6 +2347,9 @@ private fun monthChipDragModifier(
band: Array<LayoutCoordinates?>,
rowHeightPx: Float,
isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */
chipTimes: Map<Long, String?>,
): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(),
@@ -2307,6 +2378,7 @@ private fun monthChipDragModifier(
y = requireNotNull(bandTop) + lane * rowHeightPx,
),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
time = chipTimes[event.instanceId],
)
true
}
@@ -2414,7 +2486,10 @@ private fun shortMonthName(date: LocalDate): String {
}
}
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
/**
* A filled event pill/bar softened (or raw) fill, title clipped to one line,
* with the start time before it where the chip is wide enough ([showTime], #219).
*/
@Composable
private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -2428,12 +2503,26 @@ private fun MonthBar(
* the provider hands the re-read instance a new one.
*/
days: List<LocalDate>? = null,
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
time: String? = null,
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
showTime: Boolean = false,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val paint = eventPaint(event, dark)
// The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half.
val label = inlineTimeLabel(
time = time.takeIf { showTime },
title = title,
timeInk = paint.secondaryInk,
)
// Announced whether or not it is drawn, and comma-separated as the week and
// day blocks do it: what a screen reader hears shouldn't turn on how wide
// the chip happens to be (#219).
val description = if (time != null) "$title, $time" else title
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current
@@ -2444,21 +2533,24 @@ private fun MonthBar(
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape)
.padding(horizontal = 4.dp)
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
.semantics {
contentDescription = title
contentDescription = description
if (moveAction != null) customActions = listOf(moveAction)
},
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
text = label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
}
}
@@ -2514,7 +2606,7 @@ private fun MonthGridLoading() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(6) {
@@ -2540,10 +2632,16 @@ private fun MonthGridLoading() {
}
}
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
private fun formatMonthTitle(
ym: YearMonth,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
skeleton = if (abbreviated) "LLL" else "LLLL",
)
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
* [col] for a single-day chip, and for an empty slot no one should be asking
* about.
*/
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/**
* The events on [day] that [laneEvents] had no lane left for its exact
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
/** As [MonthUiState.Success.zone], and for the same reason. */
val zone: TimeZone = TimeZone.currentSystemDefault(),
) : ContinuousMonthUiState
}
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -207,9 +209,7 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): ContinuousMonthUiState {
if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
// Bucketed once for the whole window: a row then takes the handful of
// events on its seven days instead of re-scanning a year of them.
val byDay = DayIndex(instances, zone)
@@ -238,6 +238,7 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
zone = zone,
)
}
@@ -312,9 +313,7 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success(
month = ym,
@@ -438,14 +437,18 @@ internal fun layoutCalendarWeek(
days = days,
spans = spans,
timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
// Declined last, so a day that overflows drops the events you said
// no to before the ones you are actually going to (#230).
singles.filter { it.coversDay(d, zone) }
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
},
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
)
}
/**
* Every event touching each of [days], all-day first then by start time. Unlike
* Every event touching each of [days], declined ones last (#230), then all-day
* first and by start time within each group. Unlike
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
* they cover and applies no display cap, so the split style's day pane can list a
* date in full without querying the provider again.
@@ -458,7 +461,11 @@ internal fun instancesByDay(
days.associateWith { day ->
instances
.filter { it.coversDay(day, zone) }
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
.sortedWith(
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
}
/**
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
@@ -54,6 +55,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -67,7 +69,9 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
private enum class SettingsSection {
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
}
/**
* Settings hub: three labelled groups of category rows, each opening a
@@ -146,6 +150,13 @@ fun SettingsScreen(
) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
LicencesScreen(onBack = { section = null })
}
}
}
@@ -253,6 +264,13 @@ private fun SettingsHub(
icon = Icons.Default.Gavel,
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.settings_licences),
summary = stringResource(R.string.settings_licences_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Licences) },
)
AboutLinkRow(
label = stringResource(R.string.settings_about_privacy),
url = stringResource(R.string.about_privacy_url),
@@ -32,8 +32,8 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
val showHourGrid: Boolean = false,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
@@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor(
// Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit.
combine(
prefs.showHourLines,
prefs.showHourGrid,
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
@@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor(
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = toggles.showHourLines,
showHourGrid = toggles.showHourGrid,
showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
@@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor(
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showHourGrid = views.showHourGrid,
showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
@@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor(
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
@@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor(
)
private data class DisplayToggles(
val showHourLines: Boolean,
val showHourGrid: Boolean,
val showWeekNumbers: Boolean,
val agendaShowToday: Boolean,
val softenColors: Boolean,
@@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourLines(enabled) }
fun setShowHourGrid(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) {
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
}
}
}
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders a view
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
* off in the cycle is still reachable from the drawer, including when the cycle
* is emptied and the pill disappears (#150).
*/
@Composable
internal fun ViewsScreen(
@@ -171,11 +170,11 @@ internal fun ViewsScreen(
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
checked = state.showHourGrid,
onCheckedChange = viewModel::setShowHourGrid,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
)
Spacer(Modifier.height(8.dp))
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
@@ -72,7 +69,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -81,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -90,8 +89,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
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.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -103,31 +107,30 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -135,7 +138,8 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
@@ -190,21 +194,9 @@ fun WeekScreen(
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day.
@@ -265,19 +257,19 @@ fun WeekScreen(
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = modifier,
topBar = {
WeekTopBar(
weekStart = weekStart,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
@@ -298,7 +290,6 @@ fun WeekScreen(
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -318,7 +309,6 @@ fun WeekScreen(
private fun WeekContent(
state: WeekUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -383,7 +373,6 @@ private fun WeekContent(
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
@@ -410,7 +399,6 @@ private fun WeekContent(
@Composable
internal fun WeekSuccess(
state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
@@ -423,13 +411,19 @@ internal fun WeekSuccess(
Column(
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
.background(MaterialTheme.colorScheme.surface),
) {
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
}
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
// Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
@@ -449,20 +443,25 @@ private fun WeekTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = formatWeekTitle(weekStart, locale, currentYear),
title = title,
currentDate = weekStart,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -483,17 +482,17 @@ private fun WeekTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Match the static top section exactly: plain surface, lifting to
// surfaceContainer once content scrolls under the bar.
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
scrollBehavior = scrollBehavior,
)
}
@@ -653,23 +652,25 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
val paint = eventPaint(event, dark)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(fill, EventChipShape)
.eventSurface(paint, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
}
}
@@ -687,6 +688,12 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
@@ -733,6 +740,7 @@ private fun Timeline(
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days
@@ -777,8 +785,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
val showHourGrid = LocalShowHourGrid.current
val zone = remember { TimeZone.currentSystemDefault() }
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks)
@@ -794,16 +802,24 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
// With the grid on, the container colour moves onto the hour cells
// and the column behind them recedes to surface, so the gap between
// two cells reads as the same negative space that separates the
// week's columns.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// The hour cells sit over the column background but under the
// event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
// Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
@@ -834,20 +850,34 @@ private fun DayColumnCard(
width = laneWidth,
height = height,
)
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock(
block = block,
dark = dark,
height = place.height,
height = blockHeight,
width = place.width,
date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.offset(x = place.x, y = place.y + topInset)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
.height(blockHeight)
.padding(horizontal = BLOCK_OUTER_INSET),
)
}
}
@@ -866,6 +896,8 @@ private fun EventBlock(
height: Dp,
width: Dp,
date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
@@ -883,48 +915,66 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for.
val available = height - 4.dp
val available = height - BLOCK_TEXT_INSET * 2
// 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
// fill the whole block, mirroring Google Calendar — and a block that cannot
// afford both lines spends its space on the title.
val showTime = block.endMin - block.startMin >= 45 &&
block.laneCount == 1 &&
// afford both lines spends its space on the title. Height decides that on
// 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.
val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// 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
// 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.
val showTitle = available >= titleLineHeight
// Wrap the title across as many lines as the block can fit — but only once a
// line is wide enough to hold more than a syllable. Below that the extra
// lines just stack fragments of the word, and one ellipsised line reads
// better.
// 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,
// 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.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1
} else {
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
}
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
// 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
// line there lost it outright where the block had the room to wrap it.
val titleMaxLines = blockTextLines(
text = title,
// At the weight BlockTitle will set it in, or an invited block — drawn a
// weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = titleBudget,
)
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val topInsetPx = with(density) { topInset.toPx() }
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -936,12 +986,12 @@ private fun EventBlock(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -949,19 +999,19 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
BlockTitle(
title = title,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
@@ -992,7 +1042,14 @@ private fun WeekLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
val hourHeight = scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
// of the real week doesn't flash solid blocks into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val columnGround = if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer
Row(
modifier = Modifier
.fillMaxSize()
@@ -1006,7 +1063,12 @@ private fun WeekLoading() {
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
.background(columnGround)
.hourGridCells(
showHourGrid,
hourPx,
MaterialTheme.colorScheme.surfaceContainer,
),
)
}
}
@@ -1033,13 +1095,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
private fun formatWeekTitle(
weekStart: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
skeleton = if (abbreviated) "LLL" else "LLLL",
forceYear = weekEnd.year != weekStart.year,
)
}
@@ -10,6 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -125,9 +127,7 @@ class WeekViewModel @Inject constructor(
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): WeekUiState {
if (calendars.isEmpty()) {
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
}
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
@@ -144,7 +144,7 @@ class WeekViewModel @Inject constructor(
/**
* Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column;
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
* overlapping spans are stacked on separate lanes (greedy first-fit).
*/
internal fun layoutAllDay(
events: List<EventInstance>,
@@ -158,21 +158,35 @@ internal fun layoutAllDay(
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
// Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
val laneEnd = ArrayList<Int>() // last occupied column per lane
// What each lane already holds, rather than just how far right it reaches.
// A single "last occupied column" only answers correctly while spans arrive
// 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
// 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
// and a handful of bars, so the scan is cheaper than the sort above it.
val laneCols = ArrayList<MutableList<IntRange>>()
return raw.map { r ->
var lane = laneEnd.indexOfFirst { it < r.startCol }
val cols = r.startCol..r.endCol
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
if (lane == -1) {
laneEnd.add(r.endCol)
lane = laneEnd.size - 1
laneCols.add(mutableListOf(cols))
lane = laneCols.size - 1
} else {
laneEnd[lane] = r.endCol
laneCols[lane].add(cols)
}
AllDaySpan(r.event, r.startCol, r.endCol, lane)
}
}
/** Whether two column ranges share a column, so they cannot share a lane. */
private infix fun IntRange.overlaps(other: IntRange): Boolean =
first <= other.last && other.first <= last
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -47,7 +46,6 @@ internal fun WeekViewPreview(
ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess(
state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
+52 -22
View File
@@ -43,16 +43,16 @@
<string name="event_detail_edit">تعديل</string>
<string name="event_detail_delete">حذف</string>
<string name="event_detail_share">مشاركة</string>
<string name="event_detail_duplicate">نسخ</string>
<string name="event_detail_duplicate">تكرار</string>
<string name="event_share_chooser_title">مشاركة الحدث</string>
<string name="day_today_action">اليوم</string>
<string name="week_today_action">هذا الأسبوع</string>
<string name="week_number_label">أسبوع</string>
<string name="event_detail_back">العودة</string>
<string name="event_detail_back">الرجوع</string>
<string name="month_a11y_today_prefix">اليوم</string>
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
<string name="event_delete_title">حذف الحدث؟</string>
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
<string name="event_delete_body">تتم إزالة الحدث من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
<string name="event_delete_recurring_title">حذف الحدث المتكرّر</string>
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
@@ -172,7 +172,7 @@
<string name="event_attendee_optional">اختياري</string>
<string name="reminder_default">التذكير الافتراضي</string>
<string name="event_detail_self_response">ردَّك: %1$s</string>
<string name="event_attendee_resource">المُنسِّق</string>
<string name="event_attendee_resource">المُورِّد</string>
<string name="reminder_at_time">في وقت الحدث</string>
<string name="event_untitled">(بدون عنوان)</string>
<string name="reminder_channel_name">تذكيرات الحدث</string>
@@ -319,8 +319,8 @@
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
<string name="settings_hour_lines">خطوط الساعات</string>
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
<string name="settings_hour_lines">شبكة الساعة</string>
<string name="settings_hour_lines_summary">وضع كل ساعة في خانتها الخاصة في طريقة عرض الأسبوع واليوم</string>
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
<string name="timeline_scale_custom">مخصص</string>
<string name="settings_widget_size_small">صغير</string>
@@ -341,12 +341,12 @@
<string name="month_style_continuous">تمرير الشهور</string>
<string name="month_style_dense">أسابيع سلسة</string>
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
<string name="month_style_dense_summary">تتوالى الأسابيع دون انقطاع، وينساب كل شهر مباشرة إلى التالي دون وجود فجوة بينهم.</string>
<string name="month_split_no_events">لا شيء مجدول</string>
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
<string name="settings_event_duration">المدة الافتراضية</string>
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
<string name="settings_event_duration_hint">كم مدة استمرار حدث جديد إلى أي تقوم بتغيير وقت انتهائه. لا تتأثر أحداث طوال اليوم.</string>
<string name="settings_section_notifications">الإشعارات</string>
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
<string name="settings_group_look">المظهر و السلوك</string>
@@ -361,7 +361,6 @@
<string name="settings_about_support">دَعم التطوير</string>
<string name="settings_about_version">الإصدار %1$s</string>
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
<string name="settings_section_about">حول</string>
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
@@ -375,7 +374,7 @@
<string name="shortcut_new_event_short">حدث جديد</string>
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
<string name="calendars_local_header">تقويماتك</string>
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
<string name="settings_section_special_dates">التواريخ الخاصة لجهات الاتصال</string>
<string name="calendars_title">التقويمات</string>
<string name="calendars_add">إضافة تقويم</string>
<string name="settings_section_calendars">التقويمات</string>
@@ -492,7 +491,7 @@
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
<string name="import_warning_recurrence">تم تخطي بعض التكرارات المعدلة للأحداث المتكررة.</string>
<string name="import_close">إغلاق</string>
<string name="import_done_skipped_label">التكرارات</string>
<string name="import_done_added_label">أُضيف</string>
@@ -502,9 +501,7 @@
<string name="crash_dialog_dismiss">ليس الآن</string>
<string name="crash_report_copied">تم نسخ التقرير إلى حافظتك</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في حافظتك.</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من حافظتك هنا.)_</string>
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
@@ -556,7 +553,7 @@
<string name="event_move_write_denied">Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث</string>
<string name="event_move_gone">لم يعد هذا الحدث موجودًا</string>
<string name="onboarding_step_counter">الخطوة %1$d من %2$d</string>
<string name="onboarding_backup_title">أحداثك تعيش فقط هنا</string>
<string name="onboarding_backup_title">أحداثك تعيش هنا فقط</string>
<string name="onboarding_backup_body">لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك.</string>
<string name="onboarding_backup_benefit_folder_body">النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD.</string>
<string name="onboarding_backup_benefit_daily_title">مرة واحدة يوميًا، من تلقاء نفسه</string>
@@ -663,7 +660,7 @@
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
<string name="crash_dialog_report">تقرير</string>
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل.</string>
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. مع تشغيل أقل من عرضين اثنين يكون الزر مخفيًا. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
@@ -683,8 +680,8 @@
<string name="settings_font_specimen_body">مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز.</string>
<string name="settings_section_widgets">الأدوات المصغّرة والعناصر</string>
<string name="settings_widget_size_hint">مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه.</string>
<string name="settings_agenda_widget_range_hint">كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث.</string>
<string name="settings_agenda_range_hint">كم مدى يعرض شاشة الجدول الأحداث.</string>
<string name="settings_agenda_widget_range_hint">كم مدى يُدرج جدول أداة الشاشة الرئيسية الأحداث.</string>
<string name="settings_agenda_range_hint">كم مدى يُدرج شاشة الجدول الأحداث.</string>
<string name="app_name">Calendula</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="settings_special_dates_show_year">إظهار السنة</string>
@@ -696,13 +693,46 @@
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="onboarding_backup_benefit_folder_title">مجلد تختاره</string>
<string name="import_warning_timezone">منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك.</string>
<string name="settings_calendar_reminders_managed_hint">تعيين في تواريخ جهات الاتصال الخاصة</string>
<string name="settings_special_dates_disable_all_message">يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
<string name="import_warning_timezone">تمت إعادة منطقة زمنية غير معروفة إلى الخاصّة بجهازك.</string>
<string name="settings_calendar_reminders_managed_hint">تعيين في التواريخ الخاصة لجهات الاتصال</string>
<string name="settings_special_dates_disable_all_message">يؤدي هذا لحذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
<string name="settings_special_dates_type_anniversary">الذكرات السنوية</string>
<string name="settings_special_dates_template_hint">استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة).</string>
<string name="settings_special_dates_template">صيغة العنوان</string>
<string name="settings_calendar_reminders_hint">تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
<string name="agenda_empty_title">أنت على اطلاع</string>
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير أيٍّ الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
<string name="agenda_empty_title">أنت بالكل على آخر اطلاع</string>
<string name="reminder_after">%1$s بعد</string>
<string name="import_done_failed_label">لم تتم الإضافة</string>
<string name="import_done_failed_note">تعذّر إضافة بعض الأحداث وتم استبعادها.</string>
<string name="import_done_stopped_title">تم إيقاف الاستيراد</string>
<string name="import_warning_tasks">تمت إضافة المهام في هذا الملف كأحداث.</string>
<string name="import_warning_recurrence_repaired">تم إصلاح قاعدة تكرار معطّلة.</string>
<plurals name="import_done_stopped">
<item quantity="zero">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة (%d) لا حدث من الملف مطلقًا.</item>
<item quantity="one">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة %d حدث من الملف مطلقًا.</item>
<item quantity="two">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة %d حدثان من الملف مطلقًا.</item>
<item quantity="few">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة %d أحداث من الملف مطلقًا.</item>
<item quantity="many">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة %d حدث من الملف مطلقًا.</item>
<item quantity="other">التقويم توقف عن قبول الأحداث في منتصف العملية. لم تتم إضافة %d حدث من الملف مطلقًا.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="zero">تعذّر إضافة (%d) لا أحداث.</item>
<item quantity="one">تعذّر إضافة %d حدث.</item>
<item quantity="two">تعذّر إضافة %d حدثان.</item>
<item quantity="few">تعذّر إضافة %d أحداث.</item>
<item quantity="many">تعذّر إضافة %d حدث.</item>
<item quantity="other">تعذّر إضافة %d حدث.</item>
</plurals>
<string name="state_failure_all_hidden">تم تعطيل جميع تقويماتك.</string>
<string name="state_failure_all_hidden_action">إدارة التقويمات</string>
<string name="field_copy_action">نسخ</string>
<string name="field_copied">تم النسخ إلى الحافظة</string>
<string name="field_copy_failed">تعذّر نسخ ذلك</string>
<string name="event_detail_title">العنوان</string>
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
<string name="settings_licences_subtitle">المشاريع التي بنيت ‌‌Calendula عليها</string>
<string name="licences_title">تراخيص مفتوحة المصدر</string>
<string name="licences_intro">يتضمن Calendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
<string name="licences_footer">يتم تضمين كل مشروع هنا دون تعديل، بموجب الترخيص الموضح.</string>
</resources>
+80
View File
@@ -584,4 +584,84 @@
<string name="calendars_auto_backup_hint">Пэрыядычны экспарт лякальных календароў у каталёг у выглядзе файла .ics.</string>
<string name="calendars_auto_backup_folder">Каталёг рэзэрвовага капіяваньня</string>
<string name="calendars_auto_backup_folder_unset">Націсьніце, каб выбраць каталёг</string>
<string name="calendars_auto_backup_interval">Інтэрвал</string>
<string name="calendars_auto_backup_every">Кожн. %1$s</string>
<string name="calendars_auto_backup_interval_min">Мінімум 30 хв.</string>
<string name="calendars_auto_backup_status_never">Аўтаматычнае рэзэрвовае капіяваньне ня ўключана</string>
<string name="calendars_auto_backup_status_ok">Апошняе рэзэрвовае капіяваньне: %1$s</string>
<string name="calendars_auto_backup_status_failed">Апошняе няўдалае рэзэрвовае капіяваньне: %1$s</string>
<string name="backup_channel_name">Рэзэрвовае капіяваньне</string>
<string name="backup_channel_description">Папярэджвае аб шматразовых няўдачах аўтаматычнага рэзэрвовага капіяваньня.</string>
<string name="backup_failed_title">Памылка аўтаматычнага рэзэрвовага капіяваньня</string>
<string name="backup_failed_text">Calendula не ўдалося зрабіць файл рэзэрвовай копіі. Праверце каталёг рэзэрвовага капіяваньня ў наладах.</string>
<string name="calendars_backup_failed">Не атрымалася экспартаваць рэзэрвовую копію.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Экспартавана %d падзея.</item>
<item quantity="few">Экспартавана падзеяў: %d.</item>
<item quantity="many">Экспартавана падзеяў: %d.</item>
<item quantity="other">Экспартавана падзеяў: %d.</item>
</plurals>
<string name="import_title">Імпартаваць падзеі</string>
<string name="import_target_header">Дадаць у каляндар</string>
<string name="import_empty">У гэтым файле падзеі ня знойдзены.</string>
<string name="import_failed">Не атрымалася прачытаць гэты файл.</string>
<string name="import_no_calendar">Няма календара з дазволам на запіс, у які можна імпартаваць зьвесткі. Спачатку стварэце лякальны каляндар.</string>
<string name="import_done_title">Імпарт завершаны</string>
<string name="import_done_dedup_note">Падзеі, якія ўжо ёсьць у календары, былі прамінутыя.</string>
<string name="import_done_added_label">Дадана</string>
<string name="import_done_skipped_label">Дублікаты</string>
<string name="import_close">Закрыць</string>
<string name="import_warning_recurrence">Былі прамінутыя некаторыя зьмененыя паўторныя падзеі.</string>
<string name="import_warning_no_start">Падзея бяз часу пачатку была прамінутая.</string>
<string name="import_warning_attendees">Сьпісы наведвальнікаў не былі імпартаваныя.</string>
<string name="import_warning_timezone">Замест невядомага часавага поясу ўжыты сыстэмны для вашай прылады.</string>
<string name="import_button">Імпарт</string>
<plurals name="import_title_count">
<item quantity="one">Імпартуецца %d падзея</item>
<item quantity="few">Імпартуецца падзеяў: %d</item>
<item quantity="many">Імпартуецца падзеяў: %d</item>
<item quantity="other">Імпартуецца падзеяў: %d</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d падзея ў гэтым файле.</item>
<item quantity="few">Падзеяў у гэтым файле: %d.</item>
<item quantity="many">Падзеяў у гэтым файле: %d.</item>
<item quantity="other">Падзеяў у гэтым файле: %d.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Імпарт %d падзеі</item>
<item quantity="few">Імпарт падзеяў: %d</item>
<item quantity="many">Імпарт падзеяў: %d</item>
<item quantity="other">Імпарт падзеяў: %d</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Імпартавана %d падзея.</item>
<item quantity="few">Імпартавана падзеяў: %d.</item>
<item quantity="many">Імпартавана падзеяў: %d.</item>
<item quantity="other">Імпартавана падзеяў: %d.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Прамінута %d падзея (ужо ёсьць у гэтым календары).</item>
<item quantity="few">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
<item quantity="many">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
<item quantity="other">Прамінута падзеяў: %d (ужо ёсьць у гэтым календары).</item>
</plurals>
<string name="shortcut_new_event_short">Новая падзея</string>
<string name="shortcut_new_event_long">Стварыць новую падзею</string>
<string name="qs_tile_new_event_label">Новая падзея</string>
<string name="settings_qs_tile">Дадаць плітку хуткіх наладаў</string>
<string name="settings_qs_tile_hint">Дадаць плітку «Новая падзея» на панэль хуткіх наладаў.</string>
<string name="crash_dialog_title">Крах %1$s</string>
<string name="crash_dialog_message">Апошнім разам %1$s нечакана закрылася. Вы можаце дапамагчы выправіць гэта, адправіўшы справаздачу аб праблеме. Яна застанецца на вашай прыладзе, пакуль вы не вырашыце падзяліцца ёю, і ня ўтрымлівае асабістых даных або зьмесціва календара — толькі тэхнічныя падрабязнасьці, паданыя ніжэй.</string>
<string name="crash_dialog_report">Справаздача</string>
<string name="crash_dialog_dismiss">Ня зараз</string>
<string name="crash_report_clip_label">Справаздача аб краху %1$s</string>
<string name="crash_report_copied">Справаздача скапіявана ў буфэр абмену</string>
<string name="crash_report_open_failed">Не атрымалася адкрыць сыстэму адсочваньня праблемаў. Справаздача знаходзіцца ў буфэры абмену.</string>
<string name="special_dates_calendar_birthday">Дні нараджэньня</string>
<string name="special_dates_calendar_anniversary">Юбілейныя даты</string>
<string name="special_dates_calendar_custom">Асаблівыя даты</string>
<string name="special_dates_default_title_birthday">Дзень нараджэньня {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Юбілейная дата {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>
+174 -21
View File
@@ -45,7 +45,7 @@
<string name="event_detail_edit">Upravit</string>
<string name="event_detail_delete">Odstranit</string>
<string name="event_detail_share">Sdílet</string>
<string name="event_detail_duplicate">Kopírovat</string>
<string name="event_detail_duplicate">Duplikovat</string>
<string name="event_share_chooser_title">Sdílet událost</string>
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
<string name="event_delete_title">Odstranit událost?</string>
@@ -73,7 +73,7 @@
<string name="event_edit_close">Zavřít</string>
<string name="event_edit_save">Uložit</string>
<string name="event_edit_title_hint">Přidat název</string>
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Upomínky, polohu a poznámky si můžete upravit sami.</string>
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Připomínky, polohu a poznámky si můžete upravit sami.</string>
<string name="event_edit_starts">Začátek</string>
<string name="event_edit_ends">Konec</string>
<string name="event_edit_error_end_before_start">Končí dříve, než začíná</string>
@@ -82,8 +82,8 @@
<string name="event_edit_write_denied">Calendula pro vytváření událostí vyžaduje oprávnění k zápisu</string>
<string name="event_edit_more_fields">další pole</string>
<string name="event_edit_add">Přidat</string>
<string name="event_edit_add_reminder">Přidat upomínku</string>
<string name="event_edit_remove_reminder">Vymazat upomínku</string>
<string name="event_edit_add_reminder">Přidat připomínku</string>
<string name="event_edit_remove_reminder">Vymazat připomínku</string>
<string name="event_edit_attendees">Hosté</string>
<string name="event_edit_add_guest">Přidat hosta</string>
<string name="event_edit_add_guest_hint">Přidat hosta e-mailem…</string>
@@ -123,13 +123,13 @@
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
<string name="event_edit_gone_title">Událost byla odstraněna</string>
<string name="event_edit_gone_body">Během úpravy došlo k odstranění události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
<string name="import_reminder_prompt_title">Chcete použít výchozí připomínku?</string>
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené připomínky.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Tato událost byla importována s %1$d upomínkou.</item>
<item quantity="few">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="many">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="other">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="one">Tato událost byla importována s %1$d připomínkou.</item>
<item quantity="few">Tato událost byla importována s %1$d připomínkami.</item>
<item quantity="many">Tato událost byla importována s %1$d připomínkami.</item>
<item quantity="other">Tato událost byla importována s %1$d připomínkami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Nastavit výchozí</string>
<string name="import_reminder_prompt_keep">Nechat beze změny</string>
@@ -155,7 +155,7 @@
<string name="event_detail_all_day">Celý den</string>
<string name="event_detail_calendar">Kalendář</string>
<string name="event_detail_calendar_unknown">Neznámý kalendář</string>
<string name="event_detail_location">Umístění</string>
<string name="event_detail_location">Místo</string>
<string name="event_detail_description">Popis</string>
<string name="event_detail_attendees">Účastníci</string>
<string name="event_detail_recurrence">Opakování</string>
@@ -177,7 +177,7 @@
<string name="event_attendee_tentative">Předběžné</string>
<string name="event_attendee_needs_action">Bez odezvy</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Upomínky</string>
<string name="event_detail_reminders">Připomínky</string>
<string name="event_detail_timezone">Časové pásmo</string>
<string name="event_status_tentative">Přeběžné</string>
<string name="event_status_cancelled">Zrušeno</string>
@@ -193,7 +193,7 @@
<string name="event_attendee_resource">Zdroj</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_default">Výchozí upomínka</string>
<string name="reminder_default">Výchozí připomínka</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minutu předtím</item>
<item quantity="few">%d minuty předtím</item>
@@ -261,17 +261,17 @@
<string name="onboarding_done_title">Vše je připraveno</string>
<string name="onboarding_done_body">Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení.</string>
<string name="onboarding_done_button">Otevřít můj kalendář</string>
<string name="reminder_channel_name">Upomínky událostí</string>
<string name="reminder_channel_description">Oznámení v časech upomínek vašich událostí</string>
<string name="reminder_channel_name">Připomínky událostí</string>
<string name="reminder_channel_description">Oznámení v časech připomínek vašich událostí</string>
<string name="reminder_onboarding_title">Ať vám neunikne žádná událost</string>
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje upomínky událostí to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
<string name="reminder_benefit_delivery_title">Upomínky, doručené</string>
<string name="reminder_benefit_delivery_body">Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje připomínky událostí to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
<string name="reminder_benefit_delivery_title">Připomínky, doručené</string>
<string name="reminder_benefit_delivery_body">Připomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
<string name="reminder_benefit_duplicates_title">Používáte ještě nějakou jinou aplikaci s kalendářem?</string>
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá připomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
<string name="reminder_benefit_reversible_title">Lze kdykoliv změnit</string>
<string name="reminder_benefit_reversible_body">Tento přepínač naleznete v Nastavení, v části Oznámení.</string>
<string name="reminder_onboarding_enable_button">Zapnout upomínky</string>
<string name="reminder_onboarding_enable_button">Zapnout připomínky</string>
<string name="reminder_onboarding_skip_button">Později</string>
<string name="reminder_action_snooze">Odložit</string>
<string name="reminder_action_dismiss">Zrušit</string>
@@ -280,7 +280,7 @@
<string name="view_month">Měsíc</string>
<string name="view_week">Týden</string>
<string name="view_day">Den</string>
<string name="view_agenda">Program</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Zobrazení</string>
<string name="drawer_jump_to_date">Přejít na datum</string>
<string name="agenda_today_action">Dnes</string>
@@ -332,4 +332,157 @@
<string name="widget_next_month">Následující měsíc</string>
<string name="widget_today">Dnes</string>
<string name="filter_title">Kalendáře</string>
<string name="reminder_after">%1$s po</string>
<string name="settings_title">Nastavení</string>
<string name="settings_back">Zpět</string>
<string name="back">Zpět</string>
<string name="settings_section_appearance">Vzhled</string>
<string name="settings_theme">Motiv</string>
<string name="settings_theme_system">Systém</string>
<string name="settings_theme_light">Světlý</string>
<string name="settings_theme_dark">Tmavý</string>
<string name="settings_theme_hint">Světlý nebo tmavý motiv aplikace. Volba se projeví okamžitě.</string>
<string name="settings_theme_system_summary">Nyní je nastaven %1$s</string>
<string name="settings_default_view">Výchozí zobrazení</string>
<string name="settings_dynamic_color">Dynamické barvy</string>
<string name="settings_dynamic_color_unavailable">Je zapotřepí Android 12 či novější</string>
<string name="settings_soften_colors">Sladit barvy kalendáře</string>
<string name="settings_soften_colors_summary">Zachovat odstín každého kalendáře, ale vyrovnat jeho jas, aby každá událost zůstala čitelná a barvy k sobě ladily. Vypnutím této funkce se zobrazí původní barvy zdrojového kalendáře.</string>
<string name="settings_font_headings">Písmo nadpisů</string>
<string name="settings_font_body">Písmo těla textu</string>
<string name="settings_font_system">Výchozí nastavení systému</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Vybrat soubor…</string>
<string name="settings_font_custom_selected">Vlastní písmo</string>
<string name="settings_font_import_failed">Tento soubor se nepodařilo načíst jako písmo</string>
<string name="settings_font_specimen_heading">Čtvrtek, 14. května</string>
<string name="settings_font_specimen_body">V 10:00 proběhne porada týmu, poté oběd s Robinem v kavárně na náměstí.</string>
<string name="settings_week_start">Týden začíná dnem</string>
<string name="settings_week_start_auto">Automaticky</string>
<string name="settings_week_start_auto_summary">Nyní %1$s</string>
<string name="settings_week_numbers">Čísla týdnů</string>
<string name="settings_week_numbers_summary">Zobrazit čísla týdnů kalendáře v měsíčním zobrazení</string>
<string name="settings_today_toolbar">Tlačítko „Dnes“ na panelu nástrojů</string>
<string name="settings_today_toolbar_summary">Zobrazit na panelu nástrojů tlačítko „Přejít na dnešek“ namísto plovoucího tlačítka</string>
<string name="settings_app_name">Název aplikace</string>
<string name="settings_app_name_summary">Zobrazí aplikaci Calendula jako „Kalendář“ ve vašem spouštěči. Změní se pouze název ve spouštěči; ikona se po přepnutí může přesunout na jiné místo.</string>
<string name="settings_time_format">Formát času</string>
<string name="settings_time_format_auto">Automaticky</string>
<string name="settings_time_format_12h">12hodinový (2:00 PM)</string>
<string name="settings_time_format_24h">24hodinový (14:00)</string>
<string name="settings_time_format_auto_summary">Podle systému: %1$s</string>
<string name="settings_hour_lines">Hodinová mřížka</string>
<string name="settings_hour_lines_summary">Zobrazit oddělovací čáru u každé hodiny v dané buňce v týdenním a denním zobrazení</string>
<string name="settings_timeline_scale">Výška hodin</string>
<string name="settings_timeline_scale_hint">Kolik vertikálního prostoru zabírá jedna hodina v týdenním a denním zobrazení. Toto nastavení platí pro obě zobrazení. Výšku časové osy můžete v rámci daného rozmezí nastavit pomocí gesta přiblížení dvěma prsty.</string>
<string name="timeline_scale_fit_day">Přizpůsobit se dennímu zobrazení</string>
<string name="timeline_scale_fit_day_summary">Všech 24 hodin na jedné obrazovce, bez nutnosti posouvání</string>
<string name="timeline_scale_compact">Kompaktní</string>
<string name="timeline_scale_compact_summary">Více hodin na obrazovce, menší bloky</string>
<string name="timeline_scale_regular">Normální</string>
<string name="timeline_scale_regular_summary">Standardní rozestup</string>
<string name="timeline_scale_comfortable">Pohodlná</string>
<string name="timeline_scale_comfortable_summary">Prostornější bloky, více posouvání</string>
<string name="timeline_scale_custom">Vlastní</string>
<string name="timeline_scale_custom_summary">Výška, na kterou jste časovou osu nastavili gestem</string>
<string name="settings_dim_completed">Ztmavit dokončené události</string>
<string name="settings_dim_completed_summary">Ztmavit dokončené události v měsíčním a týdenním zobrazení</string>
<string name="settings_drag_to_reschedule">Přetažením změňte termín</string>
<string name="settings_drag_to_reschedule_summary">Přesuňte událost přetažením na jiný den nebo čas</string>
<string name="settings_past_events">Uplynulé události</string>
<string name="settings_past_events_show">Zobrazit</string>
<string name="settings_past_events_dim">Ztmavit</string>
<string name="settings_past_events_hide">Skrýt</string>
<string name="settings_past_events_sample_morning">Přehled týmu</string>
<string name="settings_past_events_sample_midday">Oběd s Robinem</string>
<string name="settings_past_events_sample_evening">Zkouška sboru</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Rozsah agendy</string>
<string name="settings_agenda_range_hint">Jak daleko do budoucna zobrazuje obrazovka „Agenda“ události.</string>
<string name="settings_agenda_widget_range">Rozsah widgetu agendy</string>
<string name="settings_agenda_widget_range_hint">Jak daleko dopředu widget agendy na domovské obrazovce zobrazuje události.</string>
<string name="settings_widget_size">Velikost widgetu agendy</string>
<string name="settings_widget_size_hint">Velikost textu zobrazeného ve widgetu „Agenda“ na domovské obrazovce. Widget „Měsíc“ nemá žádné nastavení jeho mřížka se vždy přizpůsobí prostoru, který mu přidělíte.</string>
<string name="settings_widget_size_small">Malý</string>
<string name="settings_widget_size_medium">Střední</string>
<string name="settings_widget_size_large">Velký</string>
<string name="settings_widget_size_extra_large">Největší</string>
<string name="settings_widget_size_summary">Text události%1$d</string>
<string name="settings_agenda_show_today">Vždy zobrazovat dnešek</string>
<string name="settings_agenda_show_today_hint">Ponechat dnešní den na prvním místě agendy a jejího widgetu, třebaže již nezbývá žádná nadcházející událost.</string>
<string name="state_failure_all_hidden">Všechny vaše kalendáře jsou vypnuté.</string>
<string name="state_failure_all_hidden_action">Správa kalendářů</string>
<string name="field_copy_action">Kopírovat</string>
<string name="field_copied">Zkopírováno do schránky</string>
<string name="field_copy_failed">Toto se nepodařilo zkopírovat</string>
<string name="event_detail_title">Název</string>
<string name="agenda_range_day">Dnes</string>
<string name="agenda_range_this_week">Tento týden</string>
<string name="agenda_range_this_month">Tento měsíc</string>
<string name="agenda_range_week">Následujících 7 dní</string>
<string name="agenda_range_month">Následujících 30 dní</string>
<string name="agenda_range_custom">Vlastní…</string>
<string name="agenda_range_custom_hint">Dny</string>
<string name="agenda_range_override_hint">Pouze dočasně při opětovném spuštění aplikace Calendula se nastavení vrátí k uloženému rozsahu.</string>
<string name="settings_agenda_range_bar">Lišta s rozmezím</string>
<string name="settings_agenda_range_bar_hint">V horní části agendy se zobrazí lišta s rozsahem dat a tlačítkem pro změnu rozsahu dané relace</string>
<string name="agenda_range_showing_label">Zobrazit všechny nadcházející události pro</string>
<plurals name="agenda_range_days">
<item quantity="one">%dden</item>
<item quantity="few">%ddny</item>
<item quantity="many">%ddnů</item>
<item quantity="other">%ddnů</item>
</plurals>
<string name="settings_section_views">Pohledy</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="month_style_paged">Stránky</string>
<string name="month_style_paged_summary">Na obrazovce se zobrazuje jeden měsíc. Přejetím prstem doleva nebo doprava můžete změnit měsíc.</string>
<string name="month_style_continuous">Posouvání měsíců</string>
<string name="month_style_continuous_summary">Každý měsíc je zobrazen pod vlastním nadpisem, od následujícího je oddělen malou mezerou.</string>
<string name="month_style_dense">Nepřerušené týdny</string>
<string name="month_style_dense_summary">Týdny plynule navazují na sebe, jeden měsíc přechází do druhého, aniž by mezi nimi byla nějaká mezera.</string>
<string name="month_style_split_summary">Kompaktní mřížka označuje dny, ve kterých se konají události, a den, na který klepnete, je zobrazen pod ní.</string>
<string name="month_style_split">Rozdělení</string>
<string name="month_split_no_events">Není nic naplánováno</string>
<string name="month_split_expand">Zobrazit celý měsíc</string>
<string name="month_split_collapse">Zobrazit události dne</string>
<string name="settings_quick_switch_header">Tlačítko pro rychlé přepnutí</string>
<string name="settings_quick_switch_hint">Vyberte, která zobrazení bude přepínat tlačítko v pravém horním rohu, a přetažením je seřaďte. Pokud jsou zapnutá méně než dvě zobrazení, tlačítko se skryje. Vypnutá zobrazení zůstanou přístupná z navigačního menu.</string>
<string name="settings_drawer_order_header">Navigační menu</string>
<string name="settings_drawer_order_hint">Přetažením změníte pořadí zobrazení uvedených v navigačním menu.</string>
<string name="reorder_drag_handle">Přetažením změňte pořadí</string>
<string name="settings_section_event_form">Formulář nové události</string>
<string name="settings_form_fields_hint">Pole zobrazená ve výchozím nastavení — vše ostatní se zobrazí kliknutím na „Další pole“</string>
<string name="settings_autofocus_title">Zaměření názvu při vytváření nové události</string>
<string name="settings_autofocus_title_hint">Při vytváření nové události se kurzor umístí do pole názvu, otevře se klávesnice.</string>
<string name="settings_event_duration">Výchozí doba trvání</string>
<string name="settings_event_duration_hint">Jak dlouho nová událost trvá, dokud nezměníte čas jejího ukončení. Celodenní události tím nejsou ovlivněny.</string>
<string name="settings_calendar_durations_title">Doba trvání podle kalendáře</string>
<string name="settings_calendar_durations_hint">Nastavte kalendáři vlastní výchozí délku například 8 hodin pro pracovní směny.</string>
<string name="settings_calendar_duration_inherits">Výchozí (%1$s)</string>
<string name="settings_calendar_duration_use_default">Použít výchozí dobu trvání (%1$s)</string>
<string name="settings_color_unsupported">Povolit barvy u nepodporovaných kalendářů</string>
<string name="settings_color_unsupported_hint">Jisté kalendáře (např. některé typu CalDAV) neposkytují vlastní definici barev; barva události tak může být při příští synchronizaci ztracena nebo přepsána. Jde o omezení daných kalendářů, nikoli o problém Calenduly.</string>
<string name="settings_section_notifications">Oznámení</string>
<string name="settings_reminders">Připomínky událostí</string>
<string name="settings_reminders_hint">Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře v jedné z nich je vypněte.</string>
<string name="settings_default_reminder">Výchozí připomínka</string>
<string name="settings_default_reminder_allday">Celodenní události</string>
<string name="settings_allday_reminder_time">Čas celodenní připomínky</string>
<string name="settings_allday_reminder_time_hint">Připomínky na celodenní události se spouštějí v %1$s</string>
<string name="settings_allday_reminder_fires_at">Upozorní v %1$s</string>
<string name="reminder_none">Žádná</string>
<string name="reminder_use_default">Použít výchozí připomínku</string>
<string name="reminder_custom_amount">Množství</string>
<string name="reminder_custom_set">Nastavit</string>
<string name="settings_calendar_reminders_title">Připomínky pro jednotlivé kalendáře</string>
<string name="settings_calendar_reminders_hint">Přepíše výchozí nastavení pro jednotlivé kalendáře zvlášť pro události s konkrétním časem a pro celodenní události. Kalendář může zachovat výchozí nastavení, zrušit jej nebo nastavit vlastní.</string>
<string name="settings_calendar_reminder_inherits">Výchozí (%1$s)</string>
<string name="settings_reliable_delivery_hint">Android může kvůli úspoře baterie odložit připomínky. Udělte aplikaci Calendula výjimku, aby se zobrazovaly 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_special_dates_reminders">Připomínky</string>
</resources>
-3
View File
@@ -382,12 +382,9 @@
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
<string name="crash_dialog_report">Melden</string>
<string name="crash_dialog_dismiss">Nicht jetzt</string>
<string name="crash_report_issue_title">Absturzbericht</string>
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
<plurals name="duration_days">
<item quantity="one">%d Tag</item>
-3
View File
@@ -405,12 +405,9 @@
<string name="crash_dialog_message">%1$s se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
<string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">No por ahora</string>
<string name="crash_report_issue_title">Reporte de error</string>
<string name="crash_report_clip_label">Reporte de error de %1$s</string>
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
<string name="crash_report_body_template">Gracias por reportar un error en %1$s. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%2$s\n</string>
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
<string name="event_attendee_resource">Recurso</string>
<string name="settings_section_about">Acerca de</string>
<string name="recurrence_with_count">%1$s, %2$d veces</string>
+1 -3
View File
@@ -323,6 +323,7 @@
<plurals name="agenda_range_days">
<item quantity="one">%d jour</item>
<item quantity="many">%d jours</item>
<item quantity="other">%d jours</item>
</plurals>
<string name="settings_section_views">Vues</string>
<string name="settings_quick_switch_header">Bouton dinterrupteur rapide</string>
@@ -486,12 +487,9 @@
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et ninclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
<string name="crash_dialog_report">rapport</string>
<string name="crash_dialog_dismiss">pas maintenant</string>
<string name="crash_report_issue_title">rapport de plantage</string>
<string name="crash_report_clip_label">%1$s rapport de plantage</string>
<string name="crash_report_copied">Rapport copié dans votre presse-papiers</string>
<string name="crash_report_open_failed">Impossible douvrir le suivi des problèmes. Le rapport se trouve dans votre presse-papiers.</string>
<string name="crash_report_body_template">Merci davoir signalé un plantage dans %1$s. Veuillez ajouter tout ce dont vous vous souvenez sur ce que vous faisiez, puis soumettez-le.\n\n### Que sest-il passé\n\n\n### Rapport de plantage\n%2$s\n</string>
<string name="crash_report_body_paste">_(Le rapport était trop long pour ce lien — collez-le depuis votre presse-papiers ici.)_</string>
<string name="special_dates_calendar_birthday">anniversaires</string>
<string name="special_dates_calendar_anniversary">Anniversaires</string>
<string name="special_dates_calendar_custom">dates spéciales</string>
+14 -3
View File
@@ -394,12 +394,9 @@
<string name="crash_dialog_message">%1$s si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari: solo le informazioni tecniche qui sotto.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Non ora</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">%1$s crash report</string>
<string name="crash_report_copied">Report copiato negli Appunti</string>
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è negli Appunti.</string>
<string name="crash_report_body_template">Grazie per segnalare un crash di %1$s. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%2$s\n</string>
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
<string name="event_detail_recurrence">Ricorrenza</string>
<string name="event_detail_recurring">Evento ricorrente</string>
<string name="recurrence_on_days">%1$s da %2$s</string>
@@ -646,4 +643,18 @@
<string name="settings_default_view_hint">La vista di default all\'apertura dell\'app.</string>
<string name="settings_week_start_hint">Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget.</string>
<string name="settings_time_format_hint">Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema.</string>
<string name="state_failure_all_hidden_action">Gestisci calendari</string>
<string name="field_copy_action">Copia</string>
<string name="field_copied">Copiato negli appunti</string>
<string name="field_copy_failed">Impossibile copiarlo</string>
<string name="event_detail_title">Titolo</string>
<string name="reminder_after">%1$s dopo</string>
<string name="licences_title">Licenza open source</string>
<string name="licences_intro">Calendula include il codice riportato di seguito. Tocca una voce qualsiasi per visualizzarne il codice sorgente.</string>
<string name="licences_footer">Ogni progetto qui presente è incluso senza modifiche, secondo la licenza indicata.</string>
<string name="import_warning_tasks">Le attività contenute in questo file sono state aggiunte come eventi.</string>
<string name="import_warning_recurrence_repaired">È stata corretta una regola di ripetizione errata.</string>
<string name="import_done_failed_label">Non aggiunto</string>
<string name="import_done_failed_note">Alcuni eventi non possono essere aggiunti e sono stati esclusi.</string>
<string name="import_done_stopped_title">Importazione interrotta</string>
</resources>
-3
View File
@@ -495,12 +495,9 @@
<string name="crash_dialog_message">Aplikacja %1$s została nieoczekiwanie zamknięta przy ostatnim uruchomieniu. Możesz pomóc nam to naprawić, wysyłając zgłoszenie o błędzie. Pozostanie ono na Twoim urządzeniu, dopóki nie zdecydujesz się go udostępnić, i nie zawiera żadnych danych osobowych ani treści z kalendarza — jedynie poniższe szczegóły techniczne.</string>
<string name="crash_dialog_report">Zgłoś</string>
<string name="crash_dialog_dismiss">Nie teraz</string>
<string name="crash_report_issue_title">Raport o awarii</string>
<string name="crash_report_clip_label">Raport o awarii %1$s</string>
<string name="crash_report_copied">Raport skopiowany do schowka</string>
<string name="crash_report_open_failed">Nie udało się otworzyć systemu zgłoszeń. Raport znajduje się w Twoim schowku.</string>
<string name="crash_report_body_template">Dziękujemy za zgłoszenie awarii w aplikacji %1$s. Prosimy o dodanie wszelkich zapamiętanych szczegółów na temat wykonywanych czynności, a następnie przesłanie zgłoszenia.\n\n### Co się wydarzyło\n\n\n### Raport o awarii\n%2$s\n</string>
<string name="crash_report_body_paste">_(Raport był zbyt długi dla tego linku — wklej go tutaj ze schowka)._</string>
<string name="special_dates_calendar_custom">Szczególne daty</string>
<string name="special_dates_default_title_birthday">Urodziny {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Rocznica {name} ({year})</string>
@@ -636,12 +636,9 @@
<string name="crash_dialog_message">%1$s fechou inesperadamente da última vez. Você pode ajudar a consertar enviando este reporte como um problema. Ele fica no seu dispositivo até que decida enviá-lo, e não inclui dados pessoais ou conteúdo de calendário — somente os detalhes técnicos abaixo.</string>
<string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">Agora não</string>
<string name="crash_report_issue_title">Reportar quebra</string>
<string name="crash_report_clip_label">%1$s relatório de quebra</string>
<string name="crash_report_copied">Relatório copiado para a área de transferência</string>
<string name="crash_report_open_failed">Não foi possível abrir o tracker de problemas. O relatório está na área de transferência.</string>
<string name="crash_report_body_template">Obrigado por reportar uma quebra em %1$s. Por favor, escreva o que estava fazendo, depois envie. \n### O que aconteceu \n\n\n### Relatório de quebra \n%2$s\n</string>
<string name="crash_report_body_paste">_(O reporte era muito longo para este link — cole da área de transferência aqui.)_</string>
<string name="special_dates_calendar_birthday">Aniversários</string>
<string name="special_dates_calendar_anniversary">Aniversários de relacionamento</string>
<string name="special_dates_calendar_custom">Datas especiais</string>
+502
View File
@@ -6,4 +6,506 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Um calendário moderno.</string>
<string name="state_loading">A carregar…</string>
<string name="state_retry">Tentar novamente</string>
<string name="state_failure_unknown">Algo correu mal.</string>
<string name="state_failure_permission">O acesso ao calendário é necessário.</string>
<string name="state_failure_permission_action">Conceder acesso</string>
<string name="state_failure_no_calendars">Nenhum calendário configurado.</string>
<string name="state_failure_no_calendars_action">Abrir configurações do calendário do sistema</string>
<string name="state_failure_all_hidden">Todos os seus calendários estão desligados.</string>
<string name="state_failure_all_hidden_action">Gerir calendários</string>
<string name="state_failure_provider">Não foi possível ler o calendário.</string>
<string name="field_copy_action">Copiar</string>
<string name="field_copied">Copiado para a área de transferência</string>
<string name="field_copy_failed">Não foi possível copiar</string>
<string name="permission_rationale_title">Veja todos os seus eventos, lindamente</string>
<string name="permission_rationale_body">O Calendula precisa de aceder ao seu calendário para mostrar e gerir eventos. É tudo o que ele pede — e nada sai do seu dispositivo.</string>
<string name="permission_request_button">Conceder acesso ao calendário</string>
<string name="permission_denied_title">Acesso ao calendário negado</string>
<string name="permission_denied_body">O Calendula não consegue mostrar eventos sem acesso ao calendário. Pode concedê-lo novamente nas configurações do sistema.</string>
<string name="permission_open_settings_button">Abrir configurações do sistema</string>
<string name="permission_retry_button">Tentar novamente</string>
<string name="permission_benefit_private_title">Fica no seu dispositivo</string>
<string name="permission_benefit_private_body">Sem permissões de rede, sem telemetria — nada sai do seu telemóvel.</string>
<string name="permission_benefit_sync_title">Todos os seus calendários, juntos</string>
<string name="permission_benefit_sync_body">Google, CalDAV, local — tudo sincronizado ao dispositivo simplesmente aparece.</string>
<string name="permission_benefit_privacy_title">Nenhum rastreamento, jamais</string>
<string name="permission_benefit_privacy_body">Zero telemetria, zero dados para análise, sem anúncios.</string>
<string name="permission_privacy_footnote">Fica no seu dispositivo · sem permissão de internet</string>
<string name="month_prev">Mês anterior</string>
<string name="month_next">Mês seguinte</string>
<string name="month_today_action">Hoje</string>
<string name="month_more_actions">Mais ações</string>
<string name="month_open_menu">Abrir menu</string>
<string name="month_action_settings">Configurações</string>
<string name="month_a11y_today_prefix">Hoje</string>
<string name="week_today_action">Esta semana</string>
<string name="week_number_label">Sem</string>
<string name="day_today_action">Hoje</string>
<string name="event_detail_back">Voltar</string>
<string name="event_detail_edit">Editar</string>
<string name="event_detail_delete">Eliminar</string>
<string name="event_detail_share">Compartilhar</string>
<string name="event_detail_duplicate">Duplicar</string>
<string name="event_share_chooser_title">Compartilhar evento</string>
<string name="event_share_failed">Não foi possível compartilhar este evento.</string>
<string name="event_delete_title">Eliminar evento?</string>
<string name="event_delete_body">O evento será removido do seu calendário e de todos os aparelhos que o sincronizam.</string>
<string name="event_delete_recurring_title">Eliminar evento recorrente</string>
<string name="event_delete_option_occurrence">Apenas este evento</string>
<string name="event_delete_option_following">Este evento e todos os próximos</string>
<string name="event_delete_option_series">Todos os eventos nesta série</string>
<string name="event_edit_recurring_title">Editar evento recorrente</string>
<string name="event_delete_failed">Não foi possível eliminar o evento</string>
<string name="event_delete_write_denied">O Calendula requer permissão de escrita para excluir eventos</string>
<string name="event_move_action">Mover…</string>
<string name="event_move_recurring_title">Mover evento recorrente</string>
<string name="event_move_occurrence_only">Mover a série inteira mudaria os dias em que o evento cai, então somente este evento se pode mover.</string>
<string name="event_move_done">Movido para %1$s</string>
<string name="event_move_undo">Desfazer</string>
<string name="event_move_undone">Mover desfeito</string>
<string name="event_move_failed">Não foi possível mover o evento</string>
<string name="event_move_write_denied">O Calendula precisa de permissão de escrita para mover eventos</string>
<string name="event_move_gone">Este evento já não existe</string>
<string name="event_move_blocked_series_end">Impossível mover um evento após o fim da sua série</string>
<string name="dialog_cancel">Cancelar</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Novo evento</string>
<string name="event_edit_close">Fechar</string>
<string name="event_edit_save">Guardar</string>
<string name="event_edit_title_hint">Adicionar título</string>
<string name="event_edit_managed_hint">Gerido por “%1$s” — o título, data e frequência são sincronizados dos seus contactos. Os lembretes, os locais e as notas são seus para editar.</string>
<string name="event_edit_starts">Inicia</string>
<string name="event_edit_ends">Termina</string>
<string name="event_edit_error_end_before_start">Termina antes de iniciar</string>
<string name="event_edit_error_no_calendar">Nenhum calendário disponível para escrita</string>
<string name="event_edit_save_failed">Não foi possível guardar o evento</string>
<string name="event_edit_write_denied">O Calendula requer permissão de escrita para criar eventos</string>
<string name="event_edit_more_fields">Mais campos</string>
<string name="event_edit_add">Adicionar</string>
<string name="event_edit_add_reminder">Adicionar lembrete</string>
<string name="event_edit_remove_reminder">Remover lembrete</string>
<string name="event_edit_attendees">Convidados</string>
<string name="event_edit_add_guest">Adicionar convidado</string>
<string name="event_edit_add_guest_hint">Adicionar convidado via e-mail…</string>
<string name="event_edit_add_guest_from_contacts">Adicionar dos contactos</string>
<string name="event_edit_location_from_contacts">Escolher endereço dos contactos</string>
<string name="event_edit_remove_guest">Remover convidado</string>
<string name="event_edit_attendee_required">Obrigatório</string>
<string name="event_edit_attendee_optional">Opcional</string>
<string name="event_edit_attendees_note_synced">O Calendula não envia convites. A sua conta de calendário poderá enviar e-mails para os convidados quando sincronizar.</string>
<string name="event_edit_attendees_note_local">Armazenado neste dispositivo. Ninguém é notificado.</string>
<string name="event_edit_reminder_custom">Personalizado</string>
<string name="reminder_unit_minutes">minutos</string>
<string name="reminder_unit_hours">horas</string>
<string name="reminder_unit_days">dias</string>
<string name="event_edit_timezone_device_summary">Segue onde estiver</string>
<string name="event_edit_timezone_search">Procurar fusos horários</string>
<string name="event_edit_timezone_all">Todos os fusos horários</string>
<string name="reminder_unit_weeks">semanas</string>
<string name="event_edit_availability">Disponibilidade</string>
<string name="event_edit_visibility">Visibilidade</string>
<string name="event_edit_timezone_device">Fuso horário do dispositivo</string>
<string name="event_edit_timezone_recent">Recente</string>
<string name="event_edit_timezone_none">Nenhum fuso horário encontrado com “%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s, no seu fuso horário</string>
<string name="event_edit_color_default">Cor do calendário</string>
<string name="event_edit_color">Cor</string>
<string name="event_edit_color_custom">Cor personalizada</string>
<string name="event_edit_color_reset">Redefinir</string>
<string name="event_edit_color_unsupported">Não disponível neste calendário</string>
<string name="event_edit_color_unsupported_hint">Este calendário não publica um esquema de cores. Pode permitir cores personalizadas nas Configurações.</string>
<string name="event_edit_color_sync_warning">Este calendário pode remover ou sobrescrever a cor na próxima sincronização.</string>
<string name="event_edit_conflict_title">Evento alterado noutro lugar</string>
<string name="event_edit_conflict_body">Enquanto estava a editar, este evento foi alterado — por sincronia ou por outra aplicação. O que fazer com as suas mudanças?</string>
<string name="event_edit_conflict_overwrite">Guardar mudanças</string>
<string name="event_edit_conflict_overwrite_hint">Apenas os campos editados sobrescrevem a mudança externa</string>
<string name="event_edit_conflict_discard">Descartar mudanças</string>
<string name="event_edit_conflict_discard_hint">O evento fica como está agora</string>
<string name="event_edit_gone_title">Evento eliminado</string>
<string name="event_edit_gone_body">Este evento foi entretanto eliminado, por exemplo, noutro aparelho. As suas mudanças já não podem ser guardadas.</string>
<string name="import_reminder_prompt_title">Aplicar o seu lembrete padrão?</string>
<string name="import_reminder_prompt_body_none">Este evento foi importado sem lembretes.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Este evento foi importado com %1$d lembrete.</item>
<item quantity="many">Este evento foi importado com %1$d lembretes.</item>
<item quantity="other">Este evento foi importado com %1$d lembretes.</item>
</plurals>
<string name="import_reminder_prompt_apply">Aplicar o padrão</string>
<string name="import_reminder_prompt_keep">Manter assim</string>
<string name="event_edit_recurrence_none">Não se repete</string>
<string name="event_edit_recurrence_custom">Personalizado</string>
<string name="event_edit_recurrence_every">A cada</string>
<string name="recurrence_unit_days">dias</string>
<string name="recurrence_unit_weeks">semanas</string>
<string name="recurrence_unit_months">meses</string>
<string name="recurrence_unit_years">anos</string>
<string name="event_edit_recurrence_incomplete">Insira um número de 1 a 999</string>
<string name="event_edit_recurrence_next">Próximo: %1$s</string>
<string name="event_edit_recurrence_next_none">Esta regra nunca se repete</string>
<string name="event_edit_recurrence_ends">Termina</string>
<string name="event_edit_recurrence_end_never">Nunca</string>
<string name="event_edit_recurrence_end_until">Numa data</string>
<string name="event_edit_recurrence_end_count">Após um número de vezes</string>
<string name="event_edit_recurrence_times">vezes</string>
<string name="event_edit_error_recurrence_ends_before_start">As repetições terminam antes do evento começar</string>
<string name="event_availability_busy">Ocupado</string>
<string name="event_access_default">Padrão</string>
<string name="event_access_public">Público</string>
<string name="event_detail_all_day">O dia todo</string>
<string name="event_detail_calendar">Calendário</string>
<string name="event_detail_calendar_unknown">Calendário desconhecido</string>
<string name="event_detail_title">Título</string>
<string name="event_detail_location">Local</string>
<string name="event_detail_description">Descrição</string>
<string name="event_detail_attendees">Participantes</string>
<string name="event_detail_recurrence">Recorrência</string>
<string name="event_detail_recurring">Evento recorrente</string>
<string name="recurrence_daily">Todos os dias</string>
<string name="recurrence_weekly">Todas as semanas</string>
<string name="recurrence_monthly">Todos os meses</string>
<string name="recurrence_yearly">Todos os anos</string>
<string name="recurrence_every_n_days">A cada %1$d dias</string>
<string name="recurrence_every_n_weeks">A cada %1$d semanas</string>
<string name="recurrence_every_n_months">A cada %1$d meses</string>
<string name="recurrence_every_n_years">A cada %1$d anos</string>
<string name="recurrence_on_days">%1$s à %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d vezes</string>
<string name="recurrence_with_until">%1$s até %2$s</string>
<string name="event_detail_not_found">Este evento já não existe.</string>
<string name="event_attendee_accepted">Aceitou</string>
<string name="event_attendee_declined">Rejeitou</string>
<string name="event_attendee_tentative">Não decidiu</string>
<string name="event_attendee_needs_action">Sem resposta</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Lembretes</string>
<string name="event_detail_timezone">Fuso horário</string>
<string name="event_status_tentative">Talvez</string>
<string name="event_status_cancelled">Cancelado</string>
<string name="event_availability_free">Disponível</string>
<string name="event_access_private">Privado</string>
<string name="event_access_confidential">Confidencial</string>
<string name="event_access_public_summary">Todos com acesso veem tudo</string>
<string name="event_access_default_summary">O que este calendário normalmente faz</string>
<string name="event_access_private_summary">Os outros veem apenas que está ocupado</string>
<string name="event_access_confidential_summary">Marcado como confidencial; o que significa depende da conta</string>
<string name="event_attendee_organizer">Organizador</string>
<string name="event_attendee_optional">Opcional</string>
<string name="event_attendee_resource">Recurso</string>
<string name="event_detail_self_response">A sua resposta: %1$s</string>
<string name="reminder_at_time">Na hora do evento</string>
<string name="reminder_default">Lembrete padrão</string>
<string name="reminder_after">%1$s depois</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minuto antes</item>
<item quantity="many">%d minutos antes</item>
<item quantity="other">%d minutos antes</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hora antes</item>
<item quantity="many">%d horas antes</item>
<item quantity="other">%d horas antes</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d dia antes</item>
<item quantity="many">%d dias antes</item>
<item quantity="other">%d dias antes</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d semana antes</item>
<item quantity="many">%d semanas antes</item>
<item quantity="other">%d semanas antes</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuto</item>
<item quantity="many">%d minutos</item>
<item quantity="other">%d minutos</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hora</item>
<item quantity="many">%d horas</item>
<item quantity="other">%d horas</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">No máximo %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d semana</item>
<item quantity="many">%d semanas</item>
<item quantity="other">%d semanas</item>
</plurals>
<string name="event_untitled">(Sem título)</string>
<string name="onboarding_step_counter">Passo %1$d de %2$d</string>
<string name="onboarding_backup_title">Os seus eventos vivem somente aqui</string>
<string name="onboarding_backup_body">Nada neste aparelho sincroniza para uma conta, ou seja, se o perder perde também o seu calendário. O Calendula pode criar um backup para si.</string>
<string name="onboarding_backup_benefit_folder_title">Uma pasta à sua escolha</string>
<string name="onboarding_backup_benefit_folder_body">Os backups são arquivos .ics — coloque-os num lugar para sincronizar, ou num cartão SD.</string>
<string name="onboarding_backup_benefit_daily_title">Uma vez por dia, sozinho</string>
<string name="onboarding_backup_benefit_daily_body">O Calendula exporta os seus calendários locais em segundo plano. Mude a frequência nas Configurações.</string>
<string name="onboarding_backup_enable_button">Escolher pasta e fazer backup</string>
<string name="onboarding_backup_skip_button">Agora não</string>
<string name="onboarding_view_title">O que deve abrir primeiro?</string>
<string name="onboarding_month_style_title">Como se deve o Mês parecer?</string>
<string name="onboarding_view_continue_button">Continuar</string>
<string name="onboarding_back">Voltar</string>
<string name="onboarding_visibility_button">Entendi</string>
<string name="onboarding_done_title">Tudo pronto</string>
<string name="onboarding_done_body">Os seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois nas Configurações.</string>
<string name="onboarding_done_button">Abrir o meu calendário</string>
<string name="reminder_channel_description">Notificações na hora dos lembretes dos seus eventos</string>
<string name="reminder_onboarding_title">Nunca perca um evento</string>
<string name="reminder_onboarding_body">O Android não mostra os seus lembretes sozinho — uma aplicação de calendário é que faz isso. Deixe o Calendula cuidar disso.</string>
<string name="reminder_benefit_delivery_title">Lembretes, entregues</string>
<string name="reminder_benefit_delivery_body">Todos os seus lembretes de evento chegam em notificação, na hora certa.</string>
<string name="reminder_benefit_duplicates_title">A usar outra aplicação de calendário?</string>
<string name="reminder_benefit_duplicates_body">Se outra aplicação também enviar lembretes, vê-los-á em dobro — desligue aqui ou lá.</string>
<string name="reminder_benefit_reversible_body">O botão está nas Configurações, dentro de Notificações.</string>
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
<string name="reminder_onboarding_skip_button">Agora não</string>
<string name="reminder_action_snooze">Soneca</string>
<string name="reminder_action_dismiss">Ignorar</string>
<string name="reminder_day_tomorrow">Amanhã</string>
<string name="reminder_day_yesterday">Ontem</string>
<string name="view_month">Mês</string>
<string name="view_week">Semana</string>
<string name="view_day">Dia</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vista</string>
<string name="drawer_jump_to_date">Ir para data</string>
<string name="agenda_today_action">Hoje</string>
<string name="agenda_header_today">Hoje</string>
<string name="agenda_header_tomorrow">Amanhã</string>
<string name="agenda_empty_title">Tudo em dia</string>
<string name="agenda_no_more_today">Sem mais eventos hoje</string>
<string name="agenda_span_starts">Começa às %1$s</string>
<string name="agenda_span_ends">Termina às %1$s</string>
<string name="today_jump_action">Ir para Hoje</string>
<string name="search_action">Procurar</string>
<string name="search_hint">Procurar eventos</string>
<string name="search_back">Voltar</string>
<string name="search_idle_hint">Procure os seus eventos por título, local ou notas.</string>
<plurals name="search_selected_count">
<item quantity="one">%d selecionado</item>
<item quantity="many">%d selecionados</item>
<item quantity="other">%d selecionados</item>
</plurals>
<string name="search_in_descriptions">Encontrado em descrições</string>
<string name="search_selection_close">Cancelar seleção</string>
<string name="search_select_all">Selecionar tudo</string>
<string name="search_delete_selected">Eliminar selecionados</string>
<plurals name="search_delete_title">
<item quantity="one">Eliminar %d evento?</item>
<item quantity="many">Eliminar %d eventos?</item>
<item quantity="other">Eliminar %d eventos?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d evento eliminado</item>
<item quantity="many">%d eventos eliminados</item>
<item quantity="other">%d eventos eliminados</item>
</plurals>
<string name="search_delete_partial">%1$d eliminado(s), %2$d não puderam ser eliminado(s)</string>
<string name="search_delete_denied_partial">%1$d eliminado(s), e depois a permissão de escrita foi removida</string>
<string name="widget_agenda_title">A seguir</string>
<string name="widget_agenda_label">Agenda do Calendula</string>
<string name="widget_month_label">Mês do Calendula</string>
<string name="widget_refresh">Atualizar</string>
<string name="widget_new_event">Novo evento</string>
<string name="widget_needs_permission">Abra o Calendula para conceder acesso ao calendário</string>
<string name="widget_prev_month">Mês passado</string>
<string name="widget_next_month">Mês seguinte</string>
<string name="widget_today">Hoje</string>
<string name="filter_title">Calendários</string>
<string name="settings_title">Configurações</string>
<string name="search_empty">Nenhum evento corresponde a \"%1$s\".</string>
<string name="search_clear">Limpar</string>
<string name="reminder_benefit_reversible_title">Mude a qualquer hora</string>
<string name="reminder_channel_name">Lembretes de evento</string>
<string name="settings_back">Voltar</string>
<string name="back">Voltar</string>
<string name="settings_section_appearance">Aparência</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Sistema</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Escuro</string>
<string name="settings_theme_hint">Se a aplicação é clara ou escura. Aplica imediatamente.</string>
<string name="settings_theme_system_summary">De momento %1$s</string>
<string name="settings_default_view">Vista padrão</string>
<string name="settings_dynamic_color">Cor dinâmica</string>
<string name="settings_dynamic_color_unavailable">Requer Android 12 ou mais recente</string>
<string name="settings_soften_colors">Harmonizar cores do calendário</string>
<string name="settings_soften_colors_summary">Mantenha cada tom do calendário mas equilibre os brilhos, para que cada evento continue legível e as cores combinem. Desative para mostrar as cores brutas da fonte do calendário.</string>
<string name="settings_font_headings">Fonte dos cabeçalhos</string>
<string name="settings_font_body">Fonte do corpo</string>
<string name="settings_font_system">Padrão do sistema</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Escolher arquivo…</string>
<string name="settings_font_custom_selected">Fonte personalizada</string>
<string name="settings_font_import_failed">Não foi possível ler o ficheiro como fonte</string>
<string name="settings_font_specimen_heading">Quinta-feira, 14 de maio</string>
<string name="settings_font_specimen_body">Reunião de equipa às 10:00, depois almoço com a Robin no café em Markplatz.</string>
<string name="settings_week_start">A semana começa no/na</string>
<string name="settings_week_start_auto">Automático</string>
<string name="settings_week_start_auto_summary">De momento %1$s</string>
<string name="settings_week_numbers">Números das semanas</string>
<string name="settings_week_numbers_summary">Mostrar números das semanas na vista do mês</string>
<string name="settings_today_toolbar">Botão \"Hoje\" na barra de ferramentas</string>
<string name="settings_today_toolbar_summary">Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante</string>
<string name="settings_app_name">Nome da aplicação</string>
<string name="settings_app_name_summary">Mostrar o Calendula como “Calendário” no seu launcher. Somente o nome do launcher muda; o ícone pode mudar para outro lugar após a troca.</string>
<string name="settings_time_format">Formato da hora</string>
<string name="settings_time_format_auto">Automático</string>
<string name="settings_time_format_12h">12 horas (2:00 PM)</string>
<string name="settings_time_format_24h">24 horas (14:00)</string>
<string name="settings_time_format_auto_summary">A seguir o sistema: %1$s</string>
<string name="settings_hour_lines">Grelha de horas</string>
<string name="settings_hour_lines_summary">Colocar cada hora na sua própria célula nas vistas Semana e Dia</string>
<string name="settings_timeline_scale">Altura da hora</string>
<string name="settings_timeline_scale_hint">Quanto espaço vertical que a hora ocupa nas vistas Semana e Dia. Ambas as vistas partilham este ajuste. Pode também pinçar a linha do tempo com dois dedos para definir outra altura.</string>
<string name="timeline_scale_fit_day">Encaixar o dia todo</string>
<string name="timeline_scale_fit_day_summary">24 horas no ecrã, sem scroll</string>
<string name="timeline_scale_compact">Compacto</string>
<string name="timeline_scale_compact_summary">Mais horas por ecrã, blocos menores</string>
<string name="timeline_scale_regular">Normal</string>
<string name="timeline_scale_regular_summary">Espaçamento padrão</string>
<string name="timeline_scale_comfortable">Confortável</string>
<string name="timeline_scale_comfortable_summary">Blocos maiores, mais scroll</string>
<string name="timeline_scale_custom">Personalizado</string>
<string name="timeline_scale_custom_summary">A altura em que pinçou a linha do tempo</string>
<string name="settings_dim_completed">Esmaecer eventos passados</string>
<string name="settings_dim_completed_summary">Esmaecer eventos que já terminaram nas vistas do mês e semana</string>
<string name="settings_drag_to_reschedule">Arrastar para reagendar</string>
<string name="settings_drag_to_reschedule_summary">Mova um evento arrastando-o para outro dia ou hora</string>
<string name="settings_past_events">Eventos passados</string>
<string name="settings_past_events_show">Mostrar</string>
<string name="settings_past_events_dim">Esmaecer</string>
<string name="settings_past_events_hide">Ocultar</string>
<string name="settings_past_events_sample_morning">Revisão da equipa</string>
<string name="settings_past_events_sample_midday">Almoço com a Robin</string>
<string name="settings_past_events_sample_evening">Treino do coro</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Amplitude da agenda</string>
<string name="settings_agenda_range_hint">O quanto à frente o ecrã da Agenda lista eventos.</string>
<string name="settings_agenda_widget_range">Amplitude do widget de Agenda</string>
<string name="settings_agenda_widget_range_hint">O quanto à frente o widget de Agenda lista eventos.</string>
<string name="settings_widget_size">Tamanho do widget de Agenda</string>
<string name="settings_widget_size_hint">Quão grande é o texto do widget de Agenda. O widget de mês não tem ajuste — a sua grelha encaixa sempre no espaço dado.</string>
<string name="settings_widget_size_small">Pequeno</string>
<string name="settings_widget_size_medium">Médio</string>
<string name="settings_widget_size_large">Grande</string>
<string name="settings_widget_size_extra_large">Extra grande</string>
<string name="settings_widget_size_summary">Texto do evento %1$d%%</string>
<string name="settings_agenda_show_today">Mostrar sempre Hoje</string>
<string name="settings_agenda_show_today_hint">Manter o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje.</string>
<string name="settings_agenda_range_bar">Barra de amplitude</string>
<string name="settings_agenda_range_bar_hint">Mostrar uma barra no topo da agenda a nomear as datas mostradas, com um botão para mudar a amplitude durante a sessão</string>
<string name="agenda_range_day">Hoje</string>
<string name="agenda_range_this_week">Esta semana</string>
<string name="agenda_range_this_month">Este mês</string>
<string name="agenda_range_week">Nos próximos 7 dias</string>
<string name="agenda_range_month">Nos próximos 30 dias</string>
<string name="agenda_range_custom">Personalizado…</string>
<string name="agenda_range_custom_hint">Dias</string>
<string name="agenda_range_override_hint">Só por enquanto — reajusta para a amplitude guardada ao reabrir o Calendula.</string>
<string name="agenda_range_showing_label">A mostrar todos os eventos nos próximos</string>
<plurals name="agenda_range_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="settings_section_views">Vistas</string>
<string name="settings_month_header">Vista do Mês</string>
<string name="settings_month_view_style">Estilo de vista do Mês</string>
<string name="month_style_paged">Páginas</string>
<string name="month_style_paged_summary">Um mês ocupa o ecrã. Deslize para os lados para mudar o mês.</string>
<string name="month_style_continuous">Meses contínuos</string>
<string name="month_style_continuous_summary">Cada mês tem o próprio cabeçalho, com um espaço a separá-lo do próximo.</string>
<string name="month_style_dense">Semanas integradas</string>
<string name="month_style_dense_summary">As semanas seguem sem separação, com cada mês a terminar no início do outro sem distinção entre eles.</string>
<string name="month_style_split">Dividido</string>
<string name="month_style_split_summary">Uma grelha compacta marca os dias com eventos, e o dia clicado é listado abaixo.</string>
<string name="month_split_no_events">Nada agendado</string>
<string name="month_split_expand">Mostrar o mês inteiro</string>
<string name="month_split_collapse">Mostrar os eventos do dia</string>
<string name="settings_quick_switch_header">Botão de troca rápida</string>
<string name="settings_quick_switch_hint">Escolha que vistas pelas quais o botão no canto superior direito circula, e arraste para as ordenar. Com menos de duas vistas ligadas o botão é escondido. As vistas desligadas continuam acessíveis pelo menu de navegação.</string>
<string name="settings_drawer_order_header">Menu de navegação</string>
<string name="settings_drawer_order_hint">Arraste para reordenar as vistas listadas no menu de navegação.</string>
<string name="reorder_drag_handle">Arraste para reordenar</string>
<string name="settings_section_event_form">Novo formulário de evento</string>
<string name="settings_form_fields_hint">Campos padrão — o restante está dentro de \"Mais campos\"</string>
<string name="settings_autofocus_title">Focar no título num novo evento</string>
<string name="settings_autofocus_title_hint">Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado.</string>
<string name="settings_event_duration">Duração padrão</string>
<string name="settings_event_duration_hint">Quanto tempo um novo evento dura até mudar o término. Os eventos de dia inteiro não são afetados.</string>
<string name="settings_calendar_durations_title">Duração por calendário</string>
<string name="settings_calendar_durations_hint">Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho.</string>
<string name="settings_calendar_duration_inherits">Padrão (%1$s)</string>
<string name="settings_calendar_duration_use_default">Usar duração padrão (%1$s)</string>
<string name="settings_color_unsupported">Permitir cores em calendários não suportados</string>
<string name="settings_color_unsupported_hint">Alguns calendários (ex.: certos CalDAV) não publicam cores; uma cor de evento personalizada pode ser apagada ou sobrescrita na próxima sincronização. Esta é uma limitação destes calendários, e não é algo que o Calendula pode arranjar.</string>
<string name="settings_section_notifications">Notificações</string>
<string name="settings_reminders">Lembretes de evento</string>
<string name="settings_reminders_hint">Vê lembretes em duplicado ? Outra aplicação de calendário está a enviá-los — desligue num dos dois.</string>
<string name="settings_default_reminder">Lembrete padrão</string>
<string name="settings_default_reminder_allday">Eventos de dia todo</string>
<string name="settings_allday_reminder_time">Hora de lembrete de evento de dia todo</string>
<string name="settings_allday_reminder_time_hint">Os lembretes para eventos de dia todo disparam às %1$s</string>
<string name="settings_allday_reminder_fires_at">Notifica às %1$s</string>
<string name="reminder_none">Nenhum</string>
<string name="reminder_use_default">Usar lembrete padrão</string>
<string name="reminder_custom_amount">Quantidade</string>
<string name="reminder_custom_set">Ajustar</string>
<string name="settings_calendar_reminders_title">Lembretes por calendário</string>
<string name="settings_calendar_reminders_hint">Mude o padrão por calendário — separadamente para eventos com horário e de dia todo. Um calendário pode manter o padrão, removê-lo, ou ajustar o seu próprio.</string>
<string name="settings_calendar_reminder_inherits">Padrão (%1$s)</string>
<string name="settings_reliable_delivery">Entrega confiável</string>
<string name="settings_reliable_delivery_hint">O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora.</string>
<string name="settings_reliable_delivery_exempt">Isentar de otimização de bateria — os lembretes chegam na hora.</string>
<string name="settings_snooze_duration">Duração da soneca</string>
<string name="settings_section_calendars">Calendários</string>
<string name="settings_manage_calendars">Gerir calendários</string>
<string name="settings_manage_calendars_hint">Crie calendários locais; gira os sincronizados</string>
<string name="settings_section_language">Idioma</string>
<string name="settings_language">Idioma da aplicação</string>
<string name="settings_language_auto">Padrão do sistema</string>
<string name="settings_translate">Ajude a traduzir</string>
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
<string name="settings_group_look">Aparência e comportamento</string>
<string name="settings_group_data">Dados</string>
<string name="settings_group_app">Aplicação</string>
<string name="settings_group_about">Sobre</string>
<string name="settings_appearance_subtitle">Tema, cores, fontes</string>
<string name="settings_views_subtitle">Vista, layout e ordem padrão</string>
<string name="settings_event_form_subtitle">Campos e comportamento padrão</string>
<string name="settings_notifications_subtitle">Lembretes e entrega</string>
<string name="settings_special_dates_subtitle">Aniversários e datas de contacto</string>
<string name="settings_widgets_subtitle">Widget de Agenda, bloco de Ajustes Rápidos</string>
<string name="settings_backup_subtitle">Exportar, importar, backup automático</string>
<string name="settings_section_widgets">Widgets e blocos</string>
<string name="settings_widgets_hint">Ajustes para os widgets do ecrã inicial e para o bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando o ecrã inicial.</string>
<string name="settings_section_backup">Backup e restauro</string>
<string name="settings_views_all_header">Todas as vistas</string>
<string name="settings_week_day_header">Semana e dia</string>
<string name="settings_default_view_hint">A vista que o Calendula abre ao iniciar.</string>
<string name="settings_week_start_hint">O dia em que cada semana inicia, em todas as vistas e widgets.</string>
<string name="settings_time_format_hint">Como as horas são escritas pela aplicação. \"Automático\" segue a configuração do sistema.</string>
<string name="settings_past_events_hint">O que a agenda faz com eventos que já terminaram.</string>
<string name="settings_dynamic_color_summary">Use as cores da aplicação de acordo com seu papel de parede.</string>
<string name="settings_section_special_dates">Datas especiais de contactos</string>
<string name="settings_special_dates_enable">Mostrar datas de contactos</string>
<string name="settings_special_dates_enable_hint">Copiar os aniversários e outras datas dos contactos para calendários locais. Lê os contatos somente neste aparelho — nada é enviado, e seus contatos nunca são alterados.</string>
<string name="settings_special_dates_type_birthday">Aniversários</string>
<string name="settings_special_dates_type_anniversary">Aniversários de relacionamentos</string>
<string name="settings_special_dates_type_custom">Outras datas</string>
<string name="settings_special_dates_template">Formato do título</string>
<string name="settings_special_dates_template_hint">Use {nome} para o contacto e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando desconhecido).</string>
</resources>
+11
View File
@@ -344,4 +344,15 @@
<string name="settings_week_start">Неделя начинается в</string>
<string name="settings_week_start_auto">Автоматически</string>
<string name="settings_week_start_auto_summary">Выбрано: %1$s</string>
<string name="state_failure_all_hidden_action">Управление календарями</string>
<string name="field_copy_action">Копировать</string>
<string name="field_copied">Скопировано в буфер обмена</string>
<string name="event_detail_title">Заголовок</string>
<string name="settings_week_numbers">Номер недели</string>
<string name="settings_app_name">Имя приложения</string>
<string name="settings_app_name_summary">Отображать Calendula как “Календарь” в вашем лаунчере. Изменяется только отображаемое имя в лаунчере; иконка может переместиться на новое место после включения.</string>
<string name="settings_time_format">Формат времени</string>
<string name="settings_time_format_auto">Автоматически</string>
<string name="settings_time_format_12h">12-часовой формат времени</string>
<string name="settings_time_format_24h">24-часовой формат времени</string>
</resources>
@@ -598,12 +598,9 @@
<string name="crash_dialog_message">%1$s 上次意外关闭。您可以将此报告作为问题发送以帮助修复。报告在您选择共享之前仅保留在设备上,不含任何个人数据或日历内容——仅包含以下技术细节。</string>
<string name="crash_dialog_report">报告</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_copied">报告已复制到剪贴板</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_anniversary">纪念日</string>
<string name="special_dates_calendar_custom">特殊日期</string>
+22 -6
View File
@@ -14,8 +14,15 @@
<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_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_action">Manage calendars</string>
<string name="state_failure_provider">Could not read the calendar.</string>
<!-- Long-press a field to copy it (#195) -->
<string name="field_copy_action">Copy</string>
<string name="field_copied">Copied to clipboard</string>
<string name="field_copy_failed">Couldn\'t copy that</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
@@ -188,6 +195,7 @@
<string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_title">Title</string>
<string name="event_detail_location">Location</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string>
@@ -425,8 +433,12 @@
<string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<!-- Reworded in v2.20 (#113): this setting no longer draws a separator line
at each hour, it seats each hour in its own cell. The key is kept, so any
translation of the old wording ("Hour lines" / "show a separator line at
each hour") describes a feature that no longer exists and needs redoing. -->
<string name="settings_hour_lines">Hour grid</string>
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
<string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string>
<string name="timeline_scale_fit_day">Fit whole day</string>
@@ -498,7 +510,7 @@
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>
@@ -610,6 +622,8 @@
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_licences">Open source licenses</string>
<string name="settings_licences_subtitle">The projects Calendula is built on</string>
<string name="settings_about_privacy">Privacy policy</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
@@ -617,6 +631,11 @@
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
<!-- Open source licences -->
<string name="licences_title">Open source licenses</string>
<string name="licences_intro">Calendula includes the work below. Tap any entry to get its source code.</string>
<string name="licences_footer">Every project here is included unmodified, under the license shown.</string>
<!-- Calendar manager -->
<string name="calendars_title">Calendars</string>
<string name="calendars_local_header">Your calendars</string>
@@ -755,12 +774,9 @@
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
<string name="crash_dialog_report">Report</string>
<string name="crash_dialog_dismiss">Not now</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="crash_report_clip_label">%1$s crash report</string>
<string name="crash_report_copied">Report copied to your clipboard</string>
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlin.time.Instant
import org.junit.jupiter.api.Test
@@ -109,4 +111,22 @@ class InstanceMapperTest {
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
}
}
@Test
fun `an unanswered invitation reads as invited, an answered one does not`() {
assertThat(
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED)
.toEventInstance()!!.response,
).isEqualTo(EventResponse.Invited)
mapOf(
CalendarContract.Attendees.ATTENDEE_STATUS_NONE to EventResponse.Going,
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED to EventResponse.Going,
// "Maybe" is still an answer, so it keeps the solid chip (#230).
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE to EventResponse.Going,
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED to EventResponse.Declined,
).forEach { (status, expected) ->
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.response)
.isEqualTo(expected)
}
}
}
@@ -95,9 +95,9 @@ class SettingsPrefsTest {
@Test
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showHourLines.first()).isFalse()
prefs.setShowHourLines(true)
assertThat(prefs.showHourLines.first()).isTrue()
assertThat(prefs.showHourGrid.first()).isFalse()
prefs.setShowHourGrid(true)
assertThat(prefs.showHourGrid.first()).isTrue()
}
@Test
@@ -121,4 +121,24 @@ class CalendarVisibilityPlanTest {
),
).isTrue()
}
@Test
fun `no calendars on the device is its own failure`() {
assertThat(calendarListFailure(emptyList()))
.isEqualTo(FailureReason.NoCalendarsConfigured)
}
@Test
fun `calendars that are all switched off read as hidden, not missing`() {
// The remedy differs: one needs a calendar made, the other needs one
// switched back on (#239).
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = false))))
.isEqualTo(FailureReason.AllCalendarsHidden)
}
@Test
fun `one visible calendar is enough to show something`() {
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = true))))
.isNull()
}
}
@@ -36,11 +36,12 @@ class ModelsTest {
}
@Test
fun `FailureReason enum has all five variants`() {
fun `FailureReason enum has all six variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo(
setOf(
FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured,
FailureReason.AllCalendarsHidden,
FailureReason.ProviderUnavailable,
FailureReason.EventNotFound,
FailureReason.Unknown,
@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppBarSpacingTest {
@Test
fun `the pill's background ends one inset from the screen edge`() {
// The padding is applied inside M3's actions row, so it carries that
// row's own inset on top of whatever the pill asks for (#165).
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
}
@Test
fun `trailing insets stay non-negative`() {
// Modifier.padding throws on a negative value.
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
}
@Test
fun `an icon button's glyph ends further in than a container's edge`() {
assertThat(AppBarSpacing.IconTrailingInset.value)
.isGreaterThan(AppBarSpacing.Inset.value)
}
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarTitleButtonTest {
@Test
fun `keeps the full title when it fits`() {
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `keeps the full title when it fills the slot exactly`() {
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `falls back to the short title when the full one overflows`() {
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Sep")
}
@Test
fun `a caller with nothing to shorten still gets its own title back`() {
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Wed, 2 Sep")
}
}
@@ -0,0 +1,80 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The floating copy is the size of the block it lifted off, hour grid or not
* a copy a half-gap taller at each end has room for a time label the block
* itself had to drop, so the label appears on long-press and goes on drop
* (#267).
*/
class DragPieceBoundsTest {
/** A Regular 56dp hour on a 3x screen. */
private val hourPx = 168f
/** The grid's half-gap, 1dp at 3x. */
private val insetPx = 3f
private fun slice(
startMin: Int,
spanMin: Int,
continuesBefore: Boolean = false,
continuesAfter: Boolean = false,
) = DragSlice(
dayOffset = 0,
startMin = startMin,
spanMin = spanMin,
continuesBefore = continuesBefore,
continuesAfter = continuesAfter,
)
@Test
fun `with the grid off a piece keeps its raw placement`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx = 0f)
assertThat(bounds.top).isEqualTo(9 * hourPx)
assertThat(bounds.height).isEqualTo(hourPx)
}
@Test
fun `with the grid on a piece gives up a half-gap at each end`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx)
assertThat(bounds.top).isEqualTo(9 * hourPx + insetPx)
assertThat(bounds.height).isEqualTo(hourPx - insetPx * 2)
}
@Test
fun `an end cut at midnight keeps none of the inset`() {
val head = dragPieceBounds(slice(startMin = 1200, spanMin = 240, continuesAfter = true), hourPx, insetPx)
assertThat(head.top).isEqualTo(20 * hourPx + insetPx)
assertThat(head.height).isEqualTo(4 * hourPx - insetPx)
val tail = dragPieceBounds(slice(startMin = 0, spanMin = 480, continuesBefore = true), hourPx, insetPx)
assertThat(tail.top).isEqualTo(0f)
assertThat(tail.height).isEqualTo(8 * hourPx - insetPx)
}
@Test
fun `a whole day cut at both ends fills its column`() {
val bounds = dragPieceBounds(
slice(startMin = 0, spanMin = 1440, continuesBefore = true, continuesAfter = true),
hourPx,
insetPx,
)
assertThat(bounds.top).isEqualTo(0f)
assertThat(bounds.height).isEqualTo(24 * hourPx)
}
@Test
fun `a very short piece is floored before the inset comes off`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 5), hourPx, insetPx)
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
}
@Test
fun `an inset taller than the piece leaves no height at all`() {
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
assertThat(bounds.height).isEqualTo(0f)
}
}
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** How an event's time is set against its title (#219). */
class EventTimeStyleTest {
@Test
fun `a time steps down to regular and closes its tracking up`() {
val label = TextStyle(fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp)
val time = label.asEventTime()
assertThat(time.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(time.letterSpacing).isEqualTo(0.sp)
}
@Test
fun `nothing else of the style is touched`() {
val label = TextStyle(
fontSize = 11.sp,
color = Color.Red,
textDecoration = TextDecoration.LineThrough,
)
val time = label.asEventTime()
assertThat(time.fontSize).isEqualTo(11.sp)
assertThat(time.color).isEqualTo(Color.Red)
assertThat(time.textDecoration).isEqualTo(TextDecoration.LineThrough)
}
@Test
fun `the title is inked above the time beside it`() {
assertThat(TITLE_INK_ALPHA).isGreaterThan(SECONDARY_INK_ALPHA)
}
@Test
fun `the time is set apart from the title it precedes`() {
val label = inlineTimeLabel("09:05", "Standup", Color.Black)
assertThat(label.text).isEqualTo("09:05 Standup")
// Only the time is restyled: the title keeps the surface's own label
// style, so the two read as separate things on one line (#219).
val spans = label.spanStyles
assertThat(spans).hasSize(1)
assertThat(spans.single().start).isEqualTo(0)
assertThat(spans.single().end).isEqualTo("09:05".length)
assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(spans.single().item.letterSpacing).isEqualTo(0.sp)
assertThat(spans.single().item.color).isEqualTo(Color.Black)
}
@Test
fun `a chip without a time is styled title and nothing else`() {
val label = inlineTimeLabel(null, "Standup", Color.Black)
assertThat(label.text).isEqualTo("Standup")
assertThat(label.spanStyles).isEmpty()
}
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.text.style.TextOverflow
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventTitleOverflowTest {
@Test
fun `a single LTR line clips, so the title runs to the chip's edge`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
// Load-bearing: with softWrap on, a clipped line breaks at the last
// whole word and shows less title than the ellipsis did (#164).
assertThat(result.softWrap).isFalse()
}
@Test
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
// Clipping with softWrap off cuts at the node's left edge, which in RTL
// is the end of the string — the title would lose its beginning.
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `a multi-line block wraps whole words and clips, spending none on a dot`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL clips too, since softWrap cuts the logical end`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HourGridTest {
/** The hour cell's 4dp corner at a 3x density. */
private val maxRadius = 12f
@Test
fun `a tall cell rounds to the full radius`() {
// A Regular 56dp hour on a 3x screen, in a phone-width day column.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(maxRadius)
}
@Test
fun `a short cell rounds to half its height, never past a lozenge`() {
// Fit-the-day on a small viewport: the hour is shorter than the radius.
val radius = hourCellRadiusPx(cellHeight = 18f, cellWidth = 900f, maxRadius = maxRadius)
assertThat(radius).isEqualTo(9f)
}
@Test
fun `a narrow cell rounds to half its width`() {
// Seven columns on a small phone, so the cell is narrower than it is tall.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 18f, maxRadius = maxRadius))
.isEqualTo(9f)
}
@Test
fun `a degenerate cell asks for no radius at all`() {
assertThat(hourCellRadiusPx(cellHeight = -4f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(0f)
}
@Test
fun `a seated block gives up the half-gap at an end of its own`() {
assertThat(hourCellBlockInset(show = true, cut = false)).isEqualTo(HOUR_CELL_INSET)
}
@Test
fun `an end cut at midnight stays against the column edge`() {
assertThat(hourCellBlockInset(show = true, cut = true)).isEqualTo(0.dp)
}
@Test
fun `with the grid off nothing is given up`() {
assertThat(hourCellBlockInset(show = false, cut = false)).isEqualTo(0.dp)
}
}
@@ -120,4 +120,30 @@ class ViewBackStackTest {
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `a cycle can be emptied down to no views at all`() {
// #150: the settings screen no longer holds a floor, so every view may go.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
assertThat(config.cycle).isEmpty()
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `one enabled view is below the cycle minimum, so the pill hides`() {
// A single target is not a switch — it hides rather than becoming a
// jump-to-one-view button, which would be dead once you were there.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
assertThat(config.cycle).containsExactly(CalendarView.Day)
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `two enabled views are enough to show the pill`() {
val config = QuickSwitchConfig(
order = IMPLEMENTED_VIEWS,
enabled = setOf(CalendarView.Day, CalendarView.Month),
)
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
}
}
@@ -0,0 +1,72 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import java.util.Locale
/** Which month chips carry a start time, and how it reads (#219).
* How it is *set* against the title lives in `EventTimeStyleTest`. */
class MonthChipTimeTest {
private val zone = TimeZone.UTC
private val locale = Locale.UK
private val day = LocalDate(2026, Month.SEPTEMBER, 2)
private fun timed(hour: Int, minute: Int) = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Standup",
start = day.atTime(hour, minute).toInstant(zone),
end = day.atTime(hour + 1, minute).toInstant(zone),
isAllDay = false,
color = 0,
location = null,
)
private fun allDay() = EventInstance(
instanceId = 2L,
eventId = 2L,
calendarId = 1L,
title = "Holiday",
start = day.atTime(0, 0).toInstant(zone),
end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0,
location = null,
)
@Test
fun `a timed chip shows its start in the 24-hour convention`() {
val time = monthChipTime(timed(9, 5), continuesLeft = false, zone, is24Hour = true, locale)
assertThat(time).isEqualTo("09:05")
}
@Test
fun `the 12-hour setting is honoured`() {
val time = monthChipTime(timed(14, 30), continuesLeft = false, zone, is24Hour = false, locale)
assertThat(time).isEqualTo("2:30 pm")
}
@Test
fun `an all-day chip never shows a time`() {
val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)
assertThat(time).isNull()
}
@Test
fun `a bar carried in from the previous week shows none either`() {
// Its start is not in this segment, so printing it would put a time on a
// row the event does not begin on.
val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale)
assertThat(time).isNull()
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -267,4 +268,70 @@ class MonthLayoutTest {
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
assertThat(byDay.values.flatten()).isEmpty()
}
@Test
fun `a declined event sorts behind every answered one on its day`() {
val day = LocalDate(2026, 6, 10)
// Earliest of the three, and all-day on top of that, so only the
// declined ordering can put it last.
val declined = allDay(day, id = 1L, title = "Declined")
.copy(response = EventResponse.Declined)
val morning = timed(day, 9, 10, id = 2L, title = "Morning")
val evening = timed(day, 18, 19, id = 3L, title = "Evening")
val events = listOf(declined, morning, evening)
assertThat(instancesByDay(listOf(day), events, zone).getValue(day).map { it.title })
.containsExactly("Morning", "Evening", "Declined").inOrder()
val timedOnly =
layoutCalendarWeek(weekOf8th, listOf(evening, morning, declinedTimed(day)), zone)
assertThat(timedOnly.timedByDay.getValue(day).map { it.title })
.containsExactly("Morning", "Evening", "Declined early").inOrder()
}
@Test
fun `a declined bar packed last still shares a lane it does not overlap`() {
// Sorting declined last means this bar is seated after one that reaches
// further right than it starts. A lane must still be offered on the
// columns it is actually free on, or the row grows a wasted rank.
val mon = weekOf8th[0]
val declined = allDay(mon, id = 1L, title = "Declined")
.copy(response = EventResponse.Declined)
val going = allDay(weekOf8th[2], weekOf8th[3], id = 2L, title = "Going")
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
assertThat(week.spans.map { it.lane }).containsExactly(0, 0)
}
@Test
fun `a declined all-day bar takes a lane below the ones you answered`() {
val day = LocalDate(2026, 6, 10)
val declined =
allDay(day, id = 1L, title = "Declined").copy(response = EventResponse.Declined)
val going = allDay(day, id = 2L, title = "Going")
// Declined listed first: only the sort, not the input order, may decide.
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
val lanes = week.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("Going")).isLessThan(lanes.getValue("Declined"))
}
@Test
fun `overlapping bars never share a lane, whatever order they arrive in`() {
val a = allDay(weekOf8th[0], weekOf8th[3], id = 1L, title = "A")
val b = allDay(weekOf8th[2], weekOf8th[5], id = 2L, title = "B")
val c = allDay(weekOf8th[3], weekOf8th[4], id = 3L, title = "C")
.copy(response = EventResponse.Declined)
val lanes = layoutCalendarWeek(weekOf8th, listOf(c, b, a), zone)
.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("B"))
assertThat(lanes.getValue("B")).isNotEqualTo(lanes.getValue("C"))
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("C"))
}
/** A declined timed event starting before every other one in its test. */
private fun declinedTimed(date: LocalDate) =
timed(date, 7, 8, id = 4L, title = "Declined early")
.copy(response = EventResponse.Declined)
}
+117
View File
@@ -0,0 +1,117 @@
---
title: Privacy Policy — Calendula
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
updated: 2026-07-28
---
<!--
THIS FILE IS THE POLICY. Edit it here, in a PR, reviewed like any other
change — it is the source of truth.
It is published at https://jeanlucmakiola.de/calendula/privacy, which is what
the app's Settings → About → Privacy policy row opens and what the Play
Console field must hold. That page holds no copy of the prose: the website
build checks this repository out beside itself and renders this file through
an Astro content collection, so there is exactly one copy of the policy
anywhere and the two cannot drift. An edit here reaches the live page on the
site's next build.
The page supplies its own <h1> from the `title` above, so this body starts at
the first section — do not add one. This comment is HTML so that it cannot
render on the published page.
-->
**Last updated:** 28 July 2026
Applies to the Android app **Calendula** (package `de.jeanlucmakiola.calendula`), all versions and all distribution channels.
## In short
Calendula collects nothing, sends nothing, and has no user accounts. It has **no internet permission at all** — the app is technically incapable of transmitting your data anywhere. Everything it shows you is read from the calendars that already exist on your device.
## 1. Controller
IT-Dienstleister | Jean-Luc Makiola
Mahlerstraße 10
14772 Brandenburg an der Havel
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
## 2. No data collection
Calendula contains **no analytics, no tracking, no advertising, no crash-reporting SDK and no third-party services of any kind**. No user profile is created, no identifier is generated, and no data is shared with or sold to anyone.
The app does not request the `android.permission.INTERNET` permission. Without it, Android prevents the app from opening any network connection. This is verifiable: inspect the permission list of any released APK, or read the [source code](https://codeberg.org/jlmakiola/calendula).
## 3. Data Calendula accesses on your device
All of the following is processed **locally on your device only**. None of it is transmitted, and none of it is stored by the developer.
### Calendar data
Calendula is a viewer and editor for the calendars Android already manages. It reads and writes events, reminders and calendar settings through Android's system calendar provider. The app keeps **no database of its own** — your events live in the system calendar, exactly where they lived before you installed Calendula, and they remain there if you uninstall it.
Note: if one of those system calendars is itself synchronised with an online account (for example a Google account, or a CalDAV server via DAVx5), that synchronisation is performed by Android and that other app — not by Calendula. The privacy policy of the respective provider applies to it.
### Contacts (optional)
The “Contact special dates” feature reads birthdays and anniversaries from your contacts and mirrors them one-way into a local calendar, so they appear alongside your other events. This feature is switched off by default, the contacts permission is requested only when you enable it, and it is never requested at startup. Contacts are only ever read, never modified, and the data does not leave your device.
### Notifications
Reminders are displayed as local notifications on your device. Nothing is sent to a push service.
### Files
When you import or export an ICS file, Calendula reads or writes exactly the file you select through Android's system file picker. The optional automatic backup writes an ICS export to the folder you choose. The app has no access to other files.
### App settings
Your preferences (view options, theme, reminder defaults and similar) are stored locally on your device and are removed when you uninstall the app.
## 4. Crash reports — the only case where data can leave your device
If Calendula crashes, it offers to report the problem. Nothing is sent automatically. The report is copied to your clipboard and your browser is opened with the project's issue tracker, the text pre-filled. **You see the full content, you decide whether to submit it, and you can edit or discard it.**
Such a report contains:
- app version,
- Android version,
- device manufacturer and model,
- your device language,
- the timestamp,
- and the technical stack trace.
It contains **no** event data, **no** contacts and **no** personal identifiers.
If you choose to submit it, the report becomes a public issue on the project's issue tracker at Codeberg, operated by Codeberg e. V. Their privacy policy then applies to that submission.
## 5. External links
The settings screen contains links to the source code, the licence, the issue tracker and a voluntary donation page (Ko-fi). Following one of these links opens your browser and leaves the app; the privacy policy of the respective website then applies. Calendula transmits no data of yours in the process — it only opens the address.
## 6. Permissions and why they exist
- `READ_CALENDAR`, `WRITE_CALENDAR` — display and edit your events; the core function.
- `POST_NOTIFICATIONS` — show reminders.
- `READ_CONTACTS` — optional, only for the “Contact special dates” feature.
- `USE_EXACT_ALARM`, `SCHEDULE_EXACT_ALARM` — deliver reminders at the exact time, including after snoozing.
- `RECEIVE_BOOT_COMPLETED` — re-register pending reminders after a restart.
- `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — only to open the system dialog for the “Reliable delivery” setting.
- `WAKE_LOCK`, `FOREGROUND_SERVICE`, `ACCESS_NETWORK_STATE` — required by the Android system component used for scheduled background work (WorkManager).
`ACCESS_NETWORK_STATE` allows reading *whether* a network connection exists — it does **not** permit using one. Without `INTERNET`, no connection is possible.
## 7. Distribution channels
Calendula is distributed via F-Droid, Obtainium, a self-hosted repository and, where applicable, the Google Play Store. When you download or update the app, the operator of that channel processes data (such as your IP address) under their own privacy policy. This is outside the developer's control and unrelated to the app's own behaviour.
## 8. Children
Calendula is suitable for all ages. Since it collects no data at all, no data of children is processed either.
## 9. Your rights
Because the developer processes no personal data of yours, there is no stored data to which rights of access, rectification, erasure, restriction, data portability or objection (Art. 1521 GDPR) could apply. You may nevertheless contact the address above at any time with any question. You also have the right to lodge a complaint with a supervisory authority.
## 10. Changes to this policy
Should the app's functionality change in a way that affects data processing, this policy will be updated and the date at the top adjusted. The version history is publicly traceable in the project's source repository.
+9
View File
@@ -54,6 +54,15 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
solely when the file is missing, so the self-hosted pipeline always has
something to publish. The pipeline runs the same script, so forgetting to
write the file yields a long auto-generated changelog rather than none.
To check every locale rather than just en-US, run
```bash
scripts/check_changelog_lengths.sh
```
It fails on any file for the current `versionCode` that is missing, empty or
over the limit, and only warns about older ones (already published; Play
never re-reads them). CI runs it on every pull request. `--strict` fails on
the older files too.
4. **Verify the release build on a real device** — the mandatory gate. The
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
only on first run, never show up in the debug build or on a device that
@@ -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,14 @@
New
• Long-press an event's title, location or description to copy.
• Settings → About lists the open-source projects we ship.
Changed
• Week & day: each hour sits in its own cell, not a line.
• Event titles run to the chip's edge — no more "…".
• Month events show their start time on wide chips.
• The today button shows today's date.
• Unanswered invitations are drawn as outlines.
Fixed
• A long location wraps instead of scrolling out of view.
• The event form capitalizes sentences again.
@@ -0,0 +1,11 @@
New
• Long-press an event's title, location or description to copy.
• Settings → About lists the open-source projects we ship.
Changed
• Week & day: each hour sits in its own cell, not a line.
• Unanswered invitations are drawn as outlines.
Fixed
• "No calendars configured" now really opens the system calendar setup.
• When every calendar is switched off, the app says so instead of a blank month.
@@ -0,0 +1,2 @@
Fixed
• Crash reports are now filed in English. On a phone set to another language, the prefilled issue's title and text came out in that language, which nobody on the project could read. What gets posted is English for everyone; the dialog asking you to send it stays in your language.
@@ -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 @@
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 @@
Исправлено
• Свой диапазон в Расписании больше не приводит к сбою на французском языке. Выбор своего диапазона — или даже простой ввод числа в это поле — закрывал приложение на любом телефоне с французским языком; уцелел только диапазон ровно в один день. Другие языки это никогда не затрагивало.

Some files were not shown because too many files have changed in this diff Show More