Compare commits

..
Author SHA1 Message Date
makiolaj 484d297d13 Show the title on short week and day blocks (#289) 2026-09-20 22:52:25 +02:00
Jean-Luc Makiolaandmakiolaj bdcf2b3823 Stop a fully zoomed-out timeline from scrolling (#315)
The pinch clamped its lower bound to `ceil(fillPx)`. Rounding a fill height up
by the fraction of a pixel that 24 hours don't divide the viewport into makes
the timeline up to one pixel per hour taller than the viewport those hours are
supposed to fill — roughly 24px of leftover scroll, enough to bounce off
Android's overscroll stretch. `FitDay` resolves to the unrounded height and sits
still, so the two ways of reaching "the whole day on one screen" disagreed.
That inconsistency is what #290 is about.

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

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

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

Closes #290

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

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

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

Closes #309
Closes #305
Closes #294

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

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

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

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

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

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

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

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

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

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

Two notes for review:

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

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

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/307
2026-09-15 16:10:29 +02:00
Jean-Luc Makiola 7be6b7ac31 Release v2.20.3 (#302)
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 57s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 12m49s
2026-09-12 17:09:15 +02:00
16 changed files with 608 additions and 81 deletions
+21
View File
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12
### Fixed
@@ -1693,3 +1711,6 @@ automatically, with zero telemetry and no internet permission.
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
+2 -2
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22003
versionName = "2.20.3"
versionCode = 22004
versionName = "2.20.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -113,11 +113,26 @@ class MainActivity : AppCompatActivity() {
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge()
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
// Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -242,6 +257,16 @@ class MainActivity : AppCompatActivity() {
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
}
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
@@ -32,6 +34,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -106,6 +110,27 @@ fun AgendaScreen(
}
val successState = state as? AgendaUiState.Success
// The agenda window always starts at the anchor, so moving through it is a
// list scroll, not an anchor change — "today" has to bring the list back to
// the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState()
val scrolledAway by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday = {
// Off today, moving the anchor is what resets the list (above); on today
// the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
viewModel.goToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
@@ -139,14 +164,15 @@ fun AgendaScreen(
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
onToday = jumpToToday,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
!todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onToday = jumpToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
@@ -195,6 +221,7 @@ fun AgendaScreen(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
@@ -298,6 +325,7 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
when (state) {
AgendaUiState.Loading -> Box(modifier)
@@ -326,12 +354,16 @@ internal fun AgendaContent(
enabled = showToday && state.anchor == state.today,
)
if (days.isEmpty()) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
AgendaEmpty(modifier)
} else {
AgendaList(
days = days,
today = state.today,
zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
@@ -349,6 +381,7 @@ private fun AgendaList(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
listState: LazyListState,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
@@ -357,6 +390,7 @@ private fun AgendaList(
) {
LazyColumn(
modifier = modifier,
state = listState,
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
@@ -32,6 +33,93 @@ val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/**
* Block text with Material's outer leading trimmed off. The label roles wrap a
* 12sp glyph in a 16sp line box, and on a block short enough to be at risk that
* leading is the difference between a title and a bare colour chip (#289).
* Outer edges only, so a wrapped title keeps its interior line spacing.
*/
private val BlockLineHeight = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] as a timed block draws it — see [BlockLineHeight]. */
fun TextStyle.asBlockText(): TextStyle = copy(lineHeightStyle = BlockLineHeight)
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberBlockLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.asBlockText()).size.height.toDp() }
}
}
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds: a whole line box, the trim
* reaching only the outer edges. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberBlockLineHeight(titleStyle)
val titleLeading = with(LocalDensity.current) { titleStyle.lineHeight.toDp() }
val timeLine = rememberBlockLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
@@ -70,9 +158,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val timeLineHeight = rememberBlockLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
@@ -109,7 +197,8 @@ fun BlockTitle(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }
.asBlockText(),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -150,7 +239,7 @@ fun BlockTimeLabel(
Text(
text = text,
// Regular weight against the title's medium above it (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
style = MaterialTheme.typography.labelSmall.asEventTime().asBlockText(),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
@@ -790,20 +790,15 @@ private fun DragCopy(
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267).
// on the block, so the copy hands back to the grid without shifting (#267),
// and it squeezes its inset on the same terms so a short block's title does
// not vanish the moment it is lifted (#289).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
val metrics = rememberBlockTextMetrics(height)
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
@@ -818,10 +813,10 @@ private fun DragCopy(
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val left = metrics.available - metrics.titleHeight(lines)
val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else {
1
}
@@ -857,7 +852,7 @@ private fun DragCopy(
clip = false
}
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) {
Column {
if (lines > 0) {
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom(
* names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
*
* The bounds themselves are pulled onto that grid too, each in the direction
* that keeps its own promise — up for the fill floor, so no dead space opens
* under midnight, down for the ceiling. A fractional bound would be a height the
* pinch can be held against but never actually land on, and the difference feeds
* the focal anchor a scroll correction on every frame the fingers sit still.
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
* pinch can actually land on.
*
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
* day exactly fills the viewport at, and it is the same value
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
* hour taller than its own viewport, so a pinched-all-the-way-out day still
* scrolls a hair and bounces off Android's overscroll stretch, while the
* identical FitDay preset sits still. Being the clamp result rather than a bound
* the gesture is merely held against, it is a height the pinch does land on: the
* next frame reads it back unchanged and the focal anchor is handed nothing to
* correct.
*/
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx))
.coerceAtLeast(fillPx)
/**
* The scroll offset that keeps the moment under [centroidY] under it after the
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -763,30 +763,24 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val metrics = rememberBlockTextMetrics(height)
// A block that cannot afford both lines spends its space on the title, and
// one too short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val available = height - BLOCK_TEXT_INSET * 2
val showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTitle = metrics.fitsTitle
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// Only lines the block can actually draw: a block too short for the time is
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:3011:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() }
@@ -827,7 +821,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -909,13 +909,7 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val available = height - BLOCK_TEXT_INSET * 2
val metrics = rememberBlockTextMetrics(height)
// Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot
@@ -923,19 +917,19 @@ private fun EventBlock(
// its own: a duration threshold would keep hiding the time on a half-hour
// block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight
metrics.available >= metrics.titleLine + metrics.timeLine
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight
val showTitle = metrics.fitsTitle
// The title is served first, out of everything the block has left once the
// time is down to one line — but only takes the lines it will actually use,
// and only wraps at all once a line is wide enough to hold more than a
// syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
// Every line the height affords, however narrow the lane: two events side by
// side leave columns well under a word wide, and cutting the title to one
@@ -948,8 +942,8 @@ private fun EventBlock(
textWidth = textWidth,
max = titleBudget,
)
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
@@ -991,7 +985,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
+219 -11
View File
@@ -106,7 +106,7 @@
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávné</string>
<string name="event_edit_timezone_all">Všechna časová pásma</string>
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá %1$s</string>
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá %1$s</string>
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
<string name="event_edit_color">Barva</string>
<string name="event_edit_color_default">Barva kalendáře</string>
@@ -115,7 +115,7 @@
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string>
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě</string>
<string name="event_edit_conflict_body">Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami?</string>
<string name="event_edit_conflict_overwrite">Uložit změny</string>
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
@@ -169,7 +169,7 @@
<string name="recurrence_every_n_months">Každý %1$d. měsíc</string>
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
<string name="recurrence_on_days">%1$s v %2$s</string>
<string name="recurrence_with_until">%1$s do%2$s</string>
<string name="recurrence_with_until">%1$s do %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d opakování</string>
<string name="event_detail_not_found">Tato událost již neexistuje.</string>
<string name="event_attendee_accepted">Přijato</string>
@@ -190,7 +190,7 @@
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
<string name="event_attendee_organizer">Pořadatel</string>
<string name="event_attendee_optional">Volitelné</string>
<string name="event_attendee_resource">Zdroj</string>
<string name="event_attendee_resource">Prostředky</string>
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
<string name="reminder_at_time">V době konání události</string>
<string name="reminder_default">Výchozí připomínka</string>
@@ -296,7 +296,7 @@
<string name="search_back">Zpět</string>
<string name="search_clear">Vymazat</string>
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
<string name="search_empty">Žádná událost neodpovídá %1$s.</string>
<string name="search_empty">Žádná událost neodpovídá %1$s.</string>
<plurals name="search_selected_count">
<item quantity="one">%d označený</item>
<item quantity="few">%d označené</item>
@@ -409,7 +409,7 @@
<string name="settings_widget_size_medium">Střední</string>
<string name="settings_widget_size_large">Velký</string>
<string name="settings_widget_size_extra_large">Největší</string>
<string name="settings_widget_size_summary">Text události%1$d</string>
<string name="settings_widget_size_summary">Text události %1$d</string>
<string name="settings_agenda_show_today">Vždy zobrazovat dnešek</string>
<string name="settings_agenda_show_today_hint">Ponechat dnešní den na prvním místě agendy a jejího widgetu, třebaže již nezbývá žádná nadcházející událost.</string>
<string name="state_failure_all_hidden">Všechny vaše kalendáře jsou vypnuté.</string>
@@ -430,12 +430,12 @@
<string name="settings_agenda_range_bar_hint">V horní části agendy se zobrazí lišta s rozsahem dat a tlačítkem pro změnu rozsahu dané relace</string>
<string name="agenda_range_showing_label">Zobrazit všechny nadcházející události pro</string>
<plurals name="agenda_range_days">
<item quantity="one">%dden</item>
<item quantity="few">%ddny</item>
<item quantity="many">%ddnů</item>
<item quantity="other">%ddnů</item>
<item quantity="one">%d den</item>
<item quantity="few">%d dny</item>
<item quantity="many">%d dnů</item>
<item quantity="other">%d dnů</item>
</plurals>
<string name="settings_section_views">Pohledy</string>
<string name="settings_section_views">Zobrazení</string>
<string name="settings_month_header">Měsíční zobrazení</string>
<string name="settings_month_view_style">Styl měsíčního zobrazení</string>
<string name="month_style_paged">Stránky</string>
@@ -485,4 +485,212 @@
<string name="settings_reliable_delivery_exempt">Výloučení z optimalizace baterie připomínky chodí bez prodlení.</string>
<string name="settings_notifications_subtitle">Připomínky a doručení</string>
<string name="settings_special_dates_reminders">Připomínky</string>
<string name="settings_reliable_delivery">Spolehlivé doručení</string>
<string name="settings_snooze_duration">Doba odložení</string>
<string name="settings_section_calendars">Kalendáře</string>
<string name="settings_manage_calendars">Správa kalendářů</string>
<string name="settings_manage_calendars_hint">Vytvářet místní kalendáře; spravovat synchronizované kalendáře</string>
<string name="settings_section_language">Jazyk</string>
<string name="settings_language">Jazyk aplikace</string>
<string name="settings_language_auto">Výchozí nastavení systému</string>
<string name="settings_translate">Pomozte s překladem</string>
<string name="settings_translate_hint">Přidat nebo vylepšit jazyk na Weblate</string>
<string name="settings_group_look">Vzhled a chování</string>
<string name="settings_group_data">Data</string>
<string name="settings_group_app">Aplikace</string>
<string name="settings_group_about">O aplikaci</string>
<string name="settings_appearance_subtitle">Motiv, barvy, písma</string>
<string name="settings_views_subtitle">Výchozí zobrazení, rozložení a pořadí</string>
<string name="settings_event_form_subtitle">Výchozí pole a chování</string>
<string name="settings_special_dates_subtitle">Narozeniny a výročí kontaktů</string>
<string name="settings_widgets_subtitle">Widget s agendou, dlaždice rychlého nastavení</string>
<string name="settings_backup_subtitle">Export, import, automatické zálohování</string>
<string name="settings_section_widgets">Widgety a dlaždice</string>
<string name="settings_widgets_hint">Nastavení widgetů na domovské obrazovce a dlaždice rychlého nastavení. Widget přidáte dlouhým stisknutím domovské obrazovky.</string>
<string name="settings_section_backup">Záloha a obnovení</string>
<string name="settings_views_all_header">Všechny pohledy</string>
<string name="settings_week_day_header">Týden a den</string>
<string name="settings_default_view_hint">Pohled, který se otevře při spuštění aplikace.</string>
<string name="settings_week_start_hint">Počáteční den týdne ve všech zobrazeních a widgetech.</string>
<string name="settings_time_format_hint">Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému.</string>
<string name="settings_past_events_hint">Jak program zachází s událostmi, které již skončily.</string>
<string name="settings_dynamic_color_summary">Aplikace převezme barvy z vaší tapety.</string>
<string name="settings_section_special_dates">Speciální data kontaktů</string>
<string name="settings_special_dates_enable">Zobrazit data kontaktů</string>
<string name="settings_special_dates_enable_hint">Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení nikam se nic nenahrává, vaše kontakty zůstávají beze změny.</string>
<string name="settings_special_dates_type_birthday">Narozeniny</string>
<string name="settings_special_dates_type_anniversary">Výročí</string>
<string name="settings_special_dates_type_custom">Ostatní data</string>
<string name="settings_special_dates_template">Formát názvu</string>
<string name="settings_special_dates_template_hint">Pro kontakt použijte {name} a pro rok {year} (rok narození nebo rok zahájení výročí; pokud není znám, údaj se skryje).</string>
<string name="settings_special_dates_show_year">Zobrazit rok</string>
<string name="settings_special_dates_show_year_hint">Uvést {year} v názvu, je-li znám</string>
<string name="settings_special_dates_sync_now">Synchronizovat nyní</string>
<string name="settings_special_dates_never_synced">Zatím nesynchronizováno</string>
<string name="settings_special_dates_last_synced">Naposledy synchronizováno %1$s</string>
<string name="settings_special_dates_calendar_hint">Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů.</string>
<string name="settings_calendar_reminders_managed_hint">Nastavte v části Speciální data kontaktu</string>
<string name="settings_special_dates_paused_title">Pozastaveno</string>
<string name="settings_special_dates_paused_hint">Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace.</string>
<string name="settings_special_dates_grant">Udělit přístup</string>
<string name="settings_special_dates_disable_title">Vypnout data kontaktů?</string>
<string name="settings_special_dates_disable_type_message">Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
<string name="settings_special_dates_disable_confirm">Vypnout</string>
<string name="dialog_save">Uložit</string>
<string name="settings_section_about">O aplikaci</string>
<string name="settings_license">Licence</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">od Jean-Luca Makioly</string>
<string name="settings_about_source">Zdroj</string>
<string name="settings_licences">Licence open source</string>
<string name="settings_licences_subtitle">Projekty, na kterých je Calendula postavena</string>
<string name="settings_about_privacy">Zásady ochrany osobních údajů</string>
<string name="settings_about_support">Podpořte vývoj</string>
<string name="settings_about_version">Verze %1$s</string>
<string name="settings_about_logo_desc">Ikona aplikace</string>
<string name="settings_report_problem">Nahlásit problém</string>
<string name="settings_report_problem_hint">Odeslat hlášení o pádu nebo otevřít systém pro sledování chyb</string>
<string name="licences_title">Licence open source</string>
<string name="licences_intro">Calendula zahrnuje níže uvedené projekty. Klepnutím na libovolnou položku zobrazíte jeho zdrojový kód.</string>
<string name="licences_footer">Každý projekt je zde zahrnut v nezměněné podobě a pod uvedenou licencí.</string>
<string name="calendars_title">Kalendáře</string>
<string name="calendars_local_header">Vaše kalendáře</string>
<string name="calendars_local_empty">Zatím nejsou k dispozici žádné místní kalendáře. Vytvořte si kalendář, chcete-li události ukládat pouze do tohoto zařízení.</string>
<string name="calendars_add">Přidat kalendář</string>
<string name="calendars_visibility_hint">Vypnutím kalendáře jej na tomto zařízení skryjete jeho události zmizí z aplikace a přestanou se zobrazovat připomínky. Jde o stejný přepínač, jaký používají i ostatní aplikace kalendáře, takže se kalendář skryje i v nich. Nic se nemaže, další zařízení to neovlivní a kalendář můžete kdykoli znovu zapnout.</string>
<string name="calendars_visibility_a11y">Zobrazit „%1$s“</string>
<string name="calendars_visibility_notice_title">Některé kalendáře jsou vypnuté</string>
<string name="calendars_visibility_notice_message">Calendula zobrazuje kalendáře, které jsou pro toto zařízení zapnuté; některé z vašich kalendářů jsou však vypnuté. Chcete-li zobrazit jejich události, zapněte je v nabídce Nastavení → Kalendáře.</string>
<string name="calendar_picker_missing_title">Chybí vám kalendář?</string>
<string name="calendar_picker_missing_summary">Může být vypnutý, nastavený na režim „pouze pro čtení“ nebo naplněný daty z vašich kontaktů své kalendáře spravujte zde.</string>
<string name="calendars_state_read_only">Pouze pro čtení</string>
<string name="calendars_state_not_synced">Není synchronizováno s tímto zařízením</string>
<string name="calendars_state_managed">Vyplněno z vašich kontaktů</string>
<string name="calendars_managed_delete_locked">Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data.</string>
<string name="calendars_synced_header">Synchronizované kalendáře</string>
<string name="calendars_synced_hint">Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci.</string>
<string name="calendars_manage_in_app">Spravovat v aplikaci</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">Více možností pro %1$s</string>
<string name="calendars_enable_all">Povolit vše</string>
<string name="calendars_disable_all">Zakázat vše</string>
<string name="calendars_add_account">Přidat účet</string>
<string name="calendars_new_title">Nový kalendář</string>
<string name="calendars_edit_title">Upravit kalendář</string>
<string name="calendars_name_label">Jméno</string>
<string name="calendars_color_label">Barva</string>
<string name="calendars_description_hint">Přidat popis</string>
<string name="calendars_delete_confirm_title">Vymazat kalendář?</string>
<string name="calendars_delete_confirm_message">„%1$s“ a všechny jeho události budou z tohoto zařízení trvale odstraněny.</string>
<string name="calendars_write_error">Změnu se nepodařilo uložit.</string>
<string name="calendars_backup_header">Záloha</string>
<string name="calendars_backup_hint">Místní kalendáře nejsou nikde synchronizovány, proto je pro uchování kopie exportujte do souboru ve formátu .ics.</string>
<string name="calendars_backup_action">Exportovat jako soubor .ics</string>
<string name="calendars_export_title">Exportovat kalendáře</string>
<string name="calendars_export_hint">Vyberte, které kalendáře chcete zahrnout do souboru .ics.</string>
<string name="calendars_export_action">Exportovat</string>
<string name="calendars_restore_header">Obnovit</string>
<string name="calendars_restore_action">Obnovit ze souboru .ics</string>
<string name="calendars_restore_hint">Importuovat události ze zálohy nebo z jiné aplikace kalendáře.</string>
<string name="calendars_auto_backup">Automatické zálohování</string>
<string name="calendars_auto_backup_hint">Pravidelně exportujte své místní kalendáře do složky jako soubor .ics.</string>
<string name="calendars_auto_backup_folder">Složka pro zálohování</string>
<string name="calendars_auto_backup_folder_unset">Klepnutím vyberte složku</string>
<string name="calendars_auto_backup_interval">Interval</string>
<string name="calendars_auto_backup_every">Každý %1$s</string>
<string name="calendars_auto_backup_interval_min">Minimálně 30 minut.</string>
<string name="calendars_auto_backup_status_never">Zatím není nastaveno žádné automatické zálohování</string>
<string name="calendars_auto_backup_status_ok">Poslední záloha: %1$s</string>
<string name="calendars_auto_backup_status_failed">Poslední zálohování selhalo: %1$s</string>
<string name="backup_channel_name">Záloha</string>
<string name="backup_channel_description">Upozorní, pokud automatické zálohování opakovaně selže.</string>
<string name="backup_failed_title">Automatické zálohování selhalo</string>
<string name="backup_failed_text">Calendula nemohla zapsat záložní soubor. Zkontrolujte složku pro zálohování v nastavení.</string>
<string name="calendars_backup_failed">Zálohu se nepodařilo exportovat.</string>
<plurals name="calendars_backup_done">
<item quantity="one">%d událost exportována.</item>
<item quantity="few">%d události exportovány.</item>
<item quantity="many">%d událostí exportováno.</item>
<item quantity="other">%d událostí exportováno.</item>
</plurals>
<string name="import_title">Importovat události</string>
<string name="import_target_header">Přidat do kalendáře</string>
<string name="import_empty">V souboru nenalezeny žádné události.</string>
<string name="import_failed">Tento soubor se nepodařilo přečíst.</string>
<string name="import_no_calendar">Neexistuje žádný kalendář s právem zápisu, do kterého by bylo možné importovat. Nejprve vytvořte místní kalendář.</string>
<string name="import_done_title">Import dokončen</string>
<string name="import_done_dedup_note">Události, které již byly v kalendáři, byly přeskočeny.</string>
<string name="import_done_added_label">Přidáno</string>
<string name="import_done_skipped_label">Duplicity</string>
<string name="import_done_failed_label">Nepřidáno</string>
<string name="import_done_failed_note">Některé události se nepodařilo přidat, a proto byly vynechány.</string>
<string name="import_done_stopped_title">Import byl zastaven</string>
<string name="import_close">Zavřít</string>
<string name="import_warning_recurrence">Některé změněné výskyty opakujících se událostí byly přeskočeny.</string>
<string name="import_warning_no_start">Událost bez času zahájení byla přeskočena.</string>
<string name="import_warning_attendees">Seznamy hostů nebyly importovány.</string>
<string name="import_warning_timezone">Neznámé časové pásmo se přepnulo na časové pásmo vašeho zařízení.</string>
<string name="import_warning_tasks">Úkoly v tomto souboru byly přidány jako události.</string>
<string name="import_warning_recurrence_repaired">Chybné pravidlo pro opakování bylo opraveno.</string>
<string name="import_button">Importovat</string>
<plurals name="import_title_count">
<item quantity="one">Importována %d událost</item>
<item quantity="few">Importovány %d události</item>
<item quantity="many">Importováno %d událostí</item>
<item quantity="other">Importováno %d událostí</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">V tomto souboru je %d událost.</item>
<item quantity="few">V tomto souboru jsou %d události.</item>
<item quantity="many">V tomto souboru je %d událostí.</item>
<item quantity="other">V tomto souboru je %d událostí.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importovat %d událost</item>
<item quantity="few">Importovat %d události</item>
<item quantity="many">Importovat %d událostí</item>
<item quantity="other">Importovat %d událostí</item>
</plurals>
<plurals name="import_done_stopped">
<item quantity="one">Kalendář přestal v průběhu procesu zpracovávat události. %d událost ze souboru nebyla přidána.</item>
<item quantity="few">Kalendář přestal v průběhu procesu zpracovávat události. %d události ze souboru nebyly přidány.</item>
<item quantity="many">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
<item quantity="other">Kalendář přestal v průběhu procesu zpracovávat události. %d událostí ze souboru nebylo přidáno.</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">%d událost importována.</item>
<item quantity="few">%d události importovány.</item>
<item quantity="many">%d událostí importováno.</item>
<item quantity="other">%d událostí importováno.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Přeskočena %d událost, která již v tomto kalendáři existuje.</item>
<item quantity="few">Přeskočeny %d události, která již v tomto kalendáři existují.</item>
<item quantity="many">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
<item quantity="other">Přeskočeno %d událostí, která již v tomto kalendáři existují.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="one">%d událost nebylo možné přidat.</item>
<item quantity="few">%d události nebylo možné přidat.</item>
<item quantity="many">%d událostí nebylo možné přidat.</item>
<item quantity="other">%d událostí nebylo možné přidat.</item>
</plurals>
<string name="shortcut_new_event_short">Nová událost</string>
<string name="shortcut_new_event_long">Vytvořit novou událost</string>
<string name="qs_tile_new_event_label">Nová událost</string>
<string name="settings_qs_tile">Přidat dlaždici rychlého nastavení</string>
<string name="settings_qs_tile_hint">Přidat dlaždici „Nová událost“ do panelu rychlého nastavení.</string>
<string name="crash_dialog_title">%1$s selhalo</string>
<string name="crash_dialog_message">%1$s se minule neočekávaně ukončilo. Můžete pomoci problém vyřešit odesláním tohoto hlášení. Hlášení zůstane ve vašem zařízení, dokud se jej nerozhodnete odeslat, a neobsahuje žádné osobní údaje ani obsah kalendáře pouze níže uvedené technické detaily.</string>
<string name="crash_dialog_report">Odeslat hlášení o selhání</string>
<string name="crash_dialog_dismiss">Později</string>
<string name="crash_report_clip_label">Hlášení o selhání %1$s</string>
<string name="crash_report_copied">Hlášení bylo zkopírováno do schránky</string>
<string name="crash_report_open_failed">Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce.</string>
<string name="special_dates_calendar_birthday">Narozeniny</string>
<string name="special_dates_calendar_anniversary">Výročí</string>
<string name="special_dates_calendar_custom">Speciální data</string>
<string name="special_dates_default_title_birthday">Narozeniny má {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Výročí má {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>
+40
View File
@@ -263,4 +263,44 @@
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
<string name="reminder_channel_name">Pripomienky udalostí</string>
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
<string name="agenda_today_action">Dnes</string>
<string name="agenda_header_today">Dnes</string>
<string name="search_back">Späť</string>
<string name="widget_new_event">Nová udalosť</string>
<string name="widget_prev_month">Predchádzajúci mesiac</string>
<string name="widget_next_month">Nasledujúci mesiac</string>
<string name="widget_today">Dnes</string>
<string name="filter_title">Kalendáre</string>
<string name="settings_title">Nastavenia</string>
<string name="settings_back">Späť</string>
<string name="back">Späť</string>
<string name="timeline_scale_custom">Vlastné</string>
<string name="agenda_range_day">Dnes</string>
<string name="state_failure_all_hidden">Všetky vaše kalendáre sú vypnuté.</string>
<string name="state_failure_all_hidden_action">Správa kalendárov</string>
<string name="field_copy_action">Kopírovať</string>
<string name="field_copied">Skopirovane do schránky</string>
<string name="field_copy_failed">Toto sa nepodarilo skopírovať</string>
<string name="event_detail_title">Názov</string>
<string name="reminder_after">%1$s po</string>
<string name="reminder_onboarding_title">Nikdy nezmeškajte udalosť</string>
<string name="reminder_onboarding_body">Samotný Android nezobrazuje pripomienky udalostí - to musí zabezpečiť aplikácia kalendáru. Nechajte túto prácu na Calendule.</string>
<string name="reminder_benefit_delivery_title">Pripomienky, doručené</string>
<string name="reminder_benefit_delivery_body">Pripomienky k vašim udalostiam vám prídu ako notifikácia, vždy a načas.</string>
<string name="reminder_benefit_duplicates_title">Používate ďalšiu aplikáciu s kalendárom?</string>
<string name="reminder_onboarding_skip_button">Neskôr</string>
<plurals name="search_delete_title">
<item quantity="one">Odstrániť %d udalosť?</item>
<item quantity="few">Odstrániť %d udalosti?</item>
<item quantity="many">Odstrániť %d udalostí?</item>
<item quantity="other">Odstrániť %d udalostí?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d udalosť odstránená</item>
<item quantity="few">%d udalosti odstránené</item>
<item quantity="many">%d odstránených udalostí</item>
<item quantity="other">%d odstránených udalostí</item>
</plurals>
<string name="agenda_range_this_week">Tento týždeň</string>
<string name="agenda_range_custom">Vlastné…</string>
</resources>
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class BlockTextMetricsTest {
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
private val titleLine = 14.dp
/** A block with room to spare, for the line arithmetic. */
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
inset = blockTextInset(height, titleLine),
available = height - blockTextInset(height, titleLine) * 2,
titleLine = titleLine,
titleLeading = 16.dp,
timeLine = 13.dp,
)
@Test
fun `a block with room keeps the full inset`() {
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
.isEqualTo(BLOCK_TEXT_INSET)
}
@Test
fun `the inset tapers instead of snapping as the block shrinks`() {
// Half the inset left over is half the inset kept, so a pinch closes the
// gap frame by frame rather than dropping it in one.
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
.isEqualTo(1.dp)
}
@Test
fun `a block exactly one title tall spends nothing on padding`() {
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `a block shorter than a line never insets negatively`() {
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
}
@Test
fun `the title survives a block that used to be too short for it`() {
// 18dp is under the old floor — a title line plus 2dp of inset at each
// edge — and over the new one, which is the line on its own (#289).
val m = metrics(height = 18.dp)
assertThat(m.fitsTitle).isTrue()
}
@Test
fun `a block under one line still drops the title`() {
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
}
@Test
fun `every title line after the first costs a whole line box`() {
// The trim reaches the outer edges only, so the leading between two
// lines is still there to pay for.
val m = metrics()
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
}
@Test
fun `the budget is what the block can actually draw, not what divides into it`() {
val m = metrics()
// Two lines cost 30dp: 29 buys one, 30 buys the second.
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
}
@Test
fun `a budget line is always one the block can pay for`() {
val m = metrics()
(0..80).forEach { dp ->
val within = dp.dp
val budget = m.titleBudget(within)
assertThat(m.titleHeight(budget)).isAtMost(within)
}
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
@@ -75,17 +76,16 @@ class TimelineZoomTest {
}
@Test
fun `a pinch held against a fractional bound stays put`() {
// A bound that is not a whole pixel is a height the pinch can be pushed
// against but never land on, so every frame of a held gesture would look
// like a scale change and hand the focal anchor a scroll correction.
fun `a pinch held against either bound stays put`() {
val fillPx = 62.083f
val maxPx = 616.5f
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
assertThat(floor).isEqualTo(63f)
// The ceiling is pulled onto the pixel grid so it stays landable; the
// fill floor is landable as it is, being the clamp result itself.
assertThat(floor).isEqualTo(fillPx)
assertThat(ceiling).isEqualTo(616f)
// Landing there and being pushed further must not move them again.
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
@@ -100,6 +100,35 @@ class TimelineZoomTest {
assertThat(floor * 24).isAtLeast(viewport)
}
@Test
fun `pinching all the way out leaves nothing to scroll`() {
// #290: rounding the fill floor up to a whole pixel made the day one
// pixel per hour taller than the viewport it was supposed to fill, so a
// fully zoomed-out timeline still scrolled a hair and bounced off
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
// still. 1490 is deliberately not divisible by 24.
val viewport = 1490f
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
assertThat(floor * 24).isWithin(0.01f).of(viewport)
}
@Test
fun `the pinch floor is the height FitDay resolves to`() {
// The inconsistency the issue is about: the two ways to reach "the whole
// day on one screen" have to arrive at the same height.
val density = Density(2.5f)
val viewport = 596.dp
with(density) {
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
val pinched = pinchedHourHeightPx(
target = 1f,
fillPx = fillHourHeight(viewport).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
assertThat(pinched).isWithin(0.01f).of(fitDay)
}
}
@Test
fun `a settled pinch is what gets persisted`() {
var persisted: TimelineScale? = null
+2 -2
View File
@@ -1,7 +1,7 @@
---
title: Privacy Policy — Calendula
description: What Calendula does with your data. It collects nothing, sends nothing, and has no internet permission at all.
updated: 2026-07-28
updated: 2026-09-15
---
<!--
@@ -33,7 +33,7 @@ Calendula collects nothing, sends nothing, and has no user accounts. It has **no
IT-Dienstleister | Jean-Luc Makiola
Mahlerstraße 10
14772 Brandenburg an der Havel
Email: [business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)
Email: [support@jeanlucmakiola.de](mailto:support@jeanlucmakiola.de)
## 2. No data collection
@@ -0,0 +1,3 @@
Fixed
• The app no longer reopens the event you last came in on. Opening an event from a reminder or a widget made it return to that event on every later launch, or show an error screen once the event was gone.
• "Today" works in the Agenda again. It now scrolls the list back to today, and stays offered while you are scrolled away from it.
@@ -4,7 +4,7 @@ Name: Calendula
Summary: A modern Material 3 Expressive calendar for Android.
Categories:
- Time
- Calendar & Agenda
SourceCode: https://codeberg.org/jlmakiola/calendula
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues