Compare commits

...

66 Commits

Author SHA1 Message Date
bb34973047 Merge pull request 'Release 2.16.0' (!95) from release/v2.16.0 into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m59s
Reviewed-on: #95
2026-07-24 20:00:13 +00:00
108a1890be docs(changelog): cut 2.16.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m49s
Consolidate the release under a single 2.16.0 heading dated today and fold
in the work that had accumulated in Unreleased. Add the entries the log had
been missing as PRs merged: app-name toggle (#44), the Arabic/French/Italian
translations, the custom recurrence picker redesign + end-date fix (#42),
multi-day agenda events (#83), and dark-theme picker bar icons (#70).
Regenerate the fastlane per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:47:44 +02:00
521c0ffd78 i18n: offer Arabic in the language picker
The Weblate merge added a values-ar translation (~50% complete, RTL —
the app already declares supportsRtl). Add the matching <locale> line so
Arabic is selectable in both the in-app picker and the system per-app
language settings. Russian and Portuguese stay out for now: at 12% and
<1% they're below the usable bar (the lowest currently-offered language,
zh-CN, sits at ~24%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:20:07 +02:00
33a37d4105 Merge remote-tracking branch 'origin/main' into release/v2.16.0 2026-07-24 18:17:23 +02:00
6ccc0cbed2 Merge pull request 'Translations update from Weblate' (!91) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:15:27 +00:00
Weblate
6e6ffce271 Update translation files
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m28s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
23386bb998 Translated using Weblate (Arabic)
Currently translated at 52.4% (229 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Xavier
28b1568486 Translated using Weblate (French)
Currently translated at 91.0% (398 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-24 16:09:17 +00:00
Sup99999
5127971a37 Translated using Weblate (Arabic)
Currently translated at 32.7% (143 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Weblate
0562af8d66 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
e3d3728bbd Added translation using Weblate (Arabic) 2026-07-24 16:09:17 +00:00
Weblate
8cc9d075fd Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
Sam Gamgee
ede6d967c1 Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
c30f6ab318 Merge pull request 'build(lint): don't fail the build on partial community plurals' (!94) from fix/lint-partial-plurals into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:09:12 +00:00
dd2b96b5fd build(lint): don't fail the build on partial community plurals
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m49s
A Weblate translation of a <plurals> may not fill every CLDR quantity
form its locale defines — e.g. Arabic's import_reminder_prompt_body_existing
omits the "zero" form — which lint flags fatally as MissingQuantity and
aborts the CI build. The missing form falls back to "other" at runtime, so
this is the same partial-translation case we already accept for
MissingTranslation; downgrade MissingQuantity to informational alongside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:51 +02:00
c22658db4c Merge pull request 'fix(intents): show Calendula under "Add to calendar" from other apps (#74)' (!93) from fix/insert-event-intent-filters into release/v2.16.0
Reviewed-on: #93
2026-07-24 15:46:13 +00:00
7b3893ddc6 feat(intents): open .ics/.vcs handed over as application/octet-stream (#74)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m43s
Mail clients, browsers and file managers frequently label a calendar
attachment as a generic download (application/octet-stream) rather than
text/calendar, so the MIME-typed VIEW filter missed them. Add a dedicated
filter that matches those by .ics/.vcs extension via pathPattern — kept
separate so the path constraint can't narrow the reliable MIME-typed filter.
The import handler already ignores the declared MIME, so a let-through file
imports normally. Best-effort: reliable for file:// and content:// whose path
carries the name; nameless content:// URIs still fall back to the MIME filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:40:27 +02:00
9e28fa4981 fix(intents): accept item-typed INSERT / INSERT_OR_EDIT for "Add to calendar" (#74)
Other apps' "Add to calendar" / "Save to calendar" actions commonly fire the
canonical insert intent — ACTION_INSERT with setType("vnd.android.cursor.item/
event") — the singular *item* MIME type (Android's own docs example; used by
e.g. DB Navigator). Calendula advertised INSERT only on the *dir* MIME type, so
it never matched: absent from the chooser, and a silent no-op when it was the
only calendar app installed.

Add the item-typed INSERT to the events filter, plus ACTION_INSERT_OR_EDIT (the
third "add to calendar" action AOSP and Google Calendar register). The runtime
parser already treats any ACTION_INSERT as create; teach insertFormOrNull /
editEventKeyOrNull about INSERT_OR_EDIT so an id-less one is a create and an
id-carrying one opens the edit form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:37:59 +02:00
7549c8fe04 Merge pull request 'fix(pickers): keep full-screen picker bar icons theme-matched (#70)' (!92) from fix/picker-status-bar-icons into release/v2.16.0
Reviewed-on: #92
2026-07-24 15:26:24 +00:00
75f699b714 chore(floret-kit): re-point submodule to merged main (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 2m32s
floret-kit#3 merged to main; move the pointer from the fix branch commit
to the main merge commit. No code change — same tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:24:57 +02:00
3e821c9092 fix(pickers): keep full-screen picker bar icons theme-matched (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m26s
Bump floret-kit to the fix that drives each picker Dialog window's
status/navigation-bar icon appearance from the runtime theme. The picker
Dialog owns its own Window, seeded from the XML theme rather than the
activity's runtime edge-to-edge state, so in dark theme the status-bar
icons went black and near-invisible on the dark picker (Codeberg #70).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:12:29 +02:00
7602f158a3 Merge pull request 'feat(month): month view style picker + Split pull-to-expand (#38, #53)' (!90) from feat/month-view-style into release/v2.16.0
Reviewed-on: #90
2026-07-20 21:25:49 +00:00
052335e842 fix(month): carry the scroll position across a style change (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m54s
The scrolling styles navigate by scroll position and never set the paged
month, so it sat wherever paged navigation last left it — today's month
on a fresh open. Switching to a paged style, or reseeding the other
scrolling style's list state (both read off it), snapped back there
instead of holding the month on screen.

Track the paged month to the visible month while a scrolling style is
up, realigning the split selection alongside it so landing on Split
shows a live day in the month you were viewing rather than a stale,
off-month one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:20:34 +02:00
28fb7404d8 fix(month): point the split slide the way the month moves (#53)
Tapping a greyed leading/trailing day in the split grid follows the
selection to that day's own month, but selectDate never set the slide
direction — so the incoming page reused whatever the last swipe left in
slideDir and could travel backwards while navigation moved forwards.

Wrap selectDate so a tap that crosses months points slideDir at the
month it is actually heading to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:20:21 +02:00
6ab235e1d8 refactor(month): make the style picker read by looking, not comparing (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m35s
The chooser showed a per-style blurb on every row and a generic feature
summary under the preview. Move the selected style's own blurb under its
preview instead, drop the generic summary, and reduce the rows to a single
line — the preview is now bigger (280dp, wider) in the space that frees up.

The blurbs now stand alone: Dense's copy referenced Continuous ("the same
endless scroll…"), which made no sense once each shows in isolation. Rename
the two vertical styles to say what they do — Continuous → "Scrolling months",
Dense → "Seamless weeks".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:55:40 +02:00
fb90c94e37 fix(month): let the selection outline travel with its cell (#53)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m44s
The outline was the last piece with nothing to morph against, so it could
only fade in place while everything around it moved. The expanded grid
draws no outline by design — once the cells carry real event bars it is one
mark too many — which left it partnerless.

Same answer as the multi-day dots: give it an invisible stand-in over the
same cell in the expanded grid. It draws nothing and exists only to be the
other end of the morph, so the outline now rides its cell between the two
layouts.

Tagged only while it is the selected day. Tagged unconditionally it would
put a key on all 42 cells against the single partner on offer, and the
other 41 would fly in from the layout origin — the failure the dots just
had. The stand-in carries the same padding as the outline's own bounds, or
it would arrive at the wrong size, and it sits in its own layer rather than
inside the cell pill, which is itself tagged and would make it travel twice.

MonthGrid takes `selected` again, but for an anchor rather than a mark; the
other styles pass nothing and compose no extra layer at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:19:19 +02:00
7a05fc3ac1 fix(month): give every covered day's dot a place inside the bar (#53)
A multi-day event has a dot on every day it covers but only one bar, so the
dots away from its start had no counterpart. Untagged they stood still and
faded; tagged without a partner they flew in from the top of the grid.
Neither is an animation — the last commit swapped one for the other.

The expanded grid now places an invisible slice of the bar in each further
column it spans, keyed to that column's day. Every dot has a real place to
come out of and go back into, at its own column, so it drops out of the bar
above it instead of appearing from nowhere.

That makes the seating's anchored flag pointless again — every dot has a
partner now — so it and its tests come back out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:06:22 +02:00
bf38dac5d7 fix(month): only tag the dot that has a bar to become (#53)
Dots on the middle of a multi-day run flew in from the top of the grid
instead of fading where they stand.

They were meant to be left unmatched — a bar is drawn once per row, from
the column it starts in, so only that day's dot has a counterpart. But
"unmatched" was implemented as tagged-and-unmatched, which is a different
thing: a shared element entering with no partner has no bounds to start
from, so it animates in from the layout origin. The top.

Seating now says which day a bar is actually drawn from, and only that dot
carries a tag. The rest are plain content and fade in place, which is what
the comment claimed they did all along.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:51:45 +02:00
1e9581528e fix(month): stop the row clip swallowing marks in mid-flight (#53)
Frame-by-frame, the marks were not fading in late — they were invisible
for the first half of their journey and then appearing already near the
destination, row by row from the top down.

A mark's two homes are in different rows: the dot for the 20th sits a third
of the way down the compact grid, its bar most of the way down the expanded
one. The events box clips to its own row, so a mark arriving there is out
of bounds — and undrawn — until it crosses in. The lower the row, the
further it travels and the later it showed, which is exactly the order the
capture shows them appearing in.

Rendering in place rather than in an overlay is what subjects marks to that
clip, and that is worth keeping — it is what puts the whole grid in one
z-order. So the clip yields instead, and only while pieces are in flight;
at rest it still stops a bar spilling into the row below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:40:46 +02:00
279872aa3b fix(month): morph in place instead of over the top of the grid (#53)
Travelling pieces were painted into an overlay above the entire tree —
Compose's default, and right for a thumbnail flying across a screen, wrong
for a grid changing shape in place. Everything tagged left the tree's
z-order, so every untagged neighbour spent the transition buried under it.

Lifting each of those out in turn — the selection outline, then both "+N"
markers — fixed the burying and bought something worse: they floated over
the grid on a layer of their own, out of step with what they belong to.
Three pieces needing the same escape hatch was the tell that the overlay
itself was wrong here.

Nothing renders in the overlay now. One z-order, one clip, so the grid
reads as a single surface changing shape rather than a stack of pieces
sliding past each other. The outline goes back to being plain content that
sits above its own pill the ordinary way, and morphOverlay is gone.

The "+N" markers become a matched pair instead of an orphan. The compact
grid writes the count as text and the expanded one as a row of dots, but
they stand for the same events on exactly the same days — both seat three
and overflow the rest — so they were always two halves of one marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:09:25 +02:00
ff6c4b6a4d fix(month): let the event marks actually grow, and lift the "+N" out (#53)
Two reasons a mark arrived a beat late instead of animating.

The dots and bars carried morphBounds *inside* their own size modifiers, so
the shared bounds had nothing to drive: the dot stayed pinned at 5dp however
far its bar had travelled, and the bar sat at full width from the first
frame, each snapping into place only once the transition ended. The growth
was being clamped away. morphBounds now sits outside the width/height on
both sides. A bar's offset stays outside it — that is where the bar sits,
not how big it is.

The "+N" overflow markers were untagged, so they spent every transition
painted over by the overlay the tagged pieces draw into, exactly as the
selection outline did. Both the compact grid's and the expanded grid's are
now lifted into it.

The outline itself moves to the fast effects spec, as asked: it is a small
mark that only ever appears or disappears, and at the shared pace it
lingered after the thing it marks had already moved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:55:30 +02:00
7fb01ab8d1 fix(month): fade the selection outline with the page it belongs to (#53)
Sending the outline to the shared-transition overlay stopped it hiding
behind its own cell, but it also lifted it out of the layer AnimatedContent
fades the outgoing page with — so it stopped fading at all. It stood at
full strength for the whole expand or collapse and then blinked out at the
end, which is the opposite half of the same bug.

The fade is reapplied on the lifted content's own layer, so it travels with
it. That belongs in morphOverlay rather than at the call site: anything
sent to the overlay loses the parent's fade the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:43:21 +02:00
bdb16d069e fix(month): drag from the handle, and stop the outline hiding behind its cell (#53)
The handle advertised a gesture it didn't answer to — the drag lived on the
grid alone, so the one part of the screen that says "pull me" was the one
part that ignored being pulled. Grid and handle now drag as a single
surface; the pane stays outside it, since it scrolls and is full of
tappable rows. Tapping the handle still toggles: a parent drag and a child
clickable coexist the same way they do in any scrollable list.

The selection outline was still flickering through an expand or collapse,
and being untagged was not enough to explain it. Tagged pieces paint into
an overlay above the whole regular tree, so the outline — correctly left in
that tree, to keep the collapsed cell's bounds — spent every transition
hidden behind its own morphing pill and reappeared the moment it ended.
It now renders into that same overlay while keeping its own bounds, which
is what the untagged layout was for in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:34:56 +02:00
beb8536e8a feat(calendar): commit page swipes on the threshold, not on release (#53)
The split style's pull-to-expand fires the moment the drag clears its
threshold, and that turned out to be the better feel — so paging now does
the same everywhere: month, week and day.

Waiting for the lift meant the page sat still under a finger that had
already travelled far enough to ask for it, and the answer only arrived
once you let go, which reads as the view being slow rather than as a
deliberate commit. The trade is that a drag can no longer be taken back by
dragging the other way; once you have moved 24dp deliberately you meant it,
and the page you left is one swipe back.

The three views had three copies of the same detector, so this extracts one
rememberCalendarPageSwipe and the threshold constant beside it. It stays
horizontal-only on purpose: the week and day timelines scroll vertically
underneath, and a two-dimensional detector would claim those drags before
the inner scroll saw them. The month keeps its own axis-locking detector
because the split style needs the vertical axis, and now shares the
threshold.

Also fixes the selection outline shivering rather than fading. Its animated
alpha was read while composing the cell, so every frame of the fade
recomposed all 42 cells at once — the fade was driving the whole grid
through recomposition to change one colour. It is now held as a State and
read inside a drawBehind, which keeps it in the draw phase where it touches
nothing but pixels. That means drawing the rounded rect by hand instead of
Modifier.border, so the corner radius is named alongside CELL_SHAPE to stop
the two drifting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:25:18 +02:00
63ba69729e fix(month): keep the selection outline off the morphing cell (#53)
Drawn on the cell's morphing pill, the outline rode its shared bounds — so
collapsing painted it at the *expanded* cell's size and shrank it down, a
full-height outline flashing over a grid that no longer had full-height
cells. The same shape of mistake as the marker that used to cross the
outgoing month during a page turn.

It gets its own untagged layer, laid out where the collapsed cell actually
is, and fades in there. That is the only size it is ever true at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:57:29 +02:00
01a6c8bab8 revert(month): put the morph back on the calendar's own motion specs (#53)
The trigger was what felt slow, and firing on the threshold instead of on
release already fixed that. Speeding up the animation on top of it was a
second change to the same complaint, and not one that was asked for — the
morph goes back to the shared calendar slide and fade specs, so it moves at
the same pace as every other transition in the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:51:57 +02:00
f70b33b864 fix(month): drop the expanded grid's outline and make the pull fire sooner (#53)
Three things from the first on-device look.

The selection outline is gone from the expanded grid. Once the cells carry
real event bars it was one mark too many, and it had nothing to say —
every day there is one tap from being selected, and tapping is what closes
the view. MonthGrid/MonthWeekRow lose the parameter again.

Expand and collapse now fire the instant the drag clears the threshold
instead of on release. Waiting for the lift left the screen inert under a
finger that had already asked for the thing, which read as the gesture
being slow to take rather than as a deliberate commit. Paging still waits,
because there the gesture is genuinely undecided until you let go — you can
drag back and settle on a different month. Between expanded and collapsed
there is nothing to change your mind about.

The morph itself runs on the motion scheme's *fast* specs rather than the
page slide's default ones. Paging is a spring under one whole page and
wants room to settle; this answers a drag already committed to, and with a
hundred-odd pieces moving at once a long settle reads as lag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:48:03 +02:00
0df420e551 feat(month): collapse the expanded month on back (#53)
Expanding replaces the whole screen, so it is a place you can be, and every
other place in the app can be backed out of. Without this, back left the
Month view entirely and coming back found it still expanded.

Declared deeper than CalendarHost's view-stack handler, which is what makes
it take precedence while it is enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:37:44 +02:00
488de490f9 feat(month): morph the split style's dots into the expanded grid (#53)
The expand cross-faded two unrelated grids, so a day's cell jumped from a
46dp row near the top of the screen to a full-height one halfway down and
the dots simply vanished where bars appeared. It read as a swap, not as the
month opening up.

Tag the pieces that mean the same thing on both sides and let Compose carry
them: each cell pill grows, its number rides along, and every dot travels
out to the bar it stood for — which only works because the dots are now
seated by lane, so each one has exactly one bar to become.

The tags travel as a composition local with a null default, so the paged,
continuous, and dense styles — which share these same row and cell
composables — provide nothing and pay nothing. Reduced motion provides
nothing either, leaving the plain cross-fade underneath.

A multi-day bar is anchored on the day it starts in its row; the dots on
the days it merely covers are left unmatched and fade where they stand
while the bar sweeps out over them. Only one of them could become the bar
without the others teleporting into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:36:13 +02:00
f15fffa799 feat(month): let the split style pull open to the full month (#53)
Drag the compact grid down and the day pane gives way to the month drawn
the paged style's way — real bars and pills, not dots; drag up to bring the
pane back. Split could show you which days were busy, or one day in full,
but never the month's events at once without a trip to Settings.

The month swipe grows a second axis to do it, so the two are locked to one
decision per gesture: independent horizontal and vertical detectors would
each see their own component of a diagonal drag and both fire. Paging keeps
ties, so an ambiguous drag still reads as it did.

A tap in the expanded grid picks the day and drops back, which makes it a
chooser you dip into rather than a mode you can get stranded in — so the
grid needs the selection outline too, which MonthGrid now takes optionally.

Expansion is state, not a preference: persisted, someone would expand it
once and later find their Split style changed with nothing to explain why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:30:37 +02:00
1499b5803d fix(month): seat the split style's dots by lane (#53)
The dots gathered a day's *distinct colours*, which meant dot i stood for
no particular event and two events sharing a calendar collapsed into one —
undercounting the day.

Seat them off the same lane assignment the paged grid draws from, so a dot
and the bar in that lane are the same event. That fixes the count, and it
is what will let one morph into the other when the grid expands.

The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two
have to agree or a dot would have no bar to become.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:26:57 +02:00
39cc70be95 fix(month): keep the split selection inside the month that owns it (#53)
The marker could still be caught fading in on the outgoing page. Paging changes
the selection immediately but the state only once the new month has loaded, so
until then the content key — the state's month — is unchanged and AnimatedContent
updates the page *in place*: the old grid really was told the next month's 1st
was selected, and it shows that date among its trailing days.

A page now marks only the days its own month owns. Selection always lands inside
the month it belongs to, so nothing legitimate is lost, and the phantom goes
without timing the fade against the page transition — a delay would have moved
it rather than removed it, and slowed selecting a day within a page besides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:10:08 +02:00
f9619e74d9 style(month): settle the split style's selection and day pane (#53)
Three things still moved badly while paging.

The selection marker was thrown across the outgoing grid. `selected` was read
from outside the AnimatedContent, so both pages rendered the *incoming*
selection — and the new month's 1st is a day the old grid still shows among its
trailing days, so the outline jumped there (bottom right) before the new page
slid in and put it where it belonged (top left). The selection now travels with
the state, so each page keeps its own and the marker only ever appears where its
month has it.

Moving the selection within a month also jumped, outline blinking out of one
cell and into another. Each cell now fades its own outline, so it reads as one
mark crossing the grid. Snapped under reduced motion.

And the day pane swapped its rows outright, popping one list out and another in.
It now cross-fades, the incoming list rising as it arrives — the same entrance
the rest of the family uses for resolved content — with the skeleton handing
over the same way. Rows within a day also animate their placement, so an event
arriving from a sync nudges its neighbours instead of teleporting them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:04:08 +02:00
e691336b5e style(calendar): make the page transition a proper shared-axis (#38, #53)
Paging between months, weeks and days looked rough, and the transition was the
reason: a full-width slide with no fade at all. AnimatedContent stacks the two
pages and both were fully opaque, so what you saw was one grid racing across
another and clipping at the viewport edge — the movement was carrying the entire
transition, over the longest distance available.

It is now M3's shared-axis X. The offset only hints the direction — a fifth of
the width — while a cross-fade does the swapping, position springs and opacity
eases, and the size transform no longer clips. All three calendar views share
this transition, so all three settle.

Also lifts the month view's swipe threshold from 6dp to the 24dp the week and
day views already used. Six is inside the distance a tap wanders, so brushing
the grid turned the page — which reads as the animation firing at random rather
than as an over-eager gesture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:57:45 +02:00
4cee160c12 fix(month): steady the split style's paging (#53)
Two things made a swipe between months feel rough, both from the grid and the
pane disagreeing about what was happening.

The grid sized itself to its month, 4 to 6 rows, so every swipe shunted the pane
up or down by a row while the grid content swapped underneath with no transition
at all. It now always reserves six rows, padding a short month with blank ones —
which both holds the pane still and makes the paged style's directional slide
safe to use here, since there is no longer a height change to animate under it.

The pane also flashed "Nothing scheduled" mid-page. Paging moves the selection
to the new month before that month's events have arrived, and the pane was
handed `instancesByDay[selected].orEmpty()` — so a day that simply wasn't loaded
yet was indistinguishable from a day with nothing on it, and got reported as
free. It now takes a nullable list and shows skeleton rows for the gap, saying
"still looking" rather than making a claim about the day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:52:32 +02:00
a7ae8ff912 style(month): let the outline alone mark the split style's selected day (#53)
The selected cell also swapped its fill to primaryContainer, which lightened the
whole cell — a second state stacked on the day rather than a mark on it, and one
that fought today's circle when the two landed together. The outline and the day
number carry it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:46:18 +02:00
d23a1d5b6d style(month): drop the split style's divider (#53)
The rule between the compact grid and the day pane earns its place in the
Continuous header, where it closes a section off; here it only draws a line
between two halves the layout already separates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:45:32 +02:00
b417900ddd perf(month): bucket events by day before laying out the scrolling grids (#38)
Opening Continuous stayed slow, and the slowness tracked the number of enabled
calendars rather than the window size — which pointed past the provider query to
the layout behind it.

layoutCalendarWeek opens by filtering the instances it is handed down to the
ones touching its seven days. For a single month's grid that is nothing; for the
scrolling styles it was a scan of every instance in an eleven-month window,
repeated for each of a hundred-odd rows, with a time-zone conversion per check.
The work grew with events × rows, so switching every calendar on multiplied it.

- DayIndex buckets the window's events by the dates they cover, once, and each
  row takes the handful on its own seven days. Membership is decided by
  coversDay itself rather than re-derived from the timestamps — the all-day and
  timed cases have enough edge cases between them (UTC anchoring, exclusive
  ends, zero-length events at midnight) that a second implementation would
  drift. DayIndexTest pins row-for-row equality with the old full scan, in a
  zone east of UTC.
- Only the style on screen is laid out. Adding Dense had quietly doubled the
  work, since both layouts were built from every load and one was always thrown
  away.
- The paged flow no longer queries under a scrolling style, mirroring the gate
  the scrolling flow already had.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:41:03 +02:00
95f4d28bbb perf(month): open the scrolling styles on a small window and grow it (#38)
Opening Continuous made the first frame wait for nine months of recurrence
expansion when only one was about to be looked at — a provider Instances query
expands across its whole range, so the window's generosity was paid for up
front, every time.

The window now starts at one month either side of the visible one — about what
the paged style costs — and each completed load reaches two months further in
both directions until it spans eleven. The months a scroll can reach arrive
while the first one is already on screen, and because a widening is triggered by
the previous load landing rather than by a timer, the ladder can never outrun
the provider.

- The paged and split styles no longer run this query at all. The screen
  collects the flow whatever the style is set to, so until now every Month view
  opening paid for a window it would never draw.
- The reload trigger is derived from the current pad instead of being fixed, and
  is held strictly inside it: a trigger at or beyond the pad would re-fire the
  moment its own reload landed.
- The scrolling styles get their own skeleton — the layout they are about to
  become, at the same measurements, so arriving months replace it in place. It
  and the per-month placeholders now breathe, so a slow load reads as work
  rather than as an empty grid. Held still under reduced motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:23:06 +02:00
24b005126e feat(month): add the Dense style, rule under the Continuous header (#38)
Continuous's month blocks make the boundaries legible, but the seams are exactly
what someone who wants a dense calendar doesn't want. Both are now offered:

- **Dense** is the old flowing layout, kept rather than replaced — one
  uninterrupted stream of weeks with months running into each other, the 1st
  naming its month, and the top bar keeping the full month title (there is no
  sticky header to defer to).
- Continuous gains a **rule under its month label**, closing the header off
  against the grid. On trial: it comes out again if it doesn't earn its place.

One provider query serves both. `ContinuousMonthUiState.Success` now carries the
same loaded window laid out twice — `monthsByIndex` clipped into blocks,
`weeksByIndex` left whole — rather than standing up a second flow and querying
the same range again. Both styles report *months* as they scroll, so the window
hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it
covers, widened a week each side so boundary rows don't flicker.

The list state is keyed on style *and* week start: Dense indexes by week, which
the week-start preference moves, while Continuous indexes by month, which it
doesn't — carrying an offset between the two would land somewhere arbitrary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:10:54 +02:00
ee9d65068d fix(month): don't drag the continuous window back to 1900 (#38)
The continuous grid failed outright with "Could not read the calendar" the
moment it opened.

Before the first layout pass LazyListState reports no visible items, and the
screen defaulted that to item 0 — claiming January 1900 was on screen. The load
window widened around it, its pad ran past the epoch to index -4, and
yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned
the crash into the generic provider failure. Week indexing never showed this:
index 0 was a real week, so a bogus report only cost one wasted query.

- The visible-range report is skipped entirely while the list has no items,
  rather than standing in a default for them.
- clampMonthWindow holds any window inside 1900–2100, so a pad at either end
  can't produce an index with no month behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:59:53 +02:00
6e3d10858f refactor(month): make the continuous style a stack of month blocks (#38)
The continuous style streamed weeks with no boundaries at all, which left it
hard to tell where one month ended: the only marker was a "Jul 1" label on the
1st, and every boundary week mixed two months' days into one row.

It is now a vertical stack of self-contained month blocks. Each block shows only
its own days — the boundary week keeps its seven columns so nothing shifts
sideways, but the neighbour month's cells are blank rather than filled with
duplicates of days shown again a block later — under a sticky month header with
whitespace either side. Scrolling stays continuous; only the reading changes.

- The coordinate space moves from absolute week index to absolute month index
  (two LazyColumn items per month: header, then block). Unlike week indices, it
  doesn't depend on the week-start preference, so changing that reflows the rows
  inside a block without moving the block or losing the scroll position.
- The sliding data window now loads months rather than weeks, widened to whole
  grid weeks at both ends so a bar reaching into a block from a clipped-off day
  still renders.
- `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and
  counts and cuts spanning bars back to the month's own columns, keeping a flat
  cap on the cut side so a bar reads as continuing past the block.
- The top bar carries the year in this style — the block's own header names the
  month, so repeating it two lines up was pure duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:54:26 +02:00
94a82c2f6c refactor(settings): make the month style picker a live preview (#38, #53)
The first version of this picker invented its own visual language — free-
standing bordered cards with a hand-rolled check — which matched nothing else
in the app. It is now the family's standard shape: FullScreenPicker with
PickerDescription and connected GroupedRows, tonal highlight and the shared
SelectedCheck.

Above the rows sits a live, scaled-down Month view that changes as you pick a
style. It renders the *real* grid composables rather than a drawing of them, so
the preview cannot drift from what it depicts: MonthGrid, ContinuousMonthGrid,
SplitMonthGrid and SplitDayPane are now internal rather than private, and the
sample month runs through the same layoutMonthWeeks/layoutCalendarWeek the live
views use. The month, today's position, week start, colour softening and clock
format are all real; only the events are stand-ins, since a settings screen has
no business querying the provider for a thumbnail.

Selecting applies immediately and leaves the picker open — closing on tap would
hide the very thing the screen is for. Back exits, as in the App name picker.

Scaling note worth keeping: Modifier.requiredSize looks like the way to force a
full-viewport measurement, but it *centres* content that overflows the incoming
constraints, which left only the grid's bottom-right corner inside the clip. A
layout modifier that measures at Constraints.fixed and reports the scaled size
has no overflow to align and no dependence on parent alignment.

PickerDescription is internal now rather than duplicated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:34:16 +02:00
b90f07a816 ci(release): push tag to Codeberg before creating the release
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m13s
The Codeberg publish step (added but not yet exercised by a release)
would 500 the same way agendula's did: the tag is created via the Gitea
API, which the push mirror doesn't propagate promptly, so a release POST
with target_commitish outruns the mirror and fails on an unknown commit.

Push the tag straight to Codeberg from the runner, then create the
release against that existing tag with no target_commitish. Race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:48:30 +02:00
53d42341c0 Merge pull request 'Translations update from Weblate' (!80) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #80
2026-07-19 09:42:15 +00:00
55ffaad94f Translated using Weblate (German)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m37s
Currently translated at 97.4% (426 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-19 09:37:33 +00:00
Weblate
5bdde146c1 Translated using Weblate (German)
Currently translated at 97.4% (426 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-19 09:37:33 +00:00
Weblate
97f87fb927 Translated using Weblate (French)
Currently translated at 52.8% (231 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-19 09:37:33 +00:00
augustin64
469166c0bb Translated using Weblate (French)
Currently translated at 52.8% (231 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-19 09:37:33 +00:00
Pablo
b10965babe Translated using Weblate (Russian)
Currently translated at 14.1% (62 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-07-19 09:37:33 +00:00
Weblate
e5c122a2ee Translated using Weblate (Polish)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
Bazyli Cyran
3aefc5c8f8 Translated using Weblate (Polish)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
Bazyli Cyran
ecc643ac81 Translated using Weblate (Polish)
Currently translated at 97.2% (417 of 429 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-07-19 09:37:33 +00:00
8e4d0defa2 Merge pull request 'ci(release): mirror releases to Codeberg (APK + checksum)' (!81) from feat/codeberg-release-publish into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #81
2026-07-19 09:37:29 +00:00
f459f7d39c ci(release): use CODEBERG_RELEASE_TOKEN secret name
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:23:49 +02:00
6a49539ae5 ci(release): mirror each release to Codeberg with APK + checksum
Publish the signed release APK and a SHA-256 checksum as a Codeberg
release, a direct-download channel for users who don't want F-Droid.
Gitea already push-mirrors branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release
over the Codeberg API and attaches calendula_v<version>.apk + .sha256.

Same app-signed APK the F-Droid repo serves, so no added trust surface.
Best-effort (continue-on-error) so a Codeberg outage never fails an
already-published F-Droid release; skips cleanly when CODEBERG_TOKEN is
unset. Upsert + same-name asset replacement keep re-runs safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:56:13 +02:00
34 changed files with 3965 additions and 767 deletions

View File

@@ -1,11 +1,13 @@
name: Release — F-Droid repo + Gitea release
name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, and only then creates the vX.Y.Z tag + Gitea release
# itself — the tag is an output of the pipeline, not its trigger. Ordinary
# merges (no version bump) fall through `detect` and do nothing.
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
@@ -350,3 +352,88 @@ jobs:
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="calendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/calendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" <<'PY' > cb-payload.json
import json, sys
print(json.dumps({
"tag_name": sys.argv[1],
"name": sys.argv[1],
"body": open("release-notes.md").read(),
"draft": False,
"prerelease": False,
}))
PY
# Upsert (re-run safe). POST also creates the tag at target_commitish
# if the push mirror hasn't synced it yet.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$ID" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID"
else
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."

View File

@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.16.0] — 2026-07-24
### Added
- Choose how the month view is laid out. A new **Month view style** setting
(Settings → Views) offers three ways to read a month, each shown with a
@@ -24,18 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
The Agenda view is untouched by this and stays available in all three styles —
the split layout lists a single day, while Agenda remains a rolling multi-day
window with its own range settings.
### Fixed
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
## [2.16.0] — 2026-07-17
### Added
- Give an event its own time zone. A new **Time zone** field (under "more
fields" in the event form) pins an event to a specific zone, so a call set for
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
@@ -53,6 +43,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
today icon in the top bar — always there, on today or not, matching the
familiar calendar-app pattern. Leave it off to keep the floating button as
before ([#60]).
- Choose what Calendula calls itself on your home screen. A new **App name**
setting (Settings → Appearance) switches the launcher label between
**Calendula** and **Calendar**, for launchers that can't rename apps
themselves. Pick from a full-screen chooser that previews both names as
launcher marks; the change applies at once ([#44]).
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
Italian translations have been brought up to date — thanks to the community
translators on Weblate. Pick a language under Settings → Language (or leave it
on the system default).
### Changed
- Dates in the Month, Week and Day title bars now follow your language and
@@ -71,6 +70,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
"24. Jun 31. Jun" restated the day numbers already printed in the column
headers right below it, in the widest string in the bar. A week that straddles
two months keeps the outgoing month until it is fully gone ([#60]).
- The custom recurrence picker has been redesigned and tightened up. As you
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
now describes exactly what will be saved rather than a near-copy that could
drift from it, the amount fields accept being left blank (reading as their
shown default instead of greying out OK), and the read-out no longer jumps
around as you tap weekdays ([#42]).
### Fixed
- An all-day event no longer shows up again the day after it happened. In time
@@ -80,6 +85,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
window reached back across that boundary and pulled the event forward onto
"today". Each all-day event now lists only on the day it actually falls on
([#65]).
- A multi-day event now shows under every day it spans in the Agenda, not just
its first day, so a trip or a multi-day booking appears on each day it covers.
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
didn't advertise — so it was left out of the chooser, and if it was your only
calendar app the save silently did nothing. It now accepts that form, plus the
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
mislabelled as a generic download (`application/octet-stream`), as some mail
clients, browsers and file managers do — Calendula recognises it by its file
extension instead of relying on the declared type ([#74]).
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
to end "until" a given day could save and show the day after the one picked;
the end date now reads back as chosen ([#42]).
- The status- and navigation-bar icons stay legible over full-screen pickers in
dark theme. They could render dark-on-dark — a near-invisible black clock
against the dark picker — instead of switching to light ([#70]).
## [2.15.0] — 2026-07-15
@@ -1079,3 +1108,7 @@ automatically, with zero telemetry and no internet permission.
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74
[#42]: https://codeberg.org/jlmakiola/calendula/issues/42
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70

View File

@@ -113,10 +113,13 @@ 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. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// 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.
informational += "MissingTranslation"
informational += listOf("MissingTranslation", "MissingQuantity")
}
testOptions {

View File

@@ -112,6 +112,28 @@
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
application/octet-stream — the way many mail clients, browsers and
file managers hand off attachments. Matched by file extension, so
this stays a separate filter: a pathPattern here must not narrow
the MIME-typed VIEW filter above (that one has no path and must
keep matching regardless of name). The import handler ignores the
MIME type, so a let-through octet-stream .ics imports normally.
Best-effort: pathPattern is reliable for file:// (and content://
whose path carries the filename); content:// URIs that expose no
name still fall back to the MIME-typed filter above. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:pathPattern=".*\\.ics" />
<data android:pathPattern=".*\\.vcs" />
</intent-filter>
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -123,11 +145,13 @@
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new
ACTION_INSERT on the events *dir* mime type, carrying the new
event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. -->
to the same prefilled create form. (The far more common *item*-
typed INSERT — the form the Android docs' example and apps like
DB Navigator use — is the item filter below, issue #74.) -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
@@ -135,14 +159,31 @@
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<!-- Edit an existing event another app/assistant/widget points at:
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
AOSP fires it. Opens the occurrence in the edit form (not the
read-only detail — that's the VIEW filter above). Matched by the
provider's item MIME type, like the VIEW filter. The occurrence's
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
supplied (MainActivity.editEventKeyOrNull). -->
<!-- Create or edit an event another app/assistant/widget points at,
addressed by the provider's *item* MIME type. Three actions share
this filter, told apart at runtime by the intent's data:
• ACTION_INSERT — create. This is the form the Android docs'
"insert an event" example and many apps use
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
"Save to calendar". The dir-typed filter above alone missed it,
so Calendula never showed in the chooser — and, when it was the
only calendar app, the intent resolved to nothing (issue #74).
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
and Google Calendar register; a create, or an edit when it
carries an event id.
• ACTION_EDIT — edit the existing event at
content://com.android.calendar/events/<id> (an id-less EDIT is a
create). Opens the occurrence in the edit form, not the
read-only detail — that's the VIEW filter above.
Create fields ride as CalendarContract extras; an edit's
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
/ editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.INSERT_OR_EDIT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />

View File

@@ -222,10 +222,12 @@ class MainActivity : AppCompatActivity() {
* [buildInsertEventForm].
*/
private fun Intent.insertFormOrNull(): EventForm? {
// ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
// as is any plain ACTION_INSERT.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
@@ -344,10 +346,11 @@ class MainActivity : AppCompatActivity() {
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
* some apps fire — is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) return null
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments

View File

@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
*/
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve.
* The M3 Expressive spatial spring used for the month/week/day slide: the
* *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve.
*
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
* movement, and driving a whole calendar page with it made the settle read as a
* twitch instead of a glide.
*
* Read it in a composable scope (this helper) so it can be captured by the
* non-composable `AnimatedContent` transitionSpec lambda.
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
* The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas, and reused on its own as the
* reduced-motion fallback.
*
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
* eases *opacity*, and a fade that bounced with the slide would shimmer.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
MaterialTheme.motionScheme.defaultEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* outgoing page slides and fades one way while the incoming one arrives from the
* other, position on a spring and opacity on an easing curve.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
fun calendarSlideTransition(
slideDir: Int,
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
return ContentTransform(
targetContentEnter =
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
)
}
/**
* How far a page travels, as a fraction of the container width.
*
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5

View File

@@ -198,7 +198,7 @@ private fun CustomReminderEditor(
/** A short explanatory paragraph shown under a picker's title, above the rows. */
@Composable
private fun PickerDescription(text: String) {
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -79,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
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.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -266,8 +265,6 @@ private fun DayContent(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
@@ -296,20 +293,7 @@ private fun DayContent(
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,

View File

@@ -0,0 +1,154 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import kotlinx.datetime.LocalDate
/**
* What lets the split style's compact grid *become* the full month rather than be
* swapped for it (#53).
*
* The two grids are separate composables — the compact one draws dots, the
* expanded one draws the paged style's bars and pills — so nothing about them is
* shared by construction. Tagging the pieces that mean the same thing on both
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
* the other: a day's cell grows, its number rides along, and each dot travels out
* to the bar it stood for.
*
* It travels as a composition local rather than as parameters because the pieces
* sit five levels below where the scopes exist, and because a null default is
* exactly the right meaning for everyone else: the paged, continuous, and dense
* styles share the same row and cell composables, provide nothing, and pay
* nothing. Reduced motion provides nothing either, which leaves the plain
* cross-fade underneath.
*/
internal sealed interface MonthMorphKey {
/** A day's background pill — the structural anchor the rest rides on. */
data class Cell(val date: LocalDate) : MonthMorphKey
data class DayNumber(val date: LocalDate) : MonthMorphKey
/**
* One event *on one day*. Keyed by date as well as instance because a
* multi-day event has a dot on every day it covers but only one bar, drawn
* from where it starts: the start day's dot is the one that becomes the bar,
* and the rest are left unmatched on purpose. They fade where they stand
* while the bar sweeps out over them, which is the honest reading — one of
* them could not become the bar without the others teleporting into it.
*/
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
/**
* A day's "+N" marker. The compact grid writes it as a count beside the dots
* and the expanded one as a row of small dots under the bars, but they stand
* for the same events and appear on exactly the same days — both sides seat
* three and overflow the rest — so they are a matched pair, not two unrelated
* bits of text. Tagged, the marker travels with its day like everything else
* in the cell.
*/
data class Overflow(val date: LocalDate) : MonthMorphKey
/**
* The selected day's outline. The expanded grid draws no outline — once the
* cells carry real event bars it is one mark too many — so this pairs the
* compact grid's outline with an *invisible* stand-in over the same cell
* there. Without one it had nowhere to travel and could only fade in place
* while everything around it moved.
*/
data class Selection(val date: LocalDate) : MonthMorphKey
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
data object Handle : MonthMorphKey
}
@OptIn(ExperimentalSharedTransitionApi::class)
internal class MonthMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
)
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
/**
* True while the grid is mid-morph, for content that has to stop clipping to let
* the travelling pieces through.
*
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
* of the way down the compact grid, its bar most of the way down the expanded
* one. Clipped to the row it is arriving at, a mark spends the first half of its
* journey outside those bounds and is simply not drawn — so it appears from
* nowhere, part-way through, already near its destination. Rendering in place
* rather than in an overlay is what subjects them to that clip, and is worth
* keeping; the clip is what has to yield, and only while pieces are in flight.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun morphInFlight(): Boolean {
val morph = LocalMonthMorph.current ?: return false
return morph.shared.isTransitionActive
}
/**
* Tag content that is *the same thing* on both sides — a background pill, a day
* number — so it animates between its two positions and sizes.
*
* ### Why nothing renders in the overlay
*
* By default a travelling piece is painted into an overlay above the *entire*
* regular tree, so it can fly over anything in its way. That is right for a
* thumbnail crossing a screen, and wrong here: everything is moving inside one
* grid, and the overlay meant every untagged neighbour — the selection outline,
* the "+N" markers — spent the transition buried under pieces that had left the
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
* bought a worse problem: they then floated over the grid on their own layer,
* out of step with it.
*
* Rendering in place puts everything back in one z-order and one clip, which is
* what makes the grid read as a single surface changing shape rather than a
* stack of pieces sliding past each other. Nothing here needs to escape its
* ancestors: the cells, marks and markers all travel within the grid.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphElement.sharedElement(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
renderInOverlayDuringTransition = false,
)
}
}
/**
* Tag content that means the same thing but *is drawn differently* on each side —
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
* cross-fade inside them.
*
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
* simply clips it while there is no room, so what grows is the pill, not the type.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
val morph = LocalMonthMorph.current ?: return this
return with(morph.shared) {
this@morphBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
renderInOverlayDuringTransition = false,
)
}
}

View File

@@ -0,0 +1,284 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.math.roundToInt
/**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
* chooser.
*
* It renders the *real* grid composables rather than a drawing of them, so the
* preview cannot drift from the thing it depicts: change a cell's shape or an
* event bar's colour and every preview follows automatically. The trick is
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
* out at a full phone width and a plausible viewport height, and a
* [graphicsLayer] scale shrinks the finished layout into the card.
*
* The month, today's position and the week start are all real; only the events
* are stand-ins, since the settings screen has no business querying the
* provider for a thumbnail.
*/
@Composable
internal fun MonthStylePreview(
style: MonthViewStyle,
weekStart: DayOfWeek,
height: Dp,
modifier: Modifier = Modifier,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / VIRTUAL_HEIGHT.value
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// grid's own clickables see them, and give screen readers one label
// instead of six weeks of day cells.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes
.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { }
// Measure the grid at a full phone viewport but report the scaled
// size, so the node occupies exactly what it draws.
//
// Modifier.requiredSize would be the obvious way to force the larger
// measurement, but it *centres* content that overflows the incoming
// constraints — which pushed the grid to a negative offset and left
// only its bottom-right corner inside the clip.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
},
) {
Column(Modifier.fillMaxSize()) {
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
when (style) {
MonthViewStyle.Paged -> MonthGrid(
state = sample.month,
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Continuous -> ContinuousMonthGrid(
state = sample.continuous,
listState = rememberLazyListState(
initialFirstVisibleItemIndex = itemIndexForMonth(
monthIndexOf(YearMonth(today.year, today.month)),
),
),
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Dense -> DenseMonthGrid(
state = sample.continuous,
listState = rememberLazyListState(
// Start a week above today's, so the preview shows a
// stream running past the viewport rather than one
// beginning at its top edge.
initialFirstVisibleItemIndex =
weekIndexOf(today, weekStart) - 1,
),
showWeekNumbers = false,
onOpenDay = {},
)
MonthViewStyle.Split -> {
SplitMonthGrid(
state = sample.month,
selected = today,
showWeekNumbers = false,
onSelectDay = {},
)
SplitDayPane(
date = today,
today = today,
events = sample.month.instancesByDay[today].orEmpty(),
zone = zone,
onOpenDay = {},
onEventClick = {},
onCreateEvent = {},
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
)
}
}
}
}
}
/**
* The viewport the preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling from a fixed height keeps the
* three styles comparable — each shows the same slice of screen.
*/
private val VIRTUAL_HEIGHT = 440.dp
private val SPLIT_PANE_HEIGHT = 170.dp
private class SampleMonth(
val month: MonthUiState.Success,
val continuous: ContinuousMonthUiState.Success,
)
/**
* A month's worth of stand-in events, laid out through the same
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
* exercises the real span, lane and overflow logic rather than approximating it.
*
* Colours are raw ARGB on purpose: that is what the provider hands out for an
* event, so a token here would misrepresent what the grid actually renders.
*/
private fun sampleMonthState(
today: LocalDate,
weekStart: DayOfWeek,
zone: TimeZone,
): SampleMonth {
val ym = YearMonth(today.year, today.month)
val first = LocalDate(ym.year, ym.month, 1)
val events = sampleEvents(first, today, zone)
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
val month = MonthUiState.Success(
month = ym,
today = today,
weeks = weeks,
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
zone = zone,
)
// The month either side of this one: enough for Continuous to show a block,
// the whitespace under it and the next month's header coming up, and for
// Dense to have somewhere to scroll in both directions.
val centre = monthIndexOf(ym)
val window = (centre - 1)..(centre + 1)
val continuous = ContinuousMonthUiState.Success(
today = today,
monthsByIndex = window.associateWith { index ->
val m = yearMonthForIndex(index)
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
},
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, events, zone)
},
weekStart = weekStart,
)
return SampleMonth(month, continuous)
}
private val SAMPLE_COLORS = listOf(
0xFF3F7BD4.toInt(),
0xFFCE5B4C.toInt(),
0xFF4E9A6A.toInt(),
0xFF8A63C7.toInt(),
)
private fun sampleEvents(
firstOfMonth: LocalDate,
today: LocalDate,
zone: TimeZone,
): List<EventInstance> {
var id = 0L
fun next() = ++id
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
instanceId = next(),
eventId = id,
calendarId = 1L,
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
start = day.atTime(hour, 0).toInstant(zone),
end = day.atTime(hour + length, 0).toInstant(zone),
isAllDay = false,
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
location = null,
)
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
instanceId = next(),
eventId = id,
calendarId = 1L,
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
// All-day events sit at UTC midnights with an exclusive end.
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
location = null,
)
// Spread across the month so most weeks carry something, with one multi-day
// bar to show a span bridging cells and a busy today for the split pane.
return buildList {
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
// Today, so the split pane's list and the grid's dots both have content.
add(timed(today, 9, 1, 0))
add(timed(today, 12, 1, 1))
add(timed(today, 15, 2, 3))
}
}
private val SAMPLE_TITLES = listOf(
"Standup",
"Lunch",
"Review",
"Gym",
"Call",
"Workshop",
"Dentist",
"Trip",
)

View File

@@ -40,17 +40,58 @@ data class MonthWeek(
)
/**
* State for the continuous style (#38). Weeks are keyed by absolute week index
* rather than gathered into months: the whole point of the style is that there
* are no month boundaries to scroll across, so there is no "current month" here
* and no notion of an out-of-month day. [weeksByIndex] holds only the loaded
* window; indices outside it render as placeholders until the window catches up.
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
*
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
* week: bars keep the lane the row layout gave them, and the day's timed events
* fill whatever slots are left, top-most first. Reading it here means the split
* style's dots and the paged style's bars describe a day in the *same order*, so
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
*
* Deriving the dots independently — by distinct colour, as they first were —
* left dot _i_ standing for no particular event, and quietly merged two events
* that shared a calendar into a single dot.
*/
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) {
byLane[span.lane] = span.event
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = event
}
return byLane.filterNotNull()
}
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
* months rather than one undifferentiated run of weeks. Each month is keyed by
* absolute month index and carries only its own days — the boundary week keeps
* its seven columns so the grid geometry never shifts, but the neighbour month's
* dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
*
* The same loaded window is served two ways, because the Dense style is the same
* scroll with the seams taken out and it would be wasteful to query the provider
* twice for one range:
*
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
* clipped to its own month.
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
* weeks left carrying both months.
*
* Both hold only the loaded window; indices outside it render as placeholders
* until the window catches up.
*/
sealed interface ContinuousMonthUiState {
data object Loading : ContinuousMonthUiState
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
data class Success(
val today: LocalDate,
val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek,
) : ContinuousMonthUiState

View File

@@ -20,11 +20,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
@@ -82,8 +85,12 @@ class MonthViewModel @Inject constructor(
val month: StateFlow<YearMonth> = _month
val state: StateFlow<MonthUiState> =
combine(_month, weekStart) { ym, ws -> ym to ws }
.flatMapLatest { (ym, ws) ->
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
.flatMapLatest { (ym, ws, style) ->
// The mirror of the gate below: the paged grid is what Paged and
// Split draw, so under a scrolling style this query is a month
// of provider work for a view that isn't on screen.
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
val range = monthGridRange(ym, ws, zone)
combine(
repository.calendars(),
@@ -100,34 +107,66 @@ class MonthViewModel @Inject constructor(
initialValue = MonthUiState.Loading,
)
// --- Continuous style (#38) -------------------------------------------
// --- Continuous + Dense styles (#38) ----------------------------------
//
// The continuous grid is one endless stream of weeks, so it can't load "a
// month" — it loads a sliding window of week indices around whatever is on
// screen. The window only moves when the visible range comes within
// WINDOW_EDGE weeks of a loaded edge, so a scroll re-queries occasionally
// rather than on every frame.
// These scroll through every month there is, so they can't load "a month" —
// they load a sliding window of month indices around whatever is on screen.
// The window only moves when the visible range comes within WINDOW_EDGE
// months of a loaded edge, so a scroll re-queries occasionally rather than
// on every frame.
//
// The window also *starts small and grows*. A provider Instances query
// expands recurrences across its whole range, so opening straight onto a
// year of months made the first frame wait for eleven months of expansion
// when only one was about to be looked at. It now opens on INITIAL_PAD
// months, and each completed load widens by GROWTH_STEP in both directions
// until MAX_PAD — the months you can reach by scrolling arrive while you're
// still looking at the first one.
// Seeded around today so the first frame has data. The week-start preference
// hasn't arrived yet, but anchoring on a different day shifts an index by at
// most one week — well inside the pad, and the first scroll report corrects it.
private val _loadedWeeks = MutableStateFlow(
weekIndexOf(todayDate, DayOfWeek.MONDAY).let { it - WINDOW_PAD..it + WINDOW_PAD },
/** How far around the visible range we currently load; grows as loads land. */
@Volatile
private var loadPad = INITIAL_PAD
/** Last reported visible range, so a widening step can re-centre on it. */
@Volatile
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
.let { it..it }
private val _loadedMonths = MutableStateFlow(
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
)
val continuousState: StateFlow<ContinuousMonthUiState> =
combine(_loadedWeeks, weekStart) { window, ws -> window to ws }
.flatMapLatest { (window, ws) ->
val first = weekStartForIndex(window.first, ws)
val last = weekStartForIndex(window.last, ws).plus(6, DateTimeUnit.DAY)
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
Triple(window, ws, style)
}
.flatMapLatest { (window, ws, style) ->
// Nothing to load for the paged and split styles — they have
// their own single-month flow, and querying a year of months
// behind them is pure waste.
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
// Widened to whole grid weeks at both ends: a month block still
// has to know about an event that starts in the boundary week's
// clipped-off days, or a bar running into the block would vanish.
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
val last = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
.startOfGridWeek(ws)
.plus(6, DateTimeUnit.DAY)
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildContinuousState(window, ws, calendars, instances)
buildContinuousState(window, ws, style, calendars, instances)
}
}
// A load landing is the cue to reach further out. Widening from here
// rather than on a timer means each step waits for the previous one,
// so the ladder can never outrun the provider.
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
@@ -137,31 +176,65 @@ class MonthViewModel @Inject constructor(
)
/**
* Report which absolute week indices are on screen. Cheap to call on every
* Report which absolute month indices are on screen. Cheap to call on every
* scroll frame: it returns immediately unless the visible range has drifted
* close enough to a loaded edge to warrant a wider query.
*/
fun onVisibleWeeksChanged(firstIndex: Int, lastIndex: Int) {
nextLoadWindow(_loadedWeeks.value, firstIndex, lastIndex)?.let { _loadedWeeks.value = it }
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
visibleMonths = firstIndex..lastIndex
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
?.let { _loadedMonths.value = clampMonthWindow(it) }
}
/**
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
* that re-emit the same window don't restart it.
*/
private fun widenLoadedWindow() {
if (loadPad >= MAX_PAD) return
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
_loadedMonths.value = clampMonthWindow(
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
)
}
private fun buildContinuousState(
window: IntRange,
weekStart: DayOfWeek,
style: MonthViewStyle,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): ContinuousMonthUiState {
if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val weeks = window.associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
// 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)
// Only the style on screen is laid out. Building both doubled the work
// for a layout nothing was going to draw.
val months = if (style == MonthViewStyle.Continuous) {
window.associateWith { index ->
val ym = yearMonthForIndex(index)
layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
}
layoutCalendarWeek(days, instances, zone)
} else {
emptyMap()
}
val weeks = if (style == MonthViewStyle.Dense) {
weekWindowFor(window, weekStart).associateWith { index ->
val days = (0 until 7).map {
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
}
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
}
} else {
emptyMap()
}
return ContinuousMonthUiState.Success(
today = todayDate,
monthsByIndex = months,
weeksByIndex = weeks,
weekStart = weekStart,
)
@@ -211,6 +284,21 @@ class MonthViewModel @Inject constructor(
_selectedDate.value = todayDate
}
/**
* Track [_month] to the month a scrolling style is showing. Those styles
* navigate by scroll position and never set [_month] themselves, so it would
* otherwise sit wherever paged navigation last left it (today's month, on a
* fresh open) — and a switch to a paged style, or a reseed of the other
* scrolling style's list, would jump there instead of holding position. The
* selection is realigned alongside it so that landing on the Split style
* shows a live day in the month on screen rather than a stale, off-month one.
*/
fun syncScrollMonth(ym: YearMonth) {
if (_month.value == ym) return
_month.value = ym
_selectedDate.value = selectionForMonth(ym, todayDate)
}
/** Jump to the month containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_month.value = YearMonth(date.year, date.month)
@@ -251,17 +339,68 @@ internal fun layoutMonthWeeks(
weekStart: DayOfWeek,
instances: List<EventInstance>,
zone: TimeZone,
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
/**
* As above, but reusing a [DayIndex] already built for a wider range — what the
* scrolling styles need, since they lay out many months from one query and
* rebuilding the index per month would put the cost straight back.
*/
internal fun layoutMonthWeeks(
ym: YearMonth,
weekStart: DayOfWeek,
byDay: DayIndex,
zone: TimeZone,
): List<MonthWeek> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
val weekCount = (leadOffset + daysInMonth + 6) / 7
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
val weekCount = weekRowsInMonth(ym, weekStart)
return (0 until weekCount).map { row ->
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
layoutCalendarWeek(days, instances, zone)
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
}
}
/**
* Events bucketed by the dates they cover, built once per load.
*
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
* the ones touching its seven days. That is fine for a single month's grid, but
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
* *every* instance in an eleven-month window for each of them made the work grow
* with the number of enabled calendars until opening the view stalled.
*
* Membership is decided by [coversDay] itself rather than by re-deriving the
* rule from the timestamps: the all-day and timed cases have enough edge cases
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
* that a second implementation would drift. The walk only visits an event's own
* candidate days, so the check runs a couple of times per event rather than once
* per event per row.
*/
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
instances.forEach { event ->
// All-day events are anchored to UTC midnights; timed ones to the
// device zone. Either way the candidate span is the event's own
// dates, which is one or two days for almost everything.
val anchor = if (event.isAllDay) TimeZone.UTC else zone
var day = event.start.toLocalDateTime(anchor).date
val lastCandidate = event.end.toLocalDateTime(anchor).date
while (day <= lastCandidate) {
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
day = day.plus(1, DateTimeUnit.DAY)
}
}
}
/** Every event touching any of [days], each listed once, in input order. */
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
if (days.isEmpty()) return emptyList()
val hits = days.flatMap { byDay[it].orEmpty() }
// A multi-day event is bucketed on each date it covers, so a row that
// holds several of its days would otherwise list it several times.
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
}
}
@@ -336,13 +475,24 @@ internal fun monthGridRange(
}
/**
* How many weeks the continuous window loads beyond the visible range, and how
* close the visible range may drift to a loaded edge before it reloads. The pad
* is generous relative to the trigger so a steady scroll crosses the trigger
* well before it would run out of laid-out weeks.
* The sliding window's shape.
*
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
* visible one, so opening the view costs about what the paged style costs. Each
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
* in the months a scroll could reach while the first ones are already on screen.
*
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
* it reloads. It is always kept below the current pad — a trigger at or beyond
* the pad would re-fire the moment its own reload landed.
*/
private const val WINDOW_PAD = 12
private const val WINDOW_EDGE = 4
private const val INITIAL_PAD = 1
private const val GROWTH_STEP = 2
private const val MAX_PAD = 5
private const val WINDOW_EDGE = 2
/** The reload trigger for a given pad, held strictly inside it. */
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
/**
* Which day the split style should select when the grid lands on [month]:
@@ -362,21 +512,73 @@ internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
* Kept pure and separate from the view model so the hysteresis — the reason a
* scroll doesn't re-query the provider on every frame — is testable on its own.
*/
internal fun nextLoadWindow(loaded: IntRange, firstVisible: Int, lastVisible: Int): IntRange? {
internal fun nextLoadWindow(
loaded: IntRange,
firstVisible: Int,
lastVisible: Int,
pad: Int = MAX_PAD,
): IntRange? {
val edge = edgeForPad(pad)
val comfortablyInside =
firstVisible - WINDOW_EDGE >= loaded.first && lastVisible + WINDOW_EDGE <= loaded.last
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
if (comfortablyInside) return null
return (firstVisible - WINDOW_PAD)..(lastVisible + WINDOW_PAD)
return (firstVisible - pad)..(lastVisible + pad)
}
/**
* The continuous grid addresses weeks by an absolute index rather than a
* (month, row) pair, so the list has one stable, gap-free coordinate space to
* scroll through and key its items by. Index 0 is the first week of 1900 under
* the active week-start; the list runs to [CONTINUOUS_WEEK_COUNT].
* The continuous grid addresses months by an absolute index, so the list has one
* stable, gap-free coordinate space to scroll through and key its items by.
* Index 0 is January 1900; the list runs to [continuousMonthCount].
*
* The epoch is deliberately far in the past so every index is non-negative,
* which keeps the LazyColumn's item indices and week indices the same number.
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
* the week-start preference — changing it reflows the rows *inside* a month
* block but never moves the block, so a scroll position stays put.
*/
private const val MONTH_INDEX_EPOCH_YEAR = 1900
private const val MONTH_INDEX_END_YEAR = 2100
internal fun monthIndexOf(ym: YearMonth): Int =
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
MONTH_INDEX_EPOCH_YEAR + index / 12,
Month.entries[index % 12],
)
/** Total months the continuous grid scrolls through (1900 → 2100). */
internal fun continuousMonthCount(): Int =
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
/**
* Each month occupies two LazyColumn items — its sticky header and the block of
* week rows under it — so the two coordinate spaces differ by a factor of two.
* Scrolling to a month means scrolling to its header.
*/
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
/**
* Hold a load window inside the months that actually exist. The pad
* [nextLoadWindow] adds can otherwise push an index past either end of the
* 19002100 span, and [yearMonthForIndex] has no month to hand back for one.
*/
internal fun clampMonthWindow(window: IntRange): IntRange {
val last = continuousMonthCount() - 1
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
}
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
/**
* The Dense style addresses *weeks* by absolute index instead: it has no month
* blocks to key by, and a row's identity has to survive the months around it
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
* so — like the month space — every index is non-negative and the LazyColumn's
* item indices and week indices are the same number.
*
* Unlike month indices these *do* shift with the week-start preference, which is
* why the list state is keyed on it.
*/
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
@@ -389,10 +591,81 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
/** Total weeks the continuous grid scrolls through (1900 → 2100). */
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
/** Which month a Dense row belongs to, for reporting the visible range. */
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
// The row's midweek day: a boundary week is reported as whichever month owns
// most of it, the same rule the title uses.
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
return monthIndexOf(YearMonth(midweek.year, midweek.month))
}
/**
* Every week index the months in [window] touch — the Dense rows one month-window
* load covers. Widened by a week at each end so the boundary rows either side are
* laid out too, rather than flickering as placeholders.
*/
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
.plus(1, DateTimeUnit.MONTH)
.minus(1, DateTimeUnit.DAY)
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
}
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
val first = firstOfMonth(ym)
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
return (leadOffset + daysInMonth + 6) / 7
}
/**
* Strip everything outside [month] from a boundary week row, for the continuous
* style's self-contained month blocks.
*
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
* the grid decides which cells to draw blank — but the neighbour month's events
* are removed: its pills and counts go, and a bar reaching in from (or out into)
* it is cut back to the month's own columns. A bar cut this way keeps a flat
* cap, which is what says "this continues past the block" rather than "it ends
* here".
*/
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
val firstCol = inMonth.indexOfFirst { it }
val lastCol = inMonth.indexOfLast { it }
// Can't happen for a row of the month's own grid, but a caller-proof no-op
// beats an out-of-range crash.
if (firstCol == -1) {
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
}
val spans = week.spans.mapNotNull { span ->
val start = maxOf(span.startCol, firstCol)
val end = minOf(span.endCol, lastCol)
if (start > end) {
null
} else {
span.copy(
startCol = start,
endCol = end,
continuesLeft = span.continuesLeft || start > span.startCol,
continuesRight = span.continuesRight || end < span.endCol,
)
}
}
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
return week.copy(
spans = spans,
timedByDay = week.timedByDay.filterKeys { it in own },
countByDay = week.countByDay.filterKeys { it in own },
)
}
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7

View File

@@ -19,20 +19,34 @@ enum class MonthViewStyle {
Paged,
/**
* One uninterrupted vertical stream of weeks. Each date appears exactly once,
* which is the point — paging repeats a boundary week at both ends (#38).
* Months stacked into one vertical scroll, each a self-contained block under
* its own sticky header: a block shows only its own days, and whitespace
* separates it from the next (#38).
*/
Continuous,
/**
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
* where months flow into each other. Each date appears exactly once — paging
* repeats a boundary week at both ends — and the 1st names its month, the
* only marker of where one ends.
*/
Dense,
/** Compact dots-only grid over a list of the selected day's events (#53). */
Split,
}
/** Both vertically scrolling styles, which share a data window and a list state. */
val MonthViewStyle.isScrolling: Boolean
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
@get:StringRes
val MonthViewStyle.labelRes: Int
get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged
MonthViewStyle.Continuous -> R.string.month_style_continuous
MonthViewStyle.Dense -> R.string.month_style_dense
MonthViewStyle.Split -> R.string.month_style_split
}
@@ -41,5 +55,6 @@ val MonthViewStyle.descriptionRes: Int
get() = when (this) {
MonthViewStyle.Paged -> R.string.month_style_paged_summary
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
MonthViewStyle.Dense -> R.string.month_style_dense_summary
MonthViewStyle.Split -> R.string.month_style_split_summary
}

View File

@@ -1,297 +1,112 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
import de.jeanlucmakiola.calendula.ui.month.labelRes
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.DayOfWeek
/**
* The Month view style chooser (#38, #53). Each option carries a schematic of the
* layout it produces rather than a bare label — the three differ in *shape*, which
* a word like "Continuous" doesn't convey on its own.
* The Month view style chooser (#38, #53).
*
* Tapping applies immediately and the picker stays open, matching the App name
* picker: the change is worth seeing land before leaving.
* A live, scaled-down Month view sits above the options and changes as you pick
* one, so the choice is made by looking rather than by reading "Continuous" and
* guessing. Selecting therefore applies immediately and leaves the picker open —
* closing on tap would hide the very thing the screen is for. Back exits, the
* same as the App name picker, which stays open for the same reason.
*
* Under the preview sits the selected style's own one-line blurb, then the
* family's standard picker shape: connected single-line grouped rows with a
* tonal highlight and [SelectedCheck] on the current one.
*/
@Composable
internal fun MonthViewStylePicker(
selected: MonthViewStyle,
weekStart: DayOfWeek,
onSelect: (MonthViewStyle) -> Unit,
onDismiss: () -> Unit,
) {
val options = MonthViewStyle.entries
val reduceMotion = rememberReduceMotion()
FullScreenPicker(
title = stringResource(R.string.settings_month_view_style),
onDismiss = onDismiss,
predictiveBack = true,
) {
Text(
text = stringResource(R.string.settings_month_view_style_summary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MonthViewStyle.entries.forEach { style ->
MonthStyleOptionCard(
style = style,
selected = style == selected,
onClick = { onSelect(style) },
)
}
}
}
}
/**
* One selectable style: its schematic, name and a line on what it does. Selection
* reads three ways over — border weight, container tint and a check — so it never
* rests on colour alone.
*/
@Composable
private fun MonthStyleOptionCard(
style: MonthViewStyle,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(24.dp)
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outlineVariant
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
Row(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(containerColor)
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(all = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
MonthStyleSchematic(style)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(style.labelRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(style.descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Selection indicator: a filled check when active, an empty ring otherwise.
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
.then(
if (selected) {
Modifier
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
},
),
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp),
)
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "month-style-preview",
) { shown ->
// The preview renders the real grid at phone size and scales it
// down, so its own corners are square — the frame rounds it.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview(
style = shown,
weekStart = weekStart,
height = PREVIEW_HEIGHT,
)
}
}
}
}
}
private val SCHEMATIC_WIDTH = 64.dp
private val SCHEMATIC_HEIGHT = 60.dp
private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp)
/**
* A miniature of the layout, drawn from plain boxes on theme tokens — small
* enough to read as an icon, literal enough that the three are told apart at a
* glance: pages sit side by side, the continuous stream runs off both edges, the
* split style stacks a dotted grid over a list.
*/
@Composable
private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT)
.clip(SCHEMATIC_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.clipToBounds(),
contentAlignment = Alignment.Center,
) {
when (style) {
MonthViewStyle.Paged -> PagedSchematic()
MonthViewStyle.Continuous -> ContinuousSchematic()
MonthViewStyle.Split -> SplitSchematic()
}
}
}
/** Two pages side by side, the second peeking in — a swipe away. */
@Composable
private fun PagedSchematic() {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
SchematicGrid(rows = 4, modifier = Modifier.weight(1f))
// The next page, cropped by the container — the swipe affordance.
SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true)
}
}
/** One column of weeks running off both edges: no page breaks, no repeated days. */
@Composable
private fun ContinuousSchematic() {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Six rows in a 60dp box overflow deliberately: the stream is clipped top
// and bottom rather than fitting neatly, which is the whole idea.
repeat(6) { index ->
SchematicWeekRow(dim = index == 0 || index == 5)
}
}
}
/** A squished dotted grid over the selected day's list. */
@Composable
private fun SplitSchematic() {
Column(
modifier = Modifier.fillMaxWidth().padding(6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
SchematicGrid(rows = 3, dotted = true)
// The day pane: two stand-in event rows.
repeat(2) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)),
// The selected style's own blurb lives here, under its preview, rather
// than on every row — the rows stay single-line and dense, and the words
// describe exactly what is being shown above.
PickerDescription(stringResource(selected.descriptionRes))
options.forEachIndexed { index, style ->
val isSelected = style == selected
GroupedRow(
title = stringResource(style.labelRes),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
// Applies straight away; the preview above is the confirmation,
// so there is nothing to dismiss for.
onClick = { onSelect(style) },
)
}
}
}
/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */
@Composable
private fun SchematicGrid(
rows: Int,
modifier: Modifier = Modifier,
dim: Boolean = false,
dotted: Boolean = false,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp),
) {
repeat(rows) {
if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim)
}
}
}
/** A week as seven filled cells. */
@Composable
private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = if (dim) 0.18f else 0.38f)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(5.dp)
.clip(RoundedCornerShape(1.5.dp))
.background(color),
)
}
}
}
/** A week as seven event dots — the split style's compact grid. */
@Composable
private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) {
val color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = if (dim) 0.18f else 0.38f)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(CircleShape)
.background(color),
)
}
}
}
private val PREVIEW_HEIGHT = 280.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.qs.NewEventTileService
@@ -915,6 +916,8 @@ private fun ViewsScreen(
if (showMonthStyle) {
MonthViewStylePicker(
selected = state.monthViewStyle,
// The preview is a real grid, so it uses the real week start too.
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setMonthViewStyle,
onDismiss = { showMonthStyle = false },
)

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -94,6 +92,7 @@ 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.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -296,8 +295,6 @@ private fun WeekContent(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
@@ -329,20 +326,7 @@ private fun WeekContent(
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
AnimatedContent(
targetState = state,

View File

@@ -0,0 +1,246 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="state_loading">جارٍ التحميل…</string>
<string name="state_retry">إعادة المحاولة</string>
<string name="app_tagline">‌تقويم عصري.</string>
<string name="state_failure_unknown">حدث خطأ ما.</string>
<string name="state_failure_permission">الوصول إلى التقويم مطلوب.</string>
<string name="state_failure_permission_action">منح الوصول</string>
<string name="state_failure_no_calendars">لم يتم تكوين أي تقويمات.</string>
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
<string name="permission_request_button">منح الوصول إلى التقويم</string>
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
<string name="permission_retry_button">أعِد المحاولة</string>
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
<string name="permission_benefit_private_title">يبقى على جهازك</string>
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
<string name="permission_benefit_privacy_body">لا تتبع عن بُعد، لا تحليلات، لا إعلانات.</string>
<string name="permission_privacy_footnote">يبقى على جهازك · لا يوجد إذن بالإنترنت</string>
<string name="month_prev">الشهر السابق</string>
<string name="month_next">الشهر القادم</string>
<string name="month_today_action">اليوم</string>
<string name="month_action_settings">إعدادات</string>
<string name="settings_title">الإعدادات</string>
<string name="settings_theme">الثيم</string>
<string name="settings_theme_system">النظام</string>
<string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string>
<string name="month_open_menu">افتح القائمة</string>
<string name="month_more_actions">المزيد من الإجراءات</string>
<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_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="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_recurring_title">حذف الحدث المتكرر</string>
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
<string name="event_edit_recurring_title">تعديل الحدث المتكرر</string>
<string name="event_delete_failed">تعذّر حذف الحدث</string>
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
<string name="dialog_cancel">إلغاء</string>
<string name="dialog_ok">حسنًا</string>
<string name="event_edit_new_title">حدث جديد</string>
<string name="event_edit_close">إغلاق</string>
<string name="event_edit_save">حفظ</string>
<string name="event_edit_title_hint">إضافة عنوان</string>
<string name="event_edit_managed_hint">تتم الإدارة بواسطة ”%1$s“ — العنوان، التاريخ و التكرار تبقى في مزامنة من جهات اتصالك. التذكيرات، الموقع والملاحظات يمكن لك تعديلها.</string>
<string name="event_edit_starts">يبدأ</string>
<string name="event_edit_ends">ينتهي</string>
<string name="event_edit_error_end_before_start">ينتهي قبل أن يبدأ</string>
<string name="event_edit_error_no_calendar">لا تتوفر تقويمات قابلة للكتابة</string>
<string name="event_edit_save_failed">تعذّر حفظ الحدث</string>
<string name="event_edit_write_denied">Calendula يحتاج إلى صلاحية للكتابة لإنشاء أحداث</string>
<string name="event_edit_add_reminder">إضافة تذكير</string>
<string name="event_edit_remove_reminder">إزالة التذكير</string>
<string name="event_edit_attendees">الضيوف</string>
<string name="event_edit_add_guest">‌إضافة ضيف</string>
<string name="event_edit_add_guest_hint">إضافة ضيف بالبريد الإلكتروني…</string>
<string name="event_edit_add_guest_from_contacts">إضافة من جهات الاتصال</string>
<string name="event_edit_remove_guest">إزالة الضيف</string>
<string name="event_edit_attendee_required">مطلوب</string>
<string name="event_edit_attendee_optional">اختياري</string>
<string name="event_edit_attendees_note_synced">Calendula لا يرسل دعوات. قد يرسل حساب التقويم الخاص بك بريدًا إلكترونيًا إلى الضيوف عند مزامنته.</string>
<string name="event_edit_reminder_custom">مخصص</string>
<string name="reminder_unit_minutes">دقائق</string>
<string name="reminder_unit_hours">ساعات</string>
<string name="event_edit_attendees_note_local">مخزّن على هذا الجهاز. لن يتم إشعار أي شخص.</string>
<string name="event_edit_location_from_contacts">اختر عنوان من جهات الاتصال</string>
<string name="event_edit_add">أضِف</string>
<string name="reminder_unit_days">أيام</string>
<string name="reminder_unit_weeks">أسابيع</string>
<string name="event_edit_color_default">لون التقويم</string>
<string name="event_edit_color_custom">لون مخصص</string>
<string name="event_edit_visibility">مدى الرؤية</string>
<string name="event_edit_availability">التَّوافر</string>
<string name="event_edit_color">اللون</string>
<string name="event_edit_color_reset">إعادة تعيين</string>
<string name="event_edit_color_unsupported">غير مُتوفِّر لهذا التقويم</string>
<string name="event_edit_color_unsupported_hint">هذا التقويم لا ينشر أي مجموعة ألوان. يمكنك السماح بألوان مخصصة لمثل هذه التقويمات في الإعدادات.</string>
<string name="event_edit_color_sync_warning">هذا التقويم قد يزيل أو يستبدله اللون في مزامنته التالية.</string>
<string name="event_edit_conflict_title">الحدث غُيِّ‏‏ر في مكان آخر</string>
<string name="event_edit_conflict_body">أثناء قيامك بالتعديل، هذا الحدث تغيّر — عن طريق المزامنة أو تطبيق آخر. ماذا يجب أن يحدث لتغييراتك؟</string>
<string name="event_edit_conflict_overwrite">حفظ تغييراتي</string>
<string name="event_edit_conflict_discard">تجاهل تغييراتي</string>
<string name="event_edit_gone_title">تم حذف الحدث</string>
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
<string name="event_edit_more_fields">المزيد من الخيارات</string>
<string name="event_access_public">علني</string>
<string name="event_access_default">الافتراضي</string>
<string name="event_availability_busy">مشغول</string>
<string name="event_edit_recurrence_times">مرّات</string>
<string name="recurrence_unit_days">أيام</string>
<string name="recurrence_unit_weeks">أسابيع</string>
<string name="recurrence_unit_months">شهور</string>
<string name="recurrence_unit_years">سنوات</string>
<string name="event_edit_recurrence_ends">ينتهي</string>
<string name="event_edit_recurrence_end_never">أبدًا</string>
<string name="event_edit_recurrence_end_until">في موعد</string>
<string name="event_edit_recurrence_end_count">بعد عدد من المرّات</string>
<string name="event_edit_recurrence_custom">مخصص</string>
<string name="event_edit_recurrence_every">كل</string>
<string name="event_edit_recurrence_none">لا يتكرر</string>
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
<item quantity="many">تم استيراد هذا الحدث مع %1$d تذكير.</item>
<item quantity="other">تم استيراد هذا الحدث مع %1$d تذكير.</item>
</plurals>
<string name="event_edit_error_recurrence_ends_before_start">التكرار ينتهي قبل أن يبدأ الحدث</string>
<string name="import_reminder_prompt_keep">اتركه كما هو</string>
<string name="event_detail_calendar">التقويم</string>
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
<string name="event_detail_description">الوصف</string>
<string name="event_detail_all_day">كل يوم</string>
<string name="event_detail_location">الموقع</string>
<string name="event_detail_attendees">الحضور</string>
<string name="event_detail_recurrence">التكرار</string>
<string name="recurrence_daily">كل يوم</string>
<string name="recurrence_weekly">كل أسبوع</string>
<string name="recurrence_monthly">كل شهر</string>
<string name="recurrence_yearly">كل سنة</string>
<string name="recurrence_every_n_days">كل %1$d أيام</string>
<string name="recurrence_every_n_weeks">‌كل %1$d أسابيع</string>
<string name="recurrence_every_n_months">كل %1$d أشهر</string>
<string name="recurrence_every_n_years">كل %1$d سنوات</string>
<string name="event_detail_not_found">هذا الحدث لم يعد موجودًا.</string>
<string name="recurrence_with_until">%1$s حتى %2$s</string>
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
<string name="event_detail_recurring">حدث مُتكرر</string>
<string name="recurrence_on_days">%1$s في %2$s</string>
<string name="event_attendee_unknown"></string>
<string name="event_attendee_accepted">تم القُبول</string>
<string name="event_attendee_declined">تم الرَفض</string>
<string name="event_attendee_tentative">غير مُؤكِّد</string>
<string name="event_attendee_needs_action">لا يوجد رد</string>
<string name="event_detail_reminders">التذكيرات</string>
<string name="event_detail_timezone">المنطقة الزمنية</string>
<string name="event_status_tentative">غير مُؤكِّد</string>
<string name="event_status_cancelled">ملغي</string>
<string name="event_availability_free">متاح</string>
<string name="event_access_private">خاص</string>
<string name="event_access_confidential">سري</string>
<string name="event_attendee_organizer">المُنظِّم</string>
<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="reminder_at_time">في وقت الحدث</string>
<string name="event_untitled">(بدون عنوان)</string>
<string name="reminder_channel_name">تذكيرات الحدث</string>
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
<string name="reminder_benefit_duplicates_body">إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
<string name="reminder_onboarding_skip_button">ليس الآن</string>
<string name="reminder_action_snooze">تأجيل</string>
<string name="reminder_action_dismiss">تجاهل</string>
<string name="reminder_day_tomorrow">غدًا</string>
<string name="reminder_day_yesterday">يوم أمس</string>
<string name="view_month">شهر</string>
<string name="view_week">أسبوع</string>
<string name="view_day">يوم</string>
<string name="view_agenda">جدول</string>
<string name="drawer_jump_to_date">الانتقال إلى تاريخ</string>
<string name="agenda_today_action">اليوم</string>
<string name="agenda_header_today">اليوم</string>
<string name="agenda_header_tomorrow">غدًا</string>
<string name="view_section">طريقة العرض</string>
<string name="search_hint">البحث عن الأحداث</string>
<string name="search_clear">مسح</string>
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
<string name="search_empty">لا توجد أحداث مطابقة ”%1$s“.</string>
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
<string name="search_back">الرجوع</string>
<string name="search_action">البحث</string>
<string name="widget_agenda_title">القادم</string>
<string name="widget_agenda_label">جدول Calendula</string>
<string name="widget_month_label">شهر Calendula</string>
<string name="widget_new_event">حدث جديد</string>
<string name="widget_needs_permission">افتح Calendula لمنح الوصول إلى التقويم</string>
<string name="widget_prev_month">الشهر السابق</string>
<string name="widget_next_month">الشهر القادم</string>
<string name="widget_today">اليوم</string>
<string name="filter_title">التقويمات</string>
<string name="settings_back">الرجوع</string>
<string name="back">رجوع</string>
<string name="settings_section_appearance">المظهر</string>
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_dynamic_color_unavailable">يتطلب Android 12 أو أحدث</string>
<string name="settings_default_view">طريقة العرض الافتراضية</string>
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
<string name="settings_font_headings">خط العناوين</string>
<string name="settings_font_body">خط النص</string>
<string name="settings_font_system">افتراضي النظام</string>
<string name="settings_font_choose_file">اختر ملفًا…</string>
<string name="settings_font_custom_selected">خط مخصص</string>
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
<string name="settings_week_start">يبدأ الأسبوع في</string>
<string name="settings_week_start_auto">تلقائي</string>
<string name="settings_time_format_auto">تلقائي</string>
<string name="settings_time_format">تنسيق الوقت</string>
<string name="settings_week_numbers">أرقام الأسبوع</string>
<string name="settings_week_numbers_summary">إظهار أرقام أسابيع التقويم في عرض الشهر</string>
<string name="settings_time_format_12h">١٢-ساعة (٢:٠٠ م)</string>
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
<string name="agenda_range_day">اليوم</string>
<string name="settings_agenda_show_today">إظهار اليوم دائمًا</string>
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم.</string>
<string name="agenda_range_this_month">هذا الشهر</string>
<string name="agenda_range_this_week">هذا الأسبوع</string>
<string name="settings_past_events_show">إظهار</string>
<string name="settings_past_events_hide">إخفاء</string>
<string name="settings_agenda_header">جدول</string>
</resources>

View File

@@ -475,19 +475,21 @@
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_week_numbers">Kalenderwochen</string>
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
<string name="settings_week_numbers_summary">Kalenderwochen in Monatsansicht zeigen</string>
<string name="calendars_export_title">Kalender exportieren</string>
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
<string name="calendars_export_action">Exportieren</string>
<string name="calendars_restore_header">Wiederherstellen</string>
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
<string name="calendars_restore_hint">Einträge von einem Backup oder einer anderen Kalender App importieren.</string>
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
<string name="import_done_added_label">Hinzugefügt</string>
<string name="import_done_skipped_label">Duplikate</string>
<string name="import_done_skipped_label">Dopplungen</string>
<string name="import_button">Importieren</string>
<plurals name="import_title_count">
<item quantity="one">%d Ereignis wird importiert</item>
<item quantity="other">%d Ereignisse werden importiert</item>
</plurals>
<string name="event_detail_duplicate">Duplikate</string>
<string name="reminder_day_tomorrow">Morgen</string>
</resources>

View File

@@ -10,27 +10,27 @@
<string name="app_tagline">Un calendrier moderne.</string>
<string name="state_loading">Chargement…</string>
<string name="state_retry">Réessayer</string>
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
<string name="state_failure_unknown">Une erreur s\'est produite.</string>
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
<string name="state_failure_permission_action">Autoriser l\'accès</string>
<string name="state_failure_permission_action">Accorder l\'accès</string>
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
<string name="state_failure_no_calendars_action">Ouvrir les paramètres systèmes du calendrier</string>
<string name="state_failure_provider">Impossible de lire le calendrier.</string>
<string name="permission_rationale_title">Accédez à tous vos évènements, en beauté</string>
<string name="permission_rationale_body">Calendula a besoin d\'accéder à votre calendrier pour afficher et gérer vos évènements. C\'est tout ce qu\'il demande — et rien ne quitte votre appareil.</string>
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
<string name="permission_denied_title">Accès au calendrier refusé.</string>
<string name="permission_denied_title">Accès au calendrier refusé</string>
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
<string name="permission_open_settings_button">Ouvrir les paramètres système</string>
<string name="permission_retry_button">Essayer à nouveau</string>
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
<string name="permission_benefit_private_title">Ne quitte pas votre appareil</string>
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs.</string>
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
<string name="month_prev">Mois précédent</string>
<string name="month_next">Prochain mois</string>
<string name="month_next">Mois suivant</string>
<string name="month_today_action">Aujourd\'hui</string>
<string name="month_more_actions">Plus d\'actions</string>
<string name="month_open_menu">Ouvrir le menu</string>
@@ -153,4 +153,351 @@
<string name="event_attendee_needs_action">Aucune réponse</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Rappels</string>
<string name="permission_benefit_sync_body">Google, CalDAV, local - Tout ce qui est synchronisé sur l\'appareil s\'affiche tout simplement.</string>
<string name="event_status_cancelled">Annulé</string>
<plurals name="duration_minutes">
<item quantity="one">%d minute</item>
<item quantity="many">%d minutes</item>
<item quantity="other">%d minutes</item>
</plurals>
<string name="widget_new_event">Nouvel évènement</string>
<string name="widget_next_month">Mois suivant</string>
<string name="filter_title">Calendriers</string>
<string name="agenda_range_custom">Personnaliser…</string>
<string name="settings_section_calendars">Calendriers</string>
<string name="calendars_title">Calendriers</string>
<string name="calendars_backup_header">Sauvegarde</string>
<string name="calendars_auto_backup_every">Chaque %1$s</string>
<string name="backup_channel_name">Sauvegarde</string>
<string name="shortcut_new_event_short">Nouvel évènement</string>
<string name="qs_tile_new_event_label">Nouvel évènement</string>
<string name="event_detail_duplicate">Dupliquer</string>
<string name="event_detail_timezone">Fuseau horaire</string>
<string name="event_availability_free">Disponible</string>
<string name="event_access_private">Privé</string>
<string name="event_access_confidential">Confidentiel</string>
<string name="event_attendee_organizer">Organisateur</string>
<string name="event_attendee_resource">Ressource</string>
<string name="event_detail_self_response">Votre réponse : %1$s</string>
<string name="reminder_default">Rappel par défaut</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute avant</item>
<item quantity="many">%d minutes avant</item>
<item quantity="other">%d minutes avant</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d heure avant</item>
<item quantity="many">%d heures avant</item>
<item quantity="other">%d heures avant</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d jour avant</item>
<item quantity="many">%d jours avant</item>
<item quantity="other">%d jours avant</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d semaine avant</item>
<item quantity="many">%d semaines avant</item>
<item quantity="other">%d semaines avant</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d heure</item>
<item quantity="many">%d heures</item>
<item quantity="other">%d heures</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d jour</item>
<item quantity="many">%d jours</item>
<item quantity="other">%d jours</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d semaine</item>
<item quantity="many">%d semaines</item>
<item quantity="other">%d semaines</item>
</plurals>
<string name="event_untitled">(Pas de titre)</string>
<string name="reminder_channel_name">Rappels d\'évènement</string>
<string name="reminder_channel_description">Notifications au moment des rappels de vos évènements</string>
<string name="reminder_onboarding_title">Ne manquez jamais un évènement</string>
<string name="reminder_benefit_delivery_title">Vos rappels, libérés</string>
<string name="reminder_benefit_delivery_body">Chaque rappel à vos évènements vous délivre une notification, ponctuelle.</string>
<string name="reminder_onboarding_enable_button">Activer les rappels</string>
<string name="reminder_onboarding_skip_button">Plus tard</string>
<string name="reminder_day_tomorrow">Demain</string>
<string name="reminder_day_yesterday">Hier</string>
<string name="view_month">Mois</string>
<string name="view_week">Semaine</string>
<string name="view_day">Jour</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vue</string>
<string name="drawer_jump_to_date">Aller à ...</string>
<string name="agenda_header_tomorrow">Demain</string>
<string name="agenda_empty_title">Vous êtes à jour</string>
<string name="agenda_no_more_today">Plus d\'évènements aujourd\'hui</string>
<string name="search_action">Rechercher</string>
<string name="search_hint">Rechercher des évènements</string>
<string name="search_idle_hint">Rechercher parmi vos évènements par titre, lieu, ou notes.</string>
<string name="search_empty">Pas d\'évènements correspondant à “%1$s”.</string>
<string name="widget_agenda_title">À venir</string>
<string name="widget_agenda_label">Agenda Calendula</string>
<string name="widget_month_label">Calendula Mois</string>
<string name="widget_refresh">Actualiser</string>
<string name="settings_section_appearance">Apparence</string>
<string name="settings_theme">Thème</string>
<string name="settings_theme_system">Système</string>
<string name="settings_theme_light">Clair</string>
<string name="settings_theme_dark">Sombre</string>
<string name="settings_default_view">Vue par défaut</string>
<string name="settings_dynamic_color">Couleurs dynamiques</string>
<string name="settings_dynamic_color_unavailable">Nécessite Android 12 ou plus récent</string>
<string name="settings_soften_colors">Adoucir les couleurs du calendrier</string>
<string name="settings_soften_colors_summary">Ajuster les couleurs du calendrier et des évènements au thème. Désactiver pour voir les couleurs brutes issues du calendrier.</string>
<string name="settings_font_headings">Police des titres</string>
<string name="settings_font_body">Police du corps</string>
<string name="settings_font_system">Valeur par défaut du système</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="settings_font_choose_file">Sélectionner un fichier…</string>
<string name="settings_font_custom_selected">Police personnalisée</string>
<string name="settings_font_import_failed">Impossible d\'interpréter ce fichier comme police</string>
<string name="settings_week_start_auto">Automatique</string>
<string name="settings_week_numbers">Numéros de semaine</string>
<string name="settings_week_numbers_summary">Afficher les numéros de semaine dans la vue par mois</string>
<string name="event_attendee_optional">Optionnel</string>
<string name="agenda_today_action">Aujourd\'hui</string>
<string name="agenda_header_today">Aujourd\'hui</string>
<string name="search_back">Retour</string>
<string name="widget_prev_month">Mois précédent</string>
<string name="widget_today">Aujourd\'hui</string>
<string name="settings_title">Paramètres</string>
<string name="settings_back">Retour</string>
<string name="agenda_range_day">Aujourd\'hui</string>
<string name="agenda_range_this_week">Cette semaine</string>
<string name="back">Retour</string>
<string name="settings_special_dates_reminders">Rappels</string>
<string name="settings_special_dates_grant">Autoriser l\'accès</string>
<string name="dialog_save">Sauvegarder</string>
<string name="calendars_color_label">Couleur</string>
<string name="import_close">Fermer</string>
<string name="week_number_label">Semaine</string>
<string name="event_status_tentative">Provisoire</string>
<string name="reminder_at_time">Au moment de lévénement</string>
<string name="reminder_onboarding_body">Android naffiche pas de rappels dévénements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier?</string>
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
<string name="reminder_action_snooze">Snooze</string>
<string name="reminder_action_dismiss">Ignorer</string>
<string name="search_clear">Effacer</string>
<string name="widget_needs_permission">Ouvrez Calendula pour accorder laccès au calendrier</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_week_start">La semaine commence le</string>
<string name="settings_time_format">Format de l\'heure</string>
<string name="settings_time_format_auto">Automatique</string>
<string name="settings_time_format_12h">12 heures (2:00 PM)</string>
<string name="settings_time_format_24h">24 heures (14:00)</string>
<string name="settings_hour_lines">Lignes d\'heures</string>
<string name="settings_hour_lines_summary">Afficher une ligne de séparation à chaque heure dans la vue de la semaine et du jour</string>
<string name="settings_dim_completed">Dim événements terminés</string>
<string name="settings_dim_completed_summary">Estomper les événements qui se sont déjà terminés dans la vue du mois et de la semaine</string>
<string name="settings_past_events">Evènements passés</string>
<string name="settings_past_events_show">Montrer</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Cacher</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Plage dagenda</string>
<string name="settings_agenda_range_hint">Jusquà quelle date lécran Agenda liste les événements.</string>
<string name="settings_agenda_widget_range">Plage de widgets Agenda</string>
<string name="settings_agenda_widget_range_hint">Jusquà quelle heure le widget de lécran daccueil de lagenda liste les événements.</string>
<string name="settings_agenda_show_today">Toujours montrer aujourdhui</string>
<string name="settings_agenda_show_today_hint">Gardez aujourdhui en tête de lagenda et de son widget, même sil ne reste plus rien aujourdhui.</string>
<string name="settings_agenda_range_bar">Barre de rang</string>
<string name="settings_agenda_range_bar_hint">Afficher une barre en haut de lagenda nommant les dates affichées, avec un bouton pour changer la plage de la session</string>
<string name="agenda_range_this_month">Ce mois</string>
<string name="agenda_range_week">Les 7 jours prochains</string>
<string name="agenda_range_month">Les 30 jours prochains</string>
<string name="agenda_range_custom_hint">Jours</string>
<string name="agenda_range_override_hint">Juste pour linstant — réinitialise votre plage denregistrement lorsque vous rouvrez Calendula.</string>
<string name="agenda_range_showing_label">Afficher tous les événements à venir pour</string>
<plurals name="agenda_range_days">
<item quantity="one">%d jour</item>
<item quantity="many">%d jours</item>
</plurals>
<string name="settings_section_views">Vues</string>
<string name="settings_quick_switch_header">Bouton dinterrupteur rapide</string>
<string name="settings_quick_switch_hint">Choisissez les vues que le bouton en haut à droite fait défiler, puis faites-les glisser pour les réorganiser. Les vues désactivées restent accessibles depuis le menu de navigation.</string>
<string name="settings_drawer_order_header">Menu navigation</string>
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
<string name="settings_section_event_form">Nouveau formulaire dévénement</string>
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur dévénement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. Cest une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
<string name="settings_section_notifications">Notifications</string>
<string name="settings_reminders">rappels d\'événements</string>
<string name="settings_reminders_hint">Vous voyez des rappels deux fois? Une autre application de calendrier les publie aussi — désactivez-les dans lun des deux.</string>
<string name="settings_default_reminder">rappel par défaut</string>
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
<string name="settings_allday_reminder_time_hint">Les rappels pour les événements dune journée entière se déclenchent à %1$s</string>
<string name="reminder_none">Aucun</string>
<string name="reminder_use_default">Utiliser le rappel par défaut</string>
<string name="reminder_custom_amount">montant</string>
<string name="reminder_custom_set">fixer</string>
<string name="settings_calendar_reminders_title">Rappels par calendrier</string>
<string name="settings_calendar_reminders_hint">Remplacer le paramètre par défaut par calendrier, séparément pour les événements planifiés et sur une journée. Un calendrier peut conserver le paramètre par défaut, labandonner ou définir le sien.</string>
<string name="settings_calendar_reminder_inherits">Défaut (%1$s)</string>
<string name="settings_reliable_delivery">livraison fiable</string>
<string name="settings_reliable_delivery_hint">Android peut retarder les rappels pour économiser la batterie. Exempter Calendula afin quils arrivent à temps.</string>
<string name="settings_reliable_delivery_exempt">Exempté de loptimisation de la batterie — les rappels arrivent à temps.</string>
<string name="settings_snooze_duration">Durée de répétition</string>
<string name="settings_manage_calendars">gérer les calendriers</string>
<string name="settings_manage_calendars_hint">Créer des calendriers locaux, gérer les calendriers synchronisés</string>
<string name="settings_section_language">Langue</string>
<string name="settings_language">langue de l\'application</string>
<string name="settings_language_auto">par défaut du système</string>
<string name="settings_translate">aider à traduire</string>
<string name="settings_translate_hint">Ajouter ou améliorer une langue sur Weblate</string>
<string name="settings_appearance_subtitle">Thème, vue par défaut, début de la semaine</string>
<string name="settings_views_subtitle">Bouton de sélection rapide et ordre du menu</string>
<string name="settings_event_form_subtitle">Champs par défaut pour les nouveaux événements</string>
<string name="settings_notifications_subtitle">rappels d\'événement</string>
<string name="settings_special_dates_subtitle">Anniversaires et dates de naissance des contacts</string>
<string name="settings_section_special_dates">Contact, dates spéciales</string>
<string name="settings_special_dates_enable">Afficher les dates de contact</string>
<string name="settings_special_dates_enable_hint">Reprendre les anniversaires et autres dates de vos contacts dans des calendriers locaux. Lit les contacts uniquement sur cet appareil — rien nest téléchargé et vos contacts ne sont jamais modifiés.</string>
<string name="settings_special_dates_type_birthday">Anniversaires</string>
<string name="settings_special_dates_type_anniversary">Anniversaires</string>
<string name="settings_special_dates_type_custom">Autres dates</string>
<string name="settings_special_dates_template">Format du titre</string>
<string name="settings_special_dates_template_hint">Utilisez {name} pour le contact et {year} pour lannée (lannée de naissance ou lannée de début dun anniversaire, masqué si inconnu).</string>
<string name="settings_special_dates_show_year">Montrer l\'année</string>
<string name="settings_special_dates_show_year_hint">Inclure {year} dans les titres lorsque l\'année est connue</string>
<string name="settings_special_dates_sync_now">Synchroniser maintenant</string>
<string name="settings_special_dates_never_synced">Pas encore synchronisé</string>
<string name="settings_special_dates_last_synced">Dernière synchronisation %1$s</string>
<string name="settings_special_dates_calendar_hint">Définissez la couleur et la visibilité de chaque calendrier dans les paramètres des calendriers.</string>
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
<string name="settings_special_dates_paused_title">suspendu</string>
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
<string name="settings_special_dates_disable_title">Désactiver les dates de contact?</string>
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
<string name="settings_special_dates_disable_confirm">désactiver</string>
<string name="settings_section_about">A propos</string>
<string name="settings_license">Licence</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">par Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_about_support">soutenir le développement</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Icône de lapplication Calendula</string>
<string name="settings_report_problem">Rapporter un problème</string>
<string name="settings_report_problem_hint">Envoyer un rapport de plantage ou ouvrir le suivi des problèmes</string>
<string name="calendars_local_header">Vos calendriers</string>
<string name="calendars_local_empty">Pas encore de calendrier local. Créez-en un pour conserver les événements uniquement sur cet appareil.</string>
<string name="calendars_add">Ajouter un calendrier</string>
<string name="calendars_disable_hint">Désactivez un calendrier pour le retirer de lapplication, ses événements, ses filtres et ses sélecteurs. Rien nest supprimé et vous pouvez le réactiver à tout moment ici.</string>
<string name="calendars_show_in_app_a11y">Afficher « %1$s » dans lapplication</string>
<string name="calendars_synced_header">Calendriers synchronisés</string>
<string name="calendars_synced_hint">Ils proviennent de comptes sur votre appareil. Créez et modifiez-les dans leur propre application.</string>
<string name="calendars_manage_in_app">Gérer dans lapplication</string>
<string name="calendars_account_menu_a11y">Plus doptions pour %1$s</string>
<string name="calendars_enable_all">Tout activer</string>
<string name="calendars_disable_all">Tout désactiver</string>
<string name="calendars_add_account">Ajouter un compte</string>
<string name="calendars_new_title">Nouveau calendrier</string>
<string name="calendars_edit_title">Editer un agenda</string>
<string name="calendars_name_label">Nom</string>
<string name="calendars_description_hint">Ajouter une description</string>
<string name="calendars_delete_confirm_title">Supprimer un calendrier?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
<string name="calendars_backup_action">Exporter en tant que fichier .ics</string>
<string name="calendars_export_title">Exporter des calendriers</string>
<string name="calendars_export_hint">Choisissez les calendriers à inclure dans le fichier .ics.</string>
<string name="calendars_export_action">Exporter</string>
<string name="calendars_restore_header">Restaurer</string>
<string name="calendars_restore_action">Restaurer à partir du fichier .ics</string>
<string name="calendars_restore_hint">Importez des événements à partir dune sauvegarde ou dune autre application de calendrier.</string>
<string name="calendars_auto_backup">sauvegarde automatique</string>
<string name="calendars_auto_backup_hint">Exportez périodiquement vos calendriers locaux dans un dossier en tant que fichier .ics.</string>
<string name="calendars_auto_backup_folder">dossier de sauvegarde</string>
<string name="calendars_auto_backup_folder_unset">Cliquez pour choisir un dossier</string>
<string name="calendars_auto_backup_interval">Intervalle</string>
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
<string name="backup_failed_text">Calendula na pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
<string name="calendars_backup_failed">Impossible dexporter la sauvegarde.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Événement %d exporté.</item>
<item quantity="many">Événements %d exportés.</item>
<item quantity="other">Événements %d exportés.</item>
</plurals>
<string name="import_title">importer des événements</string>
<string name="import_target_header">ajouter au calendrier</string>
<string name="import_empty">Aucun événement trouvé dans ce fichier.</string>
<string name="import_failed">Impossible de lire ce fichier.</string>
<string name="import_no_calendar">Pas de calendrier lisible à importer. Créez dabord un calendrier local.</string>
<string name="import_done_title">Importation terminée</string>
<string name="import_done_dedup_note">Les événements déjà présents dans le calendrier ont été ignorés.</string>
<string name="import_done_added_label">ajouté</string>
<string name="import_done_skipped_label">Dupliquer</string>
<string name="import_warning_recurrence">Certaines occurrences modifiées dévénements récurrents ont été ignorées.</string>
<string name="import_warning_no_start">Un événement sans heure de début a été ignoré.</string>
<string name="import_warning_attendees">Les listes dinvités nont pas été importées.</string>
<string name="import_warning_timezone">Un fuseau horaire inconnu est revenu sur celui de votre appareil.</string>
<string name="import_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importation de lévénement %d</item>
<item quantity="many">Importation des événements %d</item>
<item quantity="other">Importation des événements %d</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d événement dans ce fichier.</item>
<item quantity="many">%d événements dans ce fichier.</item>
<item quantity="other">%d événements dans ce fichier.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importer lévénement %d</item>
<item quantity="many">Importer les événements %d</item>
<item quantity="other">Importer les événements %d</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Événement %d importé.</item>
<item quantity="many">Événements %d importés.</item>
<item quantity="other">Événements %d importés.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Ignoré %d déjà dans ce calendrier.</item>
<item quantity="many">Ignorés %d déjà dans ce calendrier.</item>
<item quantity="other">Ignorés %d déjà dans ce calendrier.</item>
</plurals>
<string name="shortcut_new_event_long">créer un événement</string>
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
<string name="crash_dialog_title">%1$s a planté</string>
<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>
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
<string name="special_dates_default_title_anniversary">{name}, anniversaire ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -17,7 +17,7 @@
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
@@ -228,7 +228,7 @@
<string name="widget_new_event">Nuovo evento</string>
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
<string name="widget_prev_month">Mese precedente</string>
<string name="filter_title">Calendari</string>
<string name="filter_title">Calendario</string>
<string name="settings_section_appearance">Aspetto</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Sistema</string>
@@ -253,18 +253,18 @@
<string name="settings_past_events_hide">Nascondi</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
<string name="settings_agenda_range_hint">Intervallo di visualizzazione degli eventi nell\'Agenda.</string>
<string name="settings_agenda_widget_range">Intervallo di visualizzazione degli eventi nel widget Agenda</string>
<string name="settings_agenda_widget_range_hint">Intervallo di visualizzazione degli eventi nel widget Agenda.</string>
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
<string name="settings_agenda_range_bar_hint">Nella vista Agenda, mostra una barra che indica l\'intervallo di date visualizzato, con un pulsante per cambiarlo</string>
<string name="agenda_range_day">Oggi</string>
<string name="agenda_range_this_month">Questo mese</string>
<string name="agenda_range_week">Prossimi 7 giorni</string>
<string name="agenda_range_month">Prossimi 30 giorni</string>
<string name="agenda_range_custom">Personalizza…</string>
<string name="agenda_range_custom_hint">Giorni</string>
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
<string name="agenda_range_override_hint">L\'intervallo viene modificato solo questa volta. La prossima volta che apri Calendula verrà visualizzato l\'intervallo di default.</string>
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
<plurals name="agenda_range_days">
<item quantity="one">%d giorno</item>
@@ -272,26 +272,26 @@
<item quantity="other">%d giorni</item>
</plurals>
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
<string name="settings_section_notifications">Notifiche</string>
<string name="settings_form_fields_hint">Campi di default, tutti gli altri saranno in \"Altri campi\"</string>
<string name="settings_autofocus_title">Focus sul titolo</string>
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
<string name="settings_color_unsupported">Abilita i colori sui calendari anche sui calendari che non li supportano</string>
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non comunicano il set di colori di cui sono dotati; se assegni colori personalizzati agli eventi può succedere che vengano persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
<string name="settings_section_notifications">Promemoria</string>
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
<string name="settings_default_reminder_allday">Promemoria per eventi giornalieri</string>
<string name="settings_allday_reminder_time">Orario di invio dei promemoria degli eventi giornalieri</string>
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono inviati alle %1$s</string>
<string name="reminder_none">Nessuno</string>
<string name="reminder_use_default">Usa il promemoria standard</string>
<string name="reminder_use_default">Promemoria standard</string>
<string name="reminder_custom_amount">Valore</string>
<string name="reminder_custom_set">Imposta</string>
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
<string name="settings_calendar_reminders_hint">Sostituisci i promemoria standard con promemoria specifici per ogni calendario, dettagliando quelli per eventi standard ed quelli per eventi giornalieri. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Notifiche affidabili</string>
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per migliorare la durata batteria. Escludi Calendula dall\'ottimizzazione della batteria per far arrivare puntuali i promemoria.</string>
<string name="settings_reliable_delivery_exempt">I promemoria arriveranno puntuali.</string>
<string name="settings_snooze_duration">Durata del posticipo</string>
<string name="settings_section_calendars">Calendari</string>
<string name="settings_manage_calendars">Gestisci calendari</string>
@@ -314,17 +314,17 @@
<string name="settings_report_problem">Segnala un problema</string>
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
<string name="calendars_title">Calendari</string>
<string name="calendars_local_header">I tuoi calendari</string>
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
<string name="calendars_local_header">Calendari locali</string>
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno con eventi salvati solo su questo dispositivo.</string>
<string name="calendars_add">Aggiungi calendario</string>
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
<string name="calendars_disable_hint">Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
<string name="calendars_synced_header">Calendari sincronizzati</string>
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
<string name="calendars_synced_hint">Questi calendari vengono dagli account sincronizzati sul tuo dispositivo. Puoi modificarli o crearne di nuovi dall\'app di sincronizzazione.</string>
<string name="calendars_manage_in_app">Gestisci in app</string>
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
<string name="calendars_enable_all">Attiva tutti</string>
<string name="calendars_disable_all">Disattiva tutti</string>
<string name="calendars_enable_all">Mostra tutti</string>
<string name="calendars_disable_all">Nascondi tutti</string>
<string name="calendars_add_account">Aggiungi account</string>
<string name="calendars_new_title">Nuovo calendario</string>
<string name="calendars_edit_title">Modifica calendario</string>
@@ -350,7 +350,7 @@
<string name="backup_channel_name">Backup</string>
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
<string name="backup_failed_title">Backup automatico fallito</string>
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Verifica quale sia la cartella di backup nelle Impostazioni.</string>
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Esportato %d evento.</item>
@@ -390,17 +390,17 @@
</plurals>
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
<string name="qs_tile_new_event_label">Nuovo evento</string>
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
<string name="crash_dialog_title">Calendula è crashato</string>
<string name="crash_dialog_message">Calendula 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="settings_qs_tile">Aggiungi un riquadro nelle Impostazioni Rapide</string>
<string name="settings_qs_tile_hint">Aggiungi un riquadro \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
<string name="crash_dialog_title">%1$s crashato</string>
<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">Calendula 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 è sugli Appunti.</string>
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. 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%1$s\n</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>
@@ -414,14 +414,14 @@
<string name="settings_title">Impostazioni</string>
<string name="settings_back">Indietro</string>
<string name="agenda_range_this_week">Questa settimana</string>
<string name="settings_reminders">Promemoria evento</string>
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_reminders">Promemoria per gli eventi</string>
<string name="settings_default_reminder">Promemoria per eventi standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_system">Lingua di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
@@ -446,11 +446,11 @@
<string name="settings_special_dates_show_year_hint">Include {year} nei titoli quando è noto</string>
<string name="settings_special_dates_sync_now">Sincronizza adesso</string>
<string name="settings_special_dates_never_synced">Ancora non sincronizzato</string>
<string name="settings_special_dates_last_synced">%1$s è un tempo relativo, per esempio \"5 minuti fa\"</string>
<string name="settings_special_dates_last_synced">Ultima sincronizzazione %1$s</string>
<string name="settings_special_dates_calendar_hint">Imposta colore e visibilità di ogni calendario nelle impostazioni dei calendari.</string>
<string name="settings_special_dates_paused_title">In pausa</string>
<string name="settings_special_dates_paused_hint">Calendula non può più leggere i tuoi contatti, quindi questi calendari non vengono aggiornati.</string>
<string name="settings_special_dates_grant">Concedi accesso</string>
<string name="settings_special_dates_grant">Concedi l\'accesso</string>
<string name="settings_special_dates_disable_title">Disattivare date dei contatti?</string>
<string name="settings_special_dates_disable_all_message">Questo cancella i calendari delle date dei contatti ed i loro eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
@@ -466,4 +466,39 @@
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="event_detail_duplicate">Duplica</string>
<string name="import_reminder_prompt_title">Vuoi applicare i promemoria di default?</string>
<string name="import_reminder_prompt_body_none">Questo evento è stato importato senza nessun promemoria.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Questo evento è stato importato con %1$d promemoria.</item>
<item quantity="many">Questo evento è stato importato con %1$d promemoria.</item>
<item quantity="other">Questo evento è stato importato con %1$d promemoria.</item>
</plurals>
<string name="import_reminder_prompt_apply">Applica promemoria di default</string>
<string name="import_reminder_prompt_keep">Lascia invariato</string>
<string name="reminder_day_tomorrow">Domani</string>
<string name="reminder_day_yesterday">Ieri</string>
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
<string name="back">Indietro</string>
<string name="settings_soften_colors">Attenua i colori del calendario</string>
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
<string name="settings_week_numbers">Numeri di settimana</string>
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
<string name="settings_agenda_show_today_hint">Mantieni Oggi in alto nell\'Agenda e nel widget, anche se non ci sono altri eventi in giornata.</string>
<string name="calendars_export_title">Esporta calendari</string>
<string name="calendars_export_hint">Scegli quali calendari includere nel file .ics.</string>
<string name="calendars_export_action">Esporta</string>
<string name="calendars_restore_header">Ripristina</string>
<string name="calendars_restore_action">Ripristina da un file .ics</string>
<string name="calendars_restore_hint">Importa eventi da un backup o da un\'altra app.</string>
<string name="import_done_dedup_note">Gli eventi già presenti nel calendario sono stati saltati.</string>
<string name="import_done_added_label">Aggiunto</string>
<string name="import_done_skipped_label">Duplicati</string>
<string name="import_button">Importa</string>
<plurals name="import_title_count">
<item quantity="one">Importando %d evento</item>
<item quantity="many">Importando %d eventi</item>
<item quantity="other">Importando %d eventi</item>
</plurals>
</resources>

View File

@@ -47,11 +47,11 @@
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
<string name="event_delete_title">Usunąć wydarzenie?</string>
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
<string name="event_delete_recurring_title">Usuń wydarzenie cykliczne</string>
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
<string name="event_edit_recurring_title">Edytuj wydarzenie cykliczne</string>
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
<string name="dialog_cancel">Anuluj</string>
@@ -136,7 +136,7 @@
<string name="event_detail_description">Opis</string>
<string name="event_detail_attendees">Uczestnicy</string>
<string name="event_detail_recurrence">Powtarzanie</string>
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
<string name="event_detail_recurring">Wydarzenie cykliczne</string>
<string name="recurrence_daily">Codziennie</string>
<string name="recurrence_weekly">Co tydzień</string>
<string name="recurrence_monthly">Co miesiąc</string>
@@ -297,4 +297,224 @@
<string name="settings_agenda_range">Zakres agendy</string>
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
<string name="settings_agenda_widget_range_hint">Jak daleko w przód wyświetlane są wydarzenia w widżecie agendy na ekranie domowym.</string>
<string name="settings_agenda_range_bar">Pasek zakresu</string>
<string name="settings_agenda_range_bar_hint">Wyświetl u góry agendy pasek z zakresem widocznych dat oraz przyciskiem umożliwiającym zmianę zakresu na czas trwania sesji</string>
<string name="agenda_range_day">Dzisiaj</string>
<string name="agenda_range_this_week">Bieżący tydzień</string>
<string name="agenda_range_this_month">Bieżący miesiąc</string>
<string name="agenda_range_week">Następne 7 dni</string>
<string name="agenda_range_month">Następne 30 dni</string>
<string name="agenda_range_custom">Niestandardowy…</string>
<string name="agenda_range_custom_hint">Dni</string>
<string name="agenda_range_override_hint">Ustawienie tymczasowe — po ponownym otwarciu aplikacji Calendula przywrócony zostanie zapisany zakres.</string>
<string name="agenda_range_showing_label">Wyświetlanie wszystkich nadchodzących wydarzeń dla</string>
<plurals name="agenda_range_days">
<item quantity="one">%d dzień</item>
<item quantity="few">%d dni</item>
<item quantity="many">%d dni</item>
<item quantity="other">%d dni</item>
</plurals>
<string name="settings_section_views">Widoki</string>
<string name="settings_quick_switch_header">Przycisk szybkiego przełączania</string>
<string name="settings_quick_switch_hint">Wybierz widoki, między którymi można przełączać się przyciskiem w prawym górnym rogu, i przeciągnij je, aby zmienić ich kolejność. Wyłączone widoki pozostaną dostępne w menu nawigacyjnym.</string>
<string name="settings_drawer_order_header">Menu nawigacyjne</string>
<string name="settings_drawer_order_hint">Przeciągnij aby zmienić kolejność widoków w menu nawigacyjnym.</string>
<string name="reorder_drag_handle">Przeciągnij by zmienić kolejność</string>
<string name="settings_section_event_form">Formularz nowego wydarzenia</string>
<string name="settings_form_fields_hint">Pola wyświetlane domyślnie — cała reszta znajduje się pod przyciskiem „Więcej pól”</string>
<string name="settings_autofocus_title">Ustaw kursor na tytule nowego wydarzenia</string>
<string name="settings_autofocus_title_hint">Kiedy tworzysz nowe wydarzenie, umieść kursor w polu tytułu i od razu otwórz klawiaturę.</string>
<string name="settings_color_unsupported">Zezwalaj na kolory w nieobsługiwanych kalendarzach</string>
<string name="settings_color_unsupported_hint">Niektóre kalendarze (np. niektóre CalDAV) nie udostępniają zestawu kolorów; niestandardowy kolor wydarzenia może zostać utracony lub nadpisany przy następnej synchronizacji. Jest to ograniczenie tych kalendarzy, a nie coś, co Calendula może naprawić.</string>
<string name="settings_section_notifications">Powiadomienia</string>
<string name="settings_reminders">Przypomnienia o wydarzeniach</string>
<string name="settings_reminders_hint">Widzisz podwójne przypomnienia? Inna aplikacja kalendarza również je wyświetla — wyłącz je w jednej z nich.</string>
<string name="settings_default_reminder">Domyślne przypomnienie</string>
<string name="settings_default_reminder_allday">Wydarzenia całodniowe</string>
<string name="settings_allday_reminder_time">Godzina przypomnienia o wydarzeniach całodniowych</string>
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s</string>
<string name="reminder_none">Brak</string>
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
<string name="reminder_custom_amount">jednostek</string>
<string name="reminder_custom_set">Ustaw</string>
<string name="settings_calendar_reminders_title">Przypomnienia dla poszczególnych kalendarzy</string>
<string name="settings_calendar_reminders_hint">Zastąp ustawienia domyślne dla wybranych kalendarzy — oddzielnie dla wydarzeń terminowych i całodniowych. Kalendarz może zachować ustawienie domyślne, odrzucić je lub ustawić własne.</string>
<string name="settings_calendar_reminder_inherits">Domyślne (%1$s)</string>
<string name="settings_reliable_delivery">Niezawodne dostarczanie</string>
<string name="settings_reliable_delivery_hint">Android może opóźniać przypomnienia, aby oszczędzać baterię. Dodaj Calendula do wyjątków, żeby przychodziły na czas.</string>
<string name="settings_reliable_delivery_exempt">Wyłączono optymalizację baterii — przypomnienia przychodzą na czas.</string>
<string name="settings_snooze_duration">Długość drzemki</string>
<string name="settings_section_calendars">Kalendarze</string>
<string name="settings_manage_calendars">Zarządzaj kalendarzami</string>
<string name="settings_manage_calendars_hint">Twórz lokalne kalendarze; zarządzaj synchronizowanymi</string>
<string name="settings_section_language">Język</string>
<string name="settings_language">Język aplikacji</string>
<string name="settings_language_auto">Domyślny systemu</string>
<string name="settings_translate">Pomóż w tłumaczeniu</string>
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
<string name="settings_views_subtitle">Kolejność przycisku szybkiego przełączania oraz menu</string>
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
<string name="settings_section_special_dates">Szczególne daty kontaktów</string>
<string name="settings_special_dates_enable">Pokaż daty kontaktów</string>
<string name="settings_special_dates_enable_hint">Zsynchronizuj urodziny i inne daty z kontaktów z lokalnymi kalendarzami. Odczytuje kontakty wyłącznie z tego urządzenia — nic nie jest przesyłane, a Twoje kontakty nigdy nie ulegają zmianie.</string>
<string name="settings_special_dates_type_birthday">Urodziny</string>
<string name="settings_special_dates_type_anniversary">Rocznice</string>
<string name="settings_special_dates_type_custom">Inne daty</string>
<string name="settings_special_dates_template">Format tytułu</string>
<string name="settings_special_dates_template_hint">Użyj {name} dla nazwy kontaktu i {year} dla roku (rok urodzenia lub rok rozpoczęcia rocznicy; ukryty, gdy jest nieznany).</string>
<string name="settings_special_dates_reminders">Przypomnienia</string>
<string name="settings_special_dates_show_year">Pokaż rok</string>
<string name="settings_special_dates_show_year_hint">Uwzględniaj {year} w tytułach, jeśli jest znany</string>
<string name="settings_special_dates_sync_now">Synchronizuj teraz</string>
<string name="settings_special_dates_never_synced">Jeszcze nie zsynchronizowano</string>
<string name="settings_special_dates_last_synced">Ostatnio zsynchronizowano %1$s</string>
<string name="settings_special_dates_calendar_hint">Ustaw kolor i widoczność każdego kalendarza w sekcji Kalendarze.</string>
<string name="settings_calendar_reminders_managed_hint">Ustaw w sekcji Szczególne daty kontaktów</string>
<string name="settings_special_dates_paused_title">Wstrzymano</string>
<string name="settings_special_dates_paused_hint">Aplikacja Calendula nie ma już dostępu do Twoich kontaktów, więc te kalendarze nie są aktualizowane.</string>
<string name="settings_special_dates_grant">Przyznaj dostęp</string>
<string name="settings_special_dates_disable_title">Wyłączyć daty kontaktów?</string>
<string name="settings_special_dates_disable_all_message">To spowoduje usunięcie kalendarzy ze szczególnymi datami kontaktów oraz ich wydarzeń. Wszelkie dodane do nich przypomnienia lub notatki zostaną utracone.</string>
<string name="settings_special_dates_disable_type_message">To spowoduje usunięcie kalendarza „%1$s” wraz z jego wydarzeniami. Wszelkie dodane do nich przypomnienia lub notatki zostaną utracone.</string>
<string name="settings_special_dates_disable_confirm">Wyłącz</string>
<string name="dialog_save">Zapisz</string>
<string name="settings_section_about">O aplikacji</string>
<string name="settings_license">Licencja</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">autorstwa Jean-Luc Makiola</string>
<string name="settings_about_source">Kod źródłowy</string>
<string name="settings_about_support">Wesprzyj rozwój</string>
<string name="settings_about_version">Wersja %1$s</string>
<string name="settings_about_logo_desc">Ikona aplikacji Calendula</string>
<string name="settings_report_problem">Zgłoś problem</string>
<string name="settings_report_problem_hint">Wyślij raport o awarii lub otwórz system zgłoszeń</string>
<string name="calendars_title">Kalendarze</string>
<string name="calendars_local_header">Twoje kalendarze</string>
<string name="calendars_local_empty">Brak lokalnych kalendarzy. Utwórz kalendarz, aby zapisywać wydarzenia tylko na tym urządzeniu.</string>
<string name="calendars_add">Dodaj kalendarz</string>
<string name="calendars_disable_hint">Wyłącz kalendarz, aby ukryć go w aplikacji — wraz z jego wydarzeniami, filtrami i selektorami. Nic nie zostanie usunięte, a w każdej chwili możesz go tutaj ponownie włączyć.</string>
<string name="calendars_show_in_app_a11y">Pokaż „%1$s” w aplikacji</string>
<string name="calendars_synced_header">Synchronizowane kalendarze</string>
<string name="calendars_synced_hint">Pochodzą z kont na Twoim urządzeniu. Możesz je tworzyć i edytować w powiązanych z nimi aplikacjach.</string>
<string name="calendars_manage_in_app">Zarządzaj w aplikacji</string>
<string name="calendars_account_menu_a11y">Więcej opcji dla %1$s</string>
<string name="calendars_enable_all">Włącz wszystkie</string>
<string name="calendars_disable_all">Wyłącz wszystkie</string>
<string name="calendars_add_account">Dodaj konto</string>
<string name="calendars_new_title">Nowy kalendarz</string>
<string name="calendars_edit_title">Edytuj kalendarz</string>
<string name="calendars_name_label">Nazwa</string>
<string name="calendars_color_label">Kolor</string>
<string name="calendars_description_hint">Dodaj opis</string>
<string name="calendars_delete_confirm_title">Usunąć kalendarz?</string>
<string name="calendars_delete_confirm_message">„%1$s” oraz wszystkie powiązane z nim wydarzenia zostaną trwale usunięte z tego urządzenia.</string>
<string name="calendars_write_error">Nie udało się zapisać zmiany.</string>
<string name="calendars_backup_header">Kopia zapasowa</string>
<string name="calendars_backup_hint">Kalendarze lokalne nie są nigdzie synchronizowane, więc wyeksportuj je do pliku .ics, aby zachować kopię.</string>
<string name="calendars_backup_action">Eksportuj jako plik .ics</string>
<string name="calendars_export_title">Eksportuj kalendarze</string>
<string name="calendars_export_hint">Wybierz kalendarze, które mają zostać uwzględnione w pliku .ics.</string>
<string name="calendars_export_action">Eksportuj</string>
<string name="calendars_restore_header">Przywróć</string>
<string name="calendars_restore_action">Przywróć z pliku .ics</string>
<string name="calendars_restore_hint">Importuj wydarzenia z kopii zapasowej lub innej aplikacji kalendarza.</string>
<string name="calendars_auto_backup">Automatyczna kopia zapasowa</string>
<string name="calendars_auto_backup_hint">Okresowo eksportuj swoje lokalne kalendarze do folderu jako plik .ics.</string>
<string name="calendars_auto_backup_folder">Folder kopii zapasowej</string>
<string name="calendars_auto_backup_folder_unset">Dotknij by wybrać folder</string>
<string name="calendars_auto_backup_interval">Okres</string>
<string name="calendars_auto_backup_every">Co %1$s</string>
<string name="calendars_auto_backup_interval_min">Minimum 30 minut.</string>
<string name="calendars_auto_backup_status_never">Nie ma jeszcze automatycznych kopii zapasowych</string>
<string name="calendars_auto_backup_status_ok">Ostatnia kopia zapasowa: %1$s</string>
<string name="calendars_auto_backup_status_failed">Ostatnia nieudana kopia zapasowa: %1$s</string>
<string name="backup_channel_name">Kopia zapasowa</string>
<string name="backup_channel_description">Ostrzega, jeśli automatyczne kopie zapasowe wielokrotnie się nie powiodą.</string>
<string name="backup_failed_title">Automatyczna kopia zapasowa nie powiodła się</string>
<string name="backup_failed_text">Calendula nie mogła zapisać pliku kopii zapasowej. Sprawdź folder kopii zapasowej w Ustawieniach.</string>
<string name="calendars_backup_failed">Nie udało się wyeksportować kopii zapasowej.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Wyeksportowano %d wydarzenie.</item>
<item quantity="few">Wyeksportowano %d wydarzenia.</item>
<item quantity="many">Wyeksportowano %d wydarzeń.</item>
<item quantity="other">Wyeksportowano %d wydarzeń.</item>
</plurals>
<string name="import_title">Importuj wydarzenia</string>
<string name="import_target_header">Dodaj do kalendarza</string>
<string name="import_empty">Nie znaleziono wydarzeń w tym w pliku.</string>
<string name="import_failed">Nie udało się odczytać tego pliku.</string>
<string name="import_no_calendar">Brak kalendarza z możliwością zapisu, do którego można zaimportować dane. Najpierw utwórz kalendarz lokalny.</string>
<string name="import_done_title">Import zakończony</string>
<string name="import_done_dedup_note">Wydarzenia znajdujące się już w kalendarzu zostały pominięte.</string>
<string name="import_done_added_label">Dodano</string>
<string name="import_done_skipped_label">Duplikaty</string>
<string name="import_close">Zamknij</string>
<string name="import_warning_recurrence">Niektóre zmienione wystąpienia zdarzeń cyklicznych zostały pominięte.</string>
<string name="import_warning_no_start">Wydarzenie bez określonej godziny rozpoczęcia zostało pominięte.</string>
<string name="import_warning_attendees">Listy gości nie zostały zaimportowane.</string>
<string name="import_warning_timezone">Nieznana strefa czasowa została zastąpiona strefą ustawioną na Twoim urządzeniu.</string>
<string name="import_button">Importuj</string>
<plurals name="import_title_count">
<item quantity="one">Importowanie %d wydarzenia</item>
<item quantity="few">Importowanie %d wydarzeń</item>
<item quantity="many">Importowanie %d wydarzeń</item>
<item quantity="other">Importowanie %d wydarzeń</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d wydarzenie w tym pliku.</item>
<item quantity="few">%d wydarzenia w tym pliku.</item>
<item quantity="many">%d wydarzeń w tym pliku.</item>
<item quantity="other">%d wydarzeń w tym pliku.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importuj %d wydarzenie</item>
<item quantity="few">Importuj %d wydarzenia</item>
<item quantity="many">Importuj %d wydarzeń</item>
<item quantity="other">Importuj %d wydarzeń</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Zaimportowano %d wydarzenie.</item>
<item quantity="few">Zaimportowano %d wydarzenia.</item>
<item quantity="many">Zaimportowano %d wydarzeń.</item>
<item quantity="other">Zaimportowano %d wydarzeń.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Pominięto %d wydarzenie znajdujące się już w tym kalendarzu.</item>
<item quantity="few">Pominięto %d wydarzenia znajdujące się już w tym kalendarzu.</item>
<item quantity="many">Pominięto %d wydarzeń znajdujących się już w tym kalendarzu.</item>
<item quantity="other">Pominięto %d wydarzeń znajdujących się już w tym kalendarzu.</item>
</plurals>
<string name="shortcut_new_event_short">Nowe wydarzenie</string>
<string name="shortcut_new_event_long">Utwórz nowe wydarzenie</string>
<string name="qs_tile_new_event_label">Nowe wydarzenie</string>
<string name="settings_qs_tile">Dodaj kafelek Szybkich ustawień</string>
<string name="settings_qs_tile_hint">Dodaj kafelek „Nowe wydarzenie” do panelu Szybkich ustawień.</string>
<string name="crash_dialog_title">Aplikacja %1$s uległa awarii</string>
<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>
<string name="special_dates_default_title_custom">{name}</string>
<string name="event_detail_duplicate">Duplikuj</string>
<string name="reminder_day_tomorrow">Jutro</string>
<string name="reminder_day_yesterday">Wczoraj</string>
<string name="agenda_no_more_today">Brak zaplanowanych wydarzeń</string>
<string name="settings_soften_colors">Zredukuj intensywność kolorów kalendarza</string>
<string name="settings_soften_colors_summary">Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza.</string>
<string name="settings_agenda_show_today">Zawsze pokazuj dzisiejszy dzień</string>
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
<string name="special_dates_calendar_birthday">Urodziny</string>
<string name="special_dates_calendar_anniversary">Rocznice</string>
</resources>

View File

@@ -6,4 +6,60 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Современный календарь.</string>
<string name="state_loading">Загрузка…</string>
<string name="state_retry">Попробовать ещё раз</string>
<string name="state_failure_unknown">Что-то пошло не так.</string>
<string name="state_failure_permission">Требуется доступ к календарю.</string>
<string name="state_failure_permission_action">Предоставить доступ</string>
<string name="state_failure_no_calendars">Нет настроенных календарей.</string>
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала и никакая информация не покидает ваш девайс.</string>
<string name="permission_request_button">Дать доступ к календарю</string>
<string name="permission_denied_title">В доступе к календарю отказано</string>
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
<string name="permission_open_settings_button">Открыть настройки телефона</string>
<string name="permission_retry_button">Попытайтесь снова</string>
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь всё синхронизированное с устройством просто подключается.</string>
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
<string name="permission_benefit_privacy_body">Ноль телеметрии. ноль аналитики, без рекламы.</string>
<string name="permission_privacy_footnote">Остаётся на устройстве · не требует доступа к интернету</string>
<string name="month_prev">Предыдущий месяц</string>
<string name="month_next">Следующий месяц</string>
<string name="month_today_action">Сегодня</string>
<string name="month_more_actions">Больше действий</string>
<string name="month_open_menu">Открыть меню</string>
<string name="month_action_settings">Настройки</string>
<string name="month_a11y_today_prefix">Сегодня</string>
<string name="week_today_action">Эта неделя</string>
<string name="week_number_label">Нед</string>
<string name="day_today_action">Сегодня</string>
<string name="event_detail_back">Назад</string>
<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_share_chooser_title">Поделиться событием</string>
<string name="event_share_failed">Не удалось поделиться этим событием.</string>
<string name="event_delete_title">Удалить событие?</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>
<string name="event_delete_option_series">Все события в последовательности</string>
<string name="event_edit_recurring_title">Редактировать повторяющееся событие</string>
<string name="event_delete_failed">Не удалось удалить событие</string>
<string name="event_delete_write_denied">Calendula требуется доступ для удаления событий</string>
<string name="dialog_cancel">Отмена</string>
<string name="dialog_ok">ОК</string>
<string name="event_edit_new_title">Новое событие</string>
<string name="event_edit_close">Закрыть</string>
<string name="event_edit_save">Сохранить</string>
<string name="event_edit_title_hint">Добавить имя</string>
</resources>

View File

@@ -375,14 +375,17 @@
<!-- Month view style (#38, #53) -->
<string name="settings_month_header">Month view</string>
<string name="settings_month_view_style">Month view style</string>
<string name="settings_month_view_style_summary">Choose how the month view is laid out and how you move through it.</string>
<string name="month_style_paged">Pages</string>
<string name="month_style_paged_summary">One month at a time. Swipe sideways to change month.</string>
<string name="month_style_continuous">Continuous</string>
<string name="month_style_continuous_summary">Scroll up and down through the weeks. No month is cut off and no day appears twice.</string>
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
<string name="month_style_continuous">Scrolling months</string>
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
<string name="month_style_dense">Seamless weeks</string>
<string name="month_style_dense_summary">The weeks run on without a break, each month flowing straight into the next with no gap between them.</string>
<string name="month_style_split">Split</string>
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
<string name="month_split_no_events">Nothing scheduled</string>
<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_drawer_order_header">Navigation menu</string>

View File

@@ -9,6 +9,7 @@
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />

View File

@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What makes the continuous style's month blocks self-contained: a boundary week
* keeps its seven columns but carries only the block's own month.
*/
class ClipWeekToMonthTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 Jul 5. */
private fun firstRowOfJuly(events: List<EventInstance>) =
clipWeekToMonth(
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(),
jul26,
)
@Test
fun `the row keeps all seven days so the columns don't shift`() {
val week = firstRowOfJuly(emptyList())
assertThat(week.days).hasSize(7)
assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29))
}
@Test
fun `the neighbour month's events are dropped`() {
val june = timed(LocalDate(2026, 6, 30), 9)
val july = timed(LocalDate(2026, 7, 1), 9, id = 3L)
val week = firstRowOfJuly(listOf(june, july))
assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30))
assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july)
assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull()
assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1)
}
@Test
fun `a bar reaching in from the previous month is cut back to the 1st`() {
// Jun 29 Jul 2: columns 0..3 unclipped, 2..3 once July owns the row.
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2))))
val span = week.spans.single()
assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st
assertThat(span.endCol).isEqualTo(3)
// Flat cap on the cut side: the event continues out of this block.
assertThat(span.continuesLeft).isTrue()
assertThat(span.continuesRight).isFalse()
}
@Test
fun `a bar living entirely in the neighbour month disappears`() {
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30))))
assertThat(week.spans).isEmpty()
}
@Test
fun `a bar reaching out into the next month is cut at the last day`() {
// Jul 29 Aug 2 sits in July's final row (Jul 27 Aug 2).
val weeks = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))),
zone,
)
val span = clipWeekToMonth(weeks.last(), jul26).spans.single()
assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th
assertThat(span.endCol).isEqualTo(4) // Friday the 31st
assertThat(span.continuesRight).isTrue()
assertThat(span.continuesLeft).isFalse()
}
@Test
fun `a row wholly inside the month is untouched`() {
val mid = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))),
zone,
)[1]
assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid)
}
}

View File

@@ -0,0 +1,238 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
* The continuous grid's coordinate space. Every block's identity — and the
* LazyColumn items it maps to — hangs off this arithmetic, so it gets its own
* tests rather than being exercised only through the UI.
*/
class ContinuousMonthIndexTest {
private val jun26 = YearMonth(2026, Month.JUNE)
@Test
fun `consecutive months are consecutive indices`() {
val jun = monthIndexOf(jun26)
assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1)
assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1)
// And across the year boundary.
assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY)))
.isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1)
}
@Test
fun `index round-trips back to its month`() {
listOf(
YearMonth(1900, Month.JANUARY),
jun26,
YearMonth(2026, Month.DECEMBER),
YearMonth(2100, Month.DECEMBER),
).forEach { ym ->
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
}
}
@Test
fun `indices are non-negative and span 1900 through 2100`() {
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
.isEqualTo(continuousMonthCount() - 1)
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
}
@Test
fun `a month maps to its header item and back`() {
val index = monthIndexOf(jun26)
val item = itemIndexForMonth(index)
// Both of a month's items — its sticky header and the block under it —
// resolve back to the same month, so a title read off the first visible
// item is right wherever the viewport sits inside the block.
assertThat(monthIndexForItem(item)).isEqualTo(index)
assertThat(monthIndexForItem(item + 1)).isEqualTo(index)
assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1)
}
@Test
fun `week rows follow the month's shape and its week start`() {
// June 2026 starts on a Monday: 30 days → 5 rows either way.
assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5)
assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5)
// February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4
// rows, a Monday-anchored one needs 5 — the block's height moves with the
// preference even though its position in the list doesn't.
val feb26 = YearMonth(2026, Month.FEBRUARY)
assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4)
assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5)
// February 2021 starts on a Monday and is 28 days → exactly 4.
assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY))
.isEqualTo(4)
}
@Test
fun `the placeholder block is the size the loaded one will be`() {
// Otherwise scrolling across an unloaded month jumps when its data lands.
(0 until 24).forEach { offset ->
val ym = yearMonthForIndex(monthIndexOf(jun26) + offset)
DayOfWeek.entries.forEach { ws ->
assertThat(weekRowsInMonth(ym, ws))
.isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size)
}
}
}
@Test
fun `a window is clamped to the months that exist`() {
// The regression this guards: an empty visible-items list reported as
// "month 0", whose padded window ran to -4 — and yearMonthForIndex has
// no month for that, so the whole grid failed with "couldn't read".
assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4)
val last = continuousMonthCount() - 1
assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last)
assertThat(clampMonthWindow(10..20)).isEqualTo(10..20)
}
@Test
fun `every index a clamped window can hold resolves to a month`() {
listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3)))
.forEach { window ->
window.forEach { index -> yearMonthForIndex(index) }
}
}
@Test
fun `the dense week window covers every week its months touch`() {
// One month-window load has to lay out every Dense row those months
// appear in, boundary weeks included, or rows would flicker as
// placeholders while the data for them is already in hand.
DayOfWeek.entries.forEach { ws ->
val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST))
val weeks = weekWindowFor(window, ws)
window.forEach { index ->
val ym = yearMonthForIndex(index)
val first = weekIndexOf(firstOfMonth(ym), ws)
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
assertThat(weeks.contains(first)).isTrue()
assertThat(weeks.contains(last)).isTrue()
}
}
}
@Test
fun `a dense row reports the month that owns most of it`() {
// June 29 July 5, 2026 (Monday-anchored): four of its days are July's,
// so the window follows July rather than snapping back to June.
val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY)
assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY))
.isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY)))
// A row wholly inside June reports June.
assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY))
.isEqualTo(monthIndexOf(jun26))
}
@Test
fun `every day of a dense week shares one index`() {
val mon = LocalDate(2026, 6, 8)
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(indices.toSet()).hasSize(1)
}
@Test
fun `dense week indices round-trip and stay inside the list`() {
val wed = LocalDate(2026, 6, 10)
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(index).isLessThan(continuousWeekCount(ws))
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
.isLessThan(continuousWeekCount(ws))
}
}
@Test
fun `firstOfMonth is the 1st`() {
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
}
@Test
fun `a window comfortably around the visible range is kept`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
}
@Test
fun `nearing a loaded edge widens the window around the visible range`() {
// Within two months of the top edge → reload, padded on both sides.
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
.isEqualTo(-4..7)
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
.isEqualTo(93..105)
}
@Test
fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
.isEqualTo(495..506)
}
@Test
fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query — and with the
// window growing from a pad of 1, this has to hold at every rung of the
// ladder, not just the widest one.
(1..8).forEach { pad ->
val loaded = (10 - pad)..(10 + pad)
val past = 10 + pad + 1
val widened = nextLoadWindow(loaded, past, past, pad)!!
assertThat(nextLoadWindow(widened, past, past, pad)).isNull()
}
}
@Test
fun `the reload trigger stays inside the pad`() {
// A trigger at or beyond the pad would fire again the instant its own
// reload landed, and the window would query forever.
(1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) }
// A one-month window has no room for hysteresis: reload only on contact.
assertThat(edgeForPad(1)).isEqualTo(0)
assertThat(edgeForPad(0)).isEqualTo(0)
}
@Test
fun `the smallest window reloads on crossing rather than nearing its edge`() {
// The first frame loads one month either side of today, which leaves no
// room to reload *before* the edge: at pad 1 the trigger is contact.
// Only momentary — the first completed load widens the pad to 3, which
// buys the usual head start back.
val initial = 10..12
assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull()
assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1))
.isEqualTo(12..14)
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
}
@Test
fun `the split selection falls to the 1st of any other month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}

View File

@@ -1,121 +0,0 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import org.junit.jupiter.api.Test
/**
* The continuous grid's coordinate space. Every row's identity — and the
* LazyColumn item it maps to — hangs off this arithmetic, so it gets its own
* tests rather than being exercised only through the UI.
*/
class ContinuousWeekIndexTest {
// 2026-06-08 is a Monday; 2026-06-10 the Wednesday of the same week.
private val mon = LocalDate(2026, 6, 8)
private val wed = LocalDate(2026, 6, 10)
@Test
fun `every day of a week shares one index`() {
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
assertThat(indices.toSet()).hasSize(1)
}
@Test
fun `consecutive weeks are consecutive indices`() {
val a = weekIndexOf(mon, DayOfWeek.MONDAY)
val b = weekIndexOf(mon.plus(7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
val back = weekIndexOf(mon.plus(-7, DateTimeUnit.DAY), DayOfWeek.MONDAY)
assertThat(b).isEqualTo(a + 1)
assertThat(back).isEqualTo(a - 1)
}
@Test
fun `index round-trips back to the week's first day`() {
DayOfWeek.entries.forEach { ws ->
val index = weekIndexOf(wed, ws)
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
}
}
@Test
fun `the week start shifts which week a boundary day belongs to`() {
// Sunday the 14th closes the Monday-anchored week but opens the Sunday one.
val sun = LocalDate(2026, 6, 14)
assertThat(weekIndexOf(sun, DayOfWeek.MONDAY))
.isEqualTo(weekIndexOf(mon, DayOfWeek.MONDAY))
assertThat(weekIndexOf(sun, DayOfWeek.SUNDAY))
.isEqualTo(weekIndexOf(sun.plus(1, DateTimeUnit.DAY), DayOfWeek.SUNDAY))
}
@Test
fun `indices are non-negative across the supported span`() {
// The epoch sits before any date the grid scrolls to, so item indices and
// week indices stay the same number — no offset to reconcile.
DayOfWeek.entries.forEach { ws ->
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isGreaterThan(0)
}
}
@Test
fun `the list spans 1900 through 2100`() {
DayOfWeek.entries.forEach { ws ->
val count = continuousWeekCount(ws)
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)).isLessThan(count)
assertThat(weekIndexOf(LocalDate(2026, 6, 8), ws)).isLessThan(count)
// ~200 years of weeks, give or take the anchor.
assertThat(count).isIn(10_400..10_500)
}
}
@Test
fun `a window comfortably around the visible range is kept`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
}
@Test
fun `nearing a loaded edge widens the window around the visible range`() {
// Within four weeks of the top edge → reload, padded on both sides.
val widened = nextLoadWindow(loaded = 0..100, firstVisible = 2, lastVisible = 7)
assertThat(widened).isEqualTo(-10..19)
val atBottom = nextLoadWindow(loaded = 0..100, firstVisible = 94, lastVisible = 99)
assertThat(atBottom).isEqualTo(82..111)
}
@Test
fun `a jump far outside the window reloads around the destination`() {
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 505))
.isEqualTo(488..517)
}
@Test
fun `the reloaded window always clears the trigger it just crossed`() {
// Otherwise every scroll frame would re-trigger a query.
var loaded = 0..100
val window = nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)!!
loaded = window
assertThat(nextLoadWindow(loaded, firstVisible = 2, lastVisible = 7)).isNull()
}
@Test
fun `the split selection follows the month, landing on today when it's there`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JUNE), today))
.isEqualTo(today)
}
@Test
fun `the split selection falls to the 1st of any other month`() {
val today = LocalDate(2026, 6, 10)
assertThat(selectionForMonth(YearMonth(2026, kotlinx.datetime.Month.JULY), today))
.isEqualTo(LocalDate(2026, 7, 1))
assertThat(selectionForMonth(YearMonth(2025, kotlinx.datetime.Month.JUNE), today))
.isEqualTo(LocalDate(2025, 6, 1))
}
}

View File

@@ -0,0 +1,121 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
* is that cheapness changes *nothing*: a row laid out from the index must be the
* row that would have come from scanning every instance.
*
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
* disagree — the case that has bitten this codebase before (#65).
*/
class DayIndexTest {
private val zone = TimeZone.of("Europe/Berlin")
private val mon = LocalDate(2026, 6, 8)
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
private var nextId = 0L
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = from.atTime(startHour, 0).toInstant(zone),
end = to.atTime(endHour, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
}
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
val id = ++nextId
return EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
isAllDay = true,
color = 0xFF445566.toInt(),
location = null,
)
}
/** Every shape the layout distinguishes, plus the boundary cases around them. */
private fun awkwardEvents(): List<EventInstance> = listOf(
timed(mon, 9, mon, 10),
// Crosses midnight into the next day.
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
// Ends exactly at midnight: covers its own day, not the next.
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
// Zero length, at midnight: covers nothing at all.
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
// Runs in from before the row and out past its end.
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
// Wholly outside the row, both sides.
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
// Two on one day, to pin the ordering the index hands back.
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
)
@Test
fun `a row from the index is the row from a full scan`() {
val events = awkwardEvents()
val scanned = layoutCalendarWeek(week, events, zone)
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
assertThat(indexed).isEqualTo(scanned)
}
@Test
fun `a whole month agrees, row for row`() {
val events = awkwardEvents()
val jun = YearMonth(2026, Month.JUNE)
DayOfWeek.entries.forEach { ws ->
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
}
}
@Test
fun `an event covering several of a row's days is listed once`() {
// It is bucketed on each date it covers, so the row would otherwise see
// it repeatedly and lay out a lane per copy.
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
val events = DayIndex(listOf(long), zone).eventsOn(week)
assertThat(events).containsExactly(long)
}
@Test
fun `days no event touches come back empty`() {
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
assertThat(index.eventsOn(emptyList())).isEmpty()
}
@Test
fun `an empty calendar indexes to nothing`() {
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
}
}

View File

@@ -0,0 +1,129 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What lets the split style's dots morph into the paged style's bars (#53): both
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
*/
class LaneEventsTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
/** July 2026 starts on a Wednesday, so this row — Jul 612 — sits wholly inside it. */
private fun rowOfJuly6(events: List<EventInstance>) =
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "A$id",
start = from.atTime(0, 0).toInstant(zone),
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = color,
location = null,
)
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = date.atTime(hour, 0).toInstant(zone),
end = date.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = color,
location = null,
)
@Test
fun `a bar keeps its lane and the day's timed events fill what's left`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, meeting))
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
.containsExactly(bar, meeting)
.inOrder()
}
@Test
fun `a day the bar misses seats its own events from lane zero`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
val week = rowOfJuly6(listOf(bar, monday))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(monday)
}
@Test
fun `a multi-day bar is seated on every day it covers`() {
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
val week = rowOfJuly6(listOf(bar))
(1..3).forEach { col ->
val day = LocalDate(2026, 7, 6 + col)
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
}
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
}
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
@Test
fun `events sharing a colour each keep their own lane`() {
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
val week = rowOfJuly6(listOf(first, second))
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
.containsExactly(first, second)
.inOrder()
}
@Test
fun `seating stops at the cap and leaves the rest to the overflow count`() {
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
val week = rowOfJuly6(events)
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
}
/** A bar parked below the cap is out of view, so it takes no dot with it. */
@Test
fun `a bar beyond the cap is left out`() {
val bars = (0 until 4).map {
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
}
val week = rowOfJuly6(bars)
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
assertThat(seated).hasSize(3)
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
.containsNoneIn(seated)
}
private companion object {
const val BLUE = 0xFF3366CC.toInt()
const val RED = 0xFFCC3333.toInt()
}
}

View File

@@ -82,9 +82,24 @@ release work when a merge actually cuts a release:
merged commit, build & sign the release APK with the **app key**, copy it into
the F-Droid repo, generate the per-version changelog, re-sign the index with
the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag +
Gitea release (CHANGELOG section as notes) and attach the R8 `mapping.txt`
(best-effort). Ordinary merges with no version bump fall through `detect` and
do nothing.
Gitea release (CHANGELOG section as notes), attach the R8 `mapping.txt`, and
mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
### Codeberg direct-download channel
Alongside F-Droid, each release is mirrored to the Codeberg repo
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release over the
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
same APK the F-Droid repo serves (same **app key**), so it adds no trust
surface. The step is best-effort: a Codeberg outage never fails an
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
unset. One-time setup: the repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope) added
to Gitea Actions.
### Manual re-sign / recovery
@@ -103,6 +118,7 @@ for key rotation or repo recovery without publishing a new app version.
| `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
The two keys are independent: the **app key** signs APKs; the **repo key**
signs the index (its fingerprint is what users pin). Neither key nor the

View File

@@ -1,21 +1,46 @@
### Added
- Give an event its own time zone. A new Time zone field (under "more fields" in
the event form) pins an event to a specific zone, so a call set for 8:00 AM in
New York stays 8:00 AM in New York wherever you open it, and keeps tracking
that zone across daylight-saving changes. The form shows the local equivalent
under the times, and the event's details keep your local time first with the
original noted beneath. Pick a zone from a searchable full-screen picker — by
city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored
and carry no zone ([#31]).
- Put the "jump to today" button in the toolbar. A new Today button in toolbar
setting (Settings → Appearance, off by default) swaps the floating corner
button for a permanent today icon in the top bar — always there, matching the
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
- Show the app as "Calendar" in your launcher. A new App name setting
(Settings → Appearance) switches the launcher label from "Calendula" to the
generic "Calendar" for anyone who prefers it — handy on launchers that can't
rename apps themselves. Only the launcher name changes; your home-screen icon
may move to a new spot after switching ([#44]).
- Choose how the month view is laid out. A new **Month view style** setting
(Settings → Views) offers three ways to read a month, each shown with a
preview of the layout it produces:
- **Pages** — what you have today: one month at a time, swiped sideways.
- **Continuous** — scroll up and down through the weeks without a break
between months. Because the weeks run on unbroken, no month is cut off and
no day appears twice, where paging repeats a boundary week at the end of one
month and the start of the next. The 1st of each month names itself so you
always know where you are, and the title bar keeps up as you scroll ([#38]).
- **Split** — a compact grid showing coloured dots for the days that have
something on them, with the day you tap listed in full underneath. Tap the
date above the list to open the whole day ([#53]).
The Agenda view is untouched by this and stays available in all three styles —
the split layout lists a single day, while Agenda remains a rolling multi-day
window with its own range settings.
- Give an event its own time zone. A new **Time zone** field (under "more
fields" in the event form) pins an event to a specific zone, so a call set for
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
tracking that zone across daylight-saving changes instead of drifting an hour.
The form edits the event in its own zone and shows the local equivalent under
the times ("2:00 PM 3:00 PM your time"); the event's details keep your local
time first and note the original beneath it, so both are always clear. Pick a
zone from a full-screen picker with your device zone and recent choices on top,
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
("CEST") to gather every matching zone at once. All-day events stay
date-anchored and carry no zone, as before ([#31]).
- Put the "jump to today" button in the toolbar. A new **Today button in
toolbar** setting (Settings → Appearance, off by default) swaps the floating
button that fades into the corner while you're away from today for a permanent
today icon in the top bar — always there, on today or not, matching the
familiar calendar-app pattern. Leave it off to keep the floating button as
before ([#60]).
- Choose what Calendula calls itself on your home screen. A new **App name**
setting (Settings → Appearance) switches the launcher label between
**Calendula** and **Calendar**, for launchers that can't rename apps
themselves. Pick from a full-screen chooser that previews both names as
launcher marks; the change applies at once ([#44]).
- Calendula now speaks **Arabic**, laid out right-to-left, and its French and
Italian translations have been brought up to date — thanks to the community
translators on Weblate. Pick a language under Settings → Language (or leave it
on the system default).
### Changed
- Dates in the Month, Week and Day title bars now follow your language and
@@ -34,4 +59,43 @@
"24. Jun 31. Jun" restated the day numbers already printed in the column
headers right below it, in the widest string in the bar. A week that straddles
two months keeps the outgoing month until it is fully gone ([#60]).
- The custom recurrence picker has been redesigned and tightened up. As you
build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary
now describes exactly what will be saved rather than a near-copy that could
drift from it, the amount fields accept being left blank (reading as their
shown default instead of greying out OK), and the read-out no longer jumps
around as you tap weekdays ([#42]).
### Fixed
- An all-day event no longer shows up again the day after it happened. In time
zones east of UTC, an all-day event — a birthday, say — set for one day also
appeared under the *next* day's heading in the Agenda (and the agenda widget),
because all-day events are anchored to UTC midnight and the following day's
window reached back across that boundary and pulled the event forward onto
"today". Each all-day event now lists only on the day it actually falls on
([#65]).
- A multi-day event now shows under every day it spans in the Agenda, not just
its first day, so a trip or a multi-day booking appears on each day it covers.
- The "Upcoming" agenda widget now scales its text and rows to the size you give
it. Previously it was laid out once for the smallest size and simply stretched
when enlarged, so the text stayed small no matter how big you made the widget.
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
didn't advertise — so it was left out of the chooser, and if it was your only
calendar app the save silently did nothing. It now accepts that form, plus the
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
mislabelled as a generic download (`application/octet-stream`), as some mail
clients, browsers and file managers do — Calendula recognises it by its file
extension instead of relying on the declared type ([#74]).
- A recurrence end date no longer lands a day late. West of UTC, setting a rule
to end "until" a given day could save and show the day after the one picked;
the end date now reads back as chosen ([#42]).
- The status- and navigation-bar icons stay legible over full-screen pickers in
dark theme. They could render dark-on-dark — a near-invisible black clock
against the dark picker — instead of switching to light ([#70]).