The realignment envelope is described as implemented: whole-day shifts, or
a time-only shift whose anchor keeps its day. Also documents the one-write-
at-a-time gate, and notes the drag as a third recurring-scope prompt.
- a zero-day month drop no longer overwrites the pending undo record
- the chip's dismissal timer restarts on undo and waits out any write
- a refused drop releases its held copy instead of timing out
- the drag preview uses the event's real length, not the day-clipped block
- block placement stops springing while a pinch rewrites the hour height
- a time-only drag checks the series anchor stays on its own day
Undo moves the event just as the drop did, so it now travels the same
way instead of the chip vanishing off the new day and reappearing on the
old one. The write signals when it starts, the controller puts a copy on
the chip's current seat, and the settle that follows is the drop's own —
glide onto the restored seat, ghost underneath, hand over there.
A week row now publishes what it draws along with where it draws it, so
the controller can find any moved event's seat itself rather than each
row scanning its own lanes.
The month grid seats a dropped chip in a lane, but the copy was left at
whatever height the finger was at for a flat 450ms hold and then
cross-faded across that gap — long enough that the event looked like it
took its time arriving. The week row now reports where the grid actually
seats the drop once the re-read lands, and the copy glides onto it and
hands over there. The flat wait is only the fallback for a drop with no
seat to find (a day's "+N" overflow, or a failed write).
Two causes, both leaving the block at the old slot or dimmed for a frame or
several. The source is now identified for the write's duration by event row
plus the slot it left, not by instance id — the provider renumbers Instances
rows, so a re-read still carrying the old time arrives under a new id and
the ghost stopped matching. And the ghost is dropped when the copy *starts*
dissolving rather than after: un-ghosting last left the block dim under the
fading copy and brightened it once the copy was gone.
The chip springs open from its corner on the expressive fast specs instead
of sliding in clipped, and its text is resolved in composition rather than
from an effect — a frame late meant it opened carrying the previous message
and grew into the new one mid-animation.
Undo now leaves the outcome standing until its write reports back, so the
one chip changes what it says instead of closing and reopening.
The chip goes back to the neutral fill and its motion is cut to a receipt's
length — the default visibility spring overshot and read as sluggish.
The landed copy is now handed back when the grid actually draws the drop,
not on a timer: releasing early put the source ghost back at full opacity in
its old slot for the rest of the re-read, which is the flicker.
The confirmation pill is now floret-kit's SnackChip, shared with Agendula's
swipe-delete undo, wearing the FAB's colours and crossfading when its
message is answered.
Timed blocks are keyed by event and their bounds tweened, so a landing drop,
the neighbour that has to make room for it, and an undo all slide instead of
popping. The source ghost travels with them rather than fading out, and the
floating copy dissolves into the block once the grid has caught up.
The floating copy used to vanish the moment the finger lifted, so the block
snapped back to its old slot until the provider notified and the query
re-read it. It now holds the slot it landed on until the write settles,
sinking its lift as it does, while the source ghost fades out.
The confirmation moves from a full-width snackbar to a pill on the FAB's
band, the shape agendula uses for its undoable delete.
While a block is lifted the gutter dims its hour labels and floats a badge
with the drop's start time, so the target reads off the same scale the
labels do. Gutter extracted into a shared composable — day and week had
identical copies.
Write path:
- realignRecurrence now handles weekly BYDAY only. What the rule must agree
with is the series *anchor*, which moves by the same wall-clock shift as the
dragged occurrence — weekday survives that (uniform mod 7), day-of-month does
not. BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1,
produced a Jan 29 anchor under a BYMONTHDAY=1 rule: a DTSTART that is not an
instance of its own rule. Also refuse when the shift is not a whole number of
days, which would carry a late-enough anchor across an extra midnight.
- The accepted parts are now a subset of parseSimpleRecurrence's, so anything
realignable is a rule the UNTIL guard can actually read. FREQ=MONTHLY;BYDAY=MO
previously slipped past it and could move a series past its own end.
- The UNTIL check moved to write time and picks the date the chosen scope
actually starts at: the anchor for a whole-series move, the occurrence for a
split, nothing for a single occurrence. It used to test the occurrence in
every case and refused the ordinary drag of a bounded series' last one.
- shiftedByDays preserves the instant duration for timed events, as shiftedTo
already did — a month drag onto a DST changeover rewrote the whole series'
DURATION.
- Guard against a second drop landing while one is in flight; the shifts would
compound on the re-read anchor.
Gestures:
- A long press that never moved wrote a reschedule: the target snaps to the
15-minute grid at pickup, so an event at 09:07 resolved to 09:00 the instant
it lifted. The drop is now refused when it lands on the slot it started from.
- After pickup the gesture is driven and consumed on the initial pass. Pinching
mid-drag used to zoom and drag at once, then write wherever the zoom left the
block; the month grid's tap layer opened the day on lift.
- RTL: pointer x was mapped to columns as if the grid were LTR, so a drop landed
6-n columns away, and both ghosts used the direction-aware offset with root
coordinates, mirroring them across the screen.
- The month drop passes the day delta instead of a date, so the grid and the
view model no longer each resolve the event's first day in their own zone.
Long-press an event block in the week or day timeline and drag it to another
time or day column; drag a chip in the month grid onto another date. The drop
runs through the edit screen's own write path, so recurring writes, reminders
and guests behave the same as a save.
Two provider traps the drag had to close:
- RRULE is written verbatim while DTSTART moves, so dragging a
FREQ=WEEKLY;BYDAY=MO occurrence to a Wednesday under "all events" left a
Wednesday anchor under a Monday rule and the series stayed put.
realignRecurrence re-derives BYDAY/BYMONTHDAY/BYMONTH, and refuses rules one
moved occurrence can't resolve (BYDAY=MO,WE, 2TH, BYSETPOS) — those may only
move the single occurrence.
- Nothing below the UI refuses a write, so allowsEventMove gates the gesture.
Not isEventTarget: a managed contact-mirror event is editable but must never
move.
The pickup is hand-rolled rather than detectDragGesturesAfterLongPress: during
the 500ms hold the block consumes nothing, so the scroll and page swipe claim at
touch slop and kill the press, and the stock detector also cancels as soon as the
finger leaves a block that may only be 10dp tall. Ours holds with a 6dp tolerance
and never cancels on leaving bounds.
Agenda is out of scope (list rows, no positioned blocks), as is
resize-by-edge-drag. Month drags don't auto-scroll or page yet.
- Back now closes the drawer instead of the app: a `BackHandler` inside the sheet, enabled while the drawer is open. The four host screens (Month/Week/Day/Agenda) pass their `drawerState` in.
- Header and section labels use `GroupedListInset` instead of a hardcoded 28.dp, so "Calendula", "View" and "Calendars" share the left edge of the rows below them — same as the Settings screens. The account labels and placeholders in `CalendarFilterList` follow.
- Plain leading icons now sit in a 40.dp box, matching `CalendarColorChip`'s footprint, so view rows, jump-to-date, Settings and the calendar rows all line up on one vertical axis.
Closes#114
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/121
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline.
**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.
### 1 · floret-kit moved
Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance.
The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve.
### 2 · Workflows split by directory
Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |
The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.
### 3 · Two release-pipeline safety changes
- `release.yaml`'s `detect` gets an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running the release pipeline on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.
### 4 · Links repointed
In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk.
### Not in this PR
Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side).
Supersedes Gitea PR #104.
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86
Adds a fourth link to the about card in settings, next to source and licence.
Play requires the privacy policy to be reachable from inside the app, not just from the store listing, for anything that touches calendar or contact data. Points at jeanlucmakiola.de/calendula/privacy.
Only the base string is added — Weblate owns the translations.
Reviewed-on: #103
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>