Compare commits

..

4 Commits

Author SHA1 Message Date
Jean-Luc Makiola
0f1df4800d Translations update from Weblate (#247)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/247
2026-08-27 20:38:38 +02:00
Weblate
7351b2d35c Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Bazyli Cyran
1a66a31563 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Weblate
fe3c659804 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
11 changed files with 143 additions and 477 deletions

View File

@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Home-screen widgets now turn the page at midnight.** Both the month and the
agenda widget kept highlighting yesterday as "today" — and the agenda kept
greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
## [2.19.3] — 2026-08-22
### Added
@@ -1481,4 +1471,3 @@ automatically, with zero telemetry and no internet permission.
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228

View File

@@ -50,12 +50,3 @@
# the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
# Belt and braces one level up: MonthWidgetReceiver and AgendaWidgetReceiver are
# nearly as alike (same supertype, same overrides, only a differing property
# initializer), and Glance's provider map is keyed off the receiver component
# too. AGP's manifest-derived keep rules already cover them, and the rule above
# keeps the two widgets distinct enough that the receivers' constructors differ
# so this is redundant today. It is here because #89 cost a release to diagnose
# and the guarantee should not rest on a component staying in the manifest.
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -331,13 +331,8 @@
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync),
and the day boundary arrives as the app's own ROLLOVER alarm (#228),
delivered by an explicit PendingIntent so it needs no filter here.
DATE_CHANGED is kept as a free extra only — it is not an exempted
implicit broadcast, so a manifest-declared receiver is not given it
on Android 8+. TIME_SET / TIMEZONE_CHANGED move the day boundary,
and boot / package-replace wipe the alarm, so all four re-arm it.
Exported: the system broadcasts arrive from outside the app. -->
and the system broadcasts the date/time ones at midnight / clock
changes so "today" highlighting rolls over. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
@@ -351,8 +346,6 @@
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>

View File

@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -45,21 +44,6 @@ class CalendulaApp : Application() {
reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
reconcileWidgetRollover()
}
/**
* Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers the cases no broadcast reaches — an
* install upgrading into the fix, or an alarm dropped by a force-stop, is
* armed again the next time the app is opened. Off the main thread because
* it makes a handful of binder calls and every process start runs it,
* including ones a worker or a receiver triggered.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
}
/**

View File

@@ -1,122 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, but that broadcast is not
* on the implicit-broadcast exemption list, so a manifest-declared receiver has
* never been given it since Android 8 — leaving only `updatePeriodMillis`, which
* the system defers in doze and OEM skins throttle harder still. The result was
* yesterday staying highlighted (and the agenda's past-event dimming staying
* anchored to yesterday) until something else forced a redraw.
*
* Exactly one alarm exists at a time and every firing re-arms the next one, the
* same shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* It is deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (which plain `set` does not), and a rollover that lands a few
* minutes late is invisible on a sleeping screen. Exact alarms stay reserved for
* reminder snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight, never exactly on it. An alarm delivered a few
* milliseconds early would still read the old date and re-arm for an instant
* later; the offset makes "the day has changed" unambiguous.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* Uses the *actual* start of the day rather than 00:00, so it stays correct
* where a DST jump means midnight never happens (Havana springs from 00:00 to
* 01:00) and where a whole local date is skipped by a date-line move (Apia
* had no 30 December 2011) — the loop then walks on to the next real day.
*
* The mirror case, a zone that rewinds *across* midnight so the day starts
* twice, resolves to the earlier start; the widget would then run an hour
* ahead of the clock. No entry in the current tz database does that (Brazil,
* which used to, dropped DST in 2019), and `updatePeriodMillis` covers it,
* so it is not worth carrying state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}

View File

@@ -12,47 +12,19 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Redraws both home-screen widgets when their data goes stale, and keeps the
* midnight rollover alarm armed. Triggered by:
* Redraws both home-screen widgets when their data goes stale. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync.
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
* the day boundary, so "today" highlighting and the agenda's past-event
* dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — a clock or zone change moves the day
* boundary relative to the armed alarm, so both redraw *and* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms. The
* package-replaced one is also what arms existing installs that upgrade into
* the fix without re-adding their widget.
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
* and the upcoming window roll over at midnight / on a clock change.
*
* `DATE_CHANGED` is still in the manifest filter as a free extra, but nothing
* depends on it: it is not an exempted implicit broadcast, so a manifest-declared
* receiver has not actually been given it since Android 8. The widgets also carry
* an `updatePeriodMillis` backstop in their provider XML, and the month widget's
* refresh button forces an immediate redraw.
*
* Exported for the system broadcasts; an extra redraw triggered by another app
* is harmless.
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
* XML, and the month widget's refresh button forces an immediate redraw.
*/
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The receiver has to stay exported for the system broadcasts, so an
// explicit intent can reach it with anything in it. Nothing here reads
// the intent's data and nothing crosses a trust boundary, but narrowing
// to the actions we actually asked for keeps a stray broadcast from
// costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
// Re-arm first: whatever happens to the redraw, the next day boundary is
// covered. Boot and package-replace dropped the alarm outright; a
// rollover just consumed it; a clock change invalidated it.
WidgetRolloverScheduler.sync(appContext)
// Boot and package-replace only cost us the alarm. The host sends
// APPWIDGET_UPDATE after both anyway, so redrawing here would just repeat
// two wide provider reads and two RemoteViews serialisations in a cold
// process, at the moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return
val pending = goAsync()
val appContext = context.applicationContext
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
@@ -66,23 +38,4 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
}
}
}
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_PROVIDER_CHANGED,
Intent.ACTION_DATE_CHANGED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
}

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -13,33 +10,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] only cancels the
* alarm if no month widget is left either, so removing one kind never stops
* the other from rolling over.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
}

View File

@@ -152,19 +152,7 @@ class ShiftMonthAction : ActionCallback {
val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
val next = cur + delta
// Landing back on the current month clears the key rather than
// storing today's index, so the widget goes back to *following* the
// date instead of being pinned to the month that happened to be
// current when it was tapped. Paging forward and back is the very
// workaround #228's reporter used to force a redraw; storing the
// index there would have left them stuck on that month for good once
// it stopped being the current one.
if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
}
prefs[MONTH_INDEX_KEY] = cur + delta
}
MonthWidget().update(context.applicationContext, glanceId)
}

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.widget.month
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
@@ -12,37 +9,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last month widget removed. [WidgetRolloverScheduler.sync] only cancels the
* alarm if no agenda widget is left either, so removing one kind never stops
* the other from rolling over.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the rollover alarm's self-heal: anything that drops
* a pending alarm without a broadcast — a force-stop, a battery-restricted
* transition, an OEM freeze — is repaired here rather than waiting for the
* app to be opened. Re-arming closer to midnight also narrows the inexact
* alarm's delivery window, which scales with how far out it was set.
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
}

View File

@@ -16,14 +16,14 @@
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
<string name="state_failure_provider">Nie udało się kalendarza.</string>
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia.</string>
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać.</string>
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
<string name="permission_retry_button">Spróbuj ponownie</string>
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszcza telefonu.</string>
<string name="permission_benefit_private_title">Prywatność z założenia</string>
<string name="permission_benefit_private_body">Brak uprawnień do internetu, brak telemetrii — nic nigdy nie opuszcza Twojego telefonu.</string>
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
@@ -333,7 +333,7 @@
<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="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych 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>
@@ -353,10 +353,10 @@
<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">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność 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_appearance_subtitle">Motyw, kolory, czcionki</string>
<string name="settings_views_subtitle">Domyślny widok, układ, kolejność</string>
<string name="settings_event_form_subtitle">Domyślne pola i zachowanie</string>
<string name="settings_notifications_subtitle">Przypomnienia i dostarczanie</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>
@@ -509,8 +509,8 @@
<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_soften_colors">Harmonizuj kolory kalendarza</string>
<string name="settings_soften_colors_summary">Zachowaj odcień każdego kalendarza, ale wyrównaj jego jasność, tak aby każde wydarzenie pozostało czytelne, a kolory współgrały ze sobą. 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>
@@ -543,4 +543,128 @@
<string name="month_split_no_events">Brak planów</string>
<string name="month_split_expand">Pokaż cały miesiąc</string>
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
<string name="event_move_gone">To wydarzenie już nie istnieje</string>
<string name="event_move_action">Przenieś…</string>
<string name="event_move_recurring_title">Przenieś wydarzenie cykliczne</string>
<string name="event_move_occurrence_only">Przeniesienie całej serii zmieniłoby dni, w których wypadają wydarzenia, więc można przenieść tylko to wystąpienie.</string>
<string name="event_move_done">Przeniesiono na %1$s</string>
<string name="event_move_undo">Cofnij</string>
<string name="event_move_undone">Cofnięto przeniesienie</string>
<string name="event_move_failed">Nie udało się przenieść wydarzenia</string>
<string name="event_move_write_denied">Calendula wymaga uprawnień do zapisu, aby móc przenosić wydarzenia</string>
<string name="event_move_blocked_series_end">Nie można przenieść wydarzenia poza datę zakończenia jego serii</string>
<string name="event_edit_recurrence_next">Następne: %1$s</string>
<string name="event_edit_recurrence_next_none">Ta reguła nie ma powtórzeń</string>
<string name="event_access_default_summary">Standardowe ustawienie tego kalendarza</string>
<string name="event_access_public_summary">Każdy, kto ma dostęp, widzi pełne szczegóły</string>
<string name="event_access_private_summary">Inni widzą tylko to, że jesteś zajęty</string>
<string name="event_access_confidential_summary">Oznaczone jako poufne; co to oznacza, zależy od konta kalendarza</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Maksymalnie %1$s</string>
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
<string name="onboarding_backup_title">Twoje wydarzenia istnieją tylko tutaj</string>
<string name="onboarding_backup_body">Nic z tego telefonu nie synchronizuje się z kontem, więc jego utrata oznaczałaby utratę kalendarza. Calendula może utworzyć dla Ciebie kopię zapasową.</string>
<string name="onboarding_backup_benefit_folder_title">Wybrany przez Ciebie folder</string>
<string name="onboarding_backup_benefit_folder_body">Kopie zapasowe to zwykłe pliki .ics — umieść je w miejscu, które się synchronizuje, albo na karcie SD.</string>
<string name="onboarding_backup_benefit_daily_title">Raz dziennie, automatycznie</string>
<string name="onboarding_backup_benefit_daily_body">Calendula eksportuje Twoje lokalne kalendarze w tle. Możesz zmienić częstotliwość w Ustawieniach.</string>
<string name="onboarding_backup_enable_button">Wybierz folder i utwórz kopię zapasową</string>
<string name="onboarding_view_title">Co powinno się otworzyć jako pierwsze?</string>
<string name="onboarding_month_style_title">Jak powinien wyglądać miesiąc?</string>
<string name="onboarding_view_continue_button">Kontynuuj</string>
<string name="onboarding_back">Wstecz</string>
<string name="onboarding_visibility_button">Rozumiem</string>
<string name="onboarding_done_title">Wszystko gotowe</string>
<string name="onboarding_done_body">Twoje kalendarze są gotowe. Wszystko co właśnie wybrałeś możesz później zmienić w Ustawieniach.</string>
<string name="onboarding_done_button">Otwórz mój kalendarz</string>
<plurals name="search_selected_count">
<item quantity="one">%d wybrane</item>
<item quantity="few">%d wybrane</item>
<item quantity="many">%d wybranych</item>
<item quantity="other">%d wybranych</item>
</plurals>
<string name="search_in_descriptions">Znaleziono w opisach</string>
<string name="search_selection_close">Anuluj wybór</string>
<string name="search_select_all">Wybierz wszystkie</string>
<string name="search_delete_selected">Usuń wybrane</string>
<plurals name="search_delete_title">
<item quantity="one">Usunąć %d wydarzenie?</item>
<item quantity="few">Usunąć %d wydarzenia?</item>
<item quantity="many">Usunąć %d wydarzeń?</item>
<item quantity="other">Usunąć %d wydarzeń?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">Usunięto %d wydarzenie</item>
<item quantity="few">Usunięto %d wydarzenia</item>
<item quantity="many">Usunięto %d wydarzeń</item>
<item quantity="other">Usunięto %d wydarzeń</item>
</plurals>
<string name="search_delete_partial">%1$d usunięto, %2$d nie udało się</string>
<string name="search_delete_denied_partial">%1$d usunięto, następnie utracono uprawnienia do zapisu</string>
<string name="settings_theme_hint">Czy aplikacja ma być jasna, czy ciemna. Wybór zostanie zastosowany natychmiast.</string>
<string name="settings_theme_system_summary">Obecnie %1$s</string>
<string name="settings_font_specimen_heading">czwartek, 14 maja</string>
<string name="settings_font_specimen_body">Przegląd zespołu o 10:00, a potem lunch z Robinem w kawiarni na rynku.</string>
<string name="settings_week_start_auto_summary">Obecnie %1$s</string>
<string name="settings_time_format_auto_summary">Zgodnie z systemem: %1$s</string>
<string name="settings_timeline_scale">Wysokość godzin</string>
<string name="settings_timeline_scale_hint">Ile miejsca w pionie zajmuje jedna godzina w widoku tygodnia i dnia. Oba widoki korzystają z tego ustawienia. Możesz również rozciągnąć lub ścisnąć oś czasu dwoma palcami, aby ustawić dowolną wysokość pomiędzy.</string>
<string name="timeline_scale_fit_day">Cały dzień na ekranie</string>
<string name="timeline_scale_fit_day_summary">Cała doba na jednym ekranie, bez przewijania</string>
<string name="timeline_scale_compact">Kompaktowa</string>
<string name="timeline_scale_compact_summary">Więcej godzin na ekranie, mniejsze bloki</string>
<string name="timeline_scale_regular">Standardowa</string>
<string name="timeline_scale_regular_summary">Standardowy rozmiar</string>
<string name="timeline_scale_comfortable">Wygodna</string>
<string name="timeline_scale_comfortable_summary">Większe bloki, więcej przewijania</string>
<string name="timeline_scale_custom">Własna</string>
<string name="timeline_scale_custom_summary">Wysokość, którą wybrałeś gestem „uszczypnięcia” osi czasu</string>
<string name="settings_drag_to_reschedule">Przeciągnij, aby zmienić termin</string>
<string name="settings_drag_to_reschedule_summary">Przenieś wydarzenie, przeciągając je na inny dzień lub godzinę</string>
<string name="settings_past_events_sample_morning">Przegląd zespołu</string>
<string name="settings_past_events_sample_midday">Lunch z Robin</string>
<string name="settings_past_events_sample_evening">Próba chóru</string>
<string name="settings_widget_size">Rozmiar widżetu agendy</string>
<string name="settings_widget_size_hint">Rozmiar tekstu wyświetlanego przez widżet agendy na ekranie domowym. Widżet miesiąca nie posiada tego ustawienia — jego siatka zawsze dopasowuje się do przydzielonego miejsca.</string>
<string name="settings_widget_size_small">Mały</string>
<string name="settings_widget_size_medium">Średni</string>
<string name="settings_widget_size_large">Duży</string>
<string name="settings_widget_size_extra_large">Bardzo duży</string>
<string name="settings_widget_size_summary">Tekst wydarzenia %1$d%%</string>
<string name="settings_event_duration">Domyślny czas trwania</string>
<string name="settings_event_duration_hint">Czas trwania nowego wydarzenia do momentu zmiany godziny jego zakończenia. Nie dotyczy to wydarzeń całodniowych.</string>
<string name="settings_calendar_durations_title">Czas trwania dla poszczególnych kalendarzy</string>
<string name="settings_calendar_durations_hint">Przypisz kalendarzowi własną domyślną długość — np. 8 godzin dla zmian w pracy.</string>
<string name="settings_calendar_duration_inherits">Domyślny (%1$s)</string>
<string name="settings_calendar_duration_use_default">Używaj domyślnego czasu trwania (%1$s)</string>
<string name="settings_allday_reminder_fires_at">Powiadamia o %1$s</string>
<string name="settings_group_look">Wygląd i zachowanie</string>
<string name="settings_group_data">Dane</string>
<string name="settings_group_app">Aplikacja</string>
<string name="settings_group_about">O aplikacji</string>
<string name="settings_widgets_subtitle">Widżet agendy, kafelek Szybkich ustawień</string>
<string name="settings_backup_subtitle">Eksport, import, automatyczna kopia zapasowa</string>
<string name="settings_section_widgets">Widżety i kafelki</string>
<string name="settings_widgets_hint">Ustawienia widżetów ekranu głównego i kafelka Szybkich ustawień. Aby dodać widżet, naciśnij i przytrzymaj na ekranie domowym.</string>
<string name="settings_section_backup">Kopia zapasowa i przywracanie</string>
<string name="settings_views_all_header">Wszystkie widoki</string>
<string name="settings_week_day_header">Tydzień i dzień</string>
<string name="settings_default_view_hint">Widok, który pojawia się po uruchomieniu aplikacji Calendula.</string>
<string name="settings_week_start_hint">Dzień, od którego zaczyna się każdy tydzień we wszystkich widokach i widżetach.</string>
<string name="settings_time_format_hint">Format godziny w aplikacji. Opcja automatyczna dostosowuje się do ustawień systemowych.</string>
<string name="settings_past_events_hint">Co agenda robi z wydarzeniami, które już się zakończyły.</string>
<string name="settings_dynamic_color_summary">Dopasuj kolory aplikacji do swojej tapety.</string>
<string name="settings_about_privacy">Polityka prywatności</string>
<string name="calendars_visibility_hint">Wyłącz kalendarz, aby ukryć go na tym urządzeniu — jego wydarzenia znikną z aplikacji i przestaniesz otrzymywać powiadomienia. To ten sam przełącznik, którego używają inne aplikacje kalendarza, więc one również go ukryją. Nic nie zostanie usunięte, nie wpłynie to na inne urządzenia, a kalendarz możesz tu włączyć ponownie w dowolnym momencie.</string>
<string name="calendars_visibility_a11y">Pokaż \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Niektóre kalendarze są wyłączone</string>
<string name="calendars_visibility_notice_message">Calendula wyświetla kalendarze włączone na tym urządzeniu, a niektóre z Twoich są wyłączone. Aby zobaczyć ich wydarzenia, włącz je w sekcji Ustawienia → Kalendarze.</string>
<string name="calendar_picker_missing_title">Brakuje jakiegoś kalendarza?</string>
<string name="calendar_picker_missing_summary">Może być wyłączony, tylko do odczytu lub wypełniony na podstawie Twoich kontaktów — zarządzaj swoimi kalendarzami tutaj.</string>
<string name="calendars_state_read_only">Tylko do odczytu</string>
<string name="calendars_state_not_synced">Niezsynchronizowany z tym urządzeniem</string>
<string name="calendars_state_managed">Wypełniony na podstawie Twoich kontaktów</string>
<string name="calendars_managed_delete_locked">Ten kalendarz jest uzupełniany na podstawie Twoich kontaktów, więc Calendula utworzy go ponownie przy następnej synchronizacji. Aby go usunąć, wyłącz daty szczególne w menu Ustawienia → Szczególne daty kontaktów.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="onboarding_backup_skip_button">Nie teraz</string>
</resources>

View File

@@ -1,166 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// The alarm has just fired and is re-arming: it must move a whole day on,
// otherwise the widget would wake itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward, the case most likely to produce a
// target in the past and so an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: midnight itself is
// unambiguous, but the day is 25h long, so "now + 24h" would land at
// 23:00 on the 26th and never roll the date over at all.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned deliberately: the widget then runs an hour ahead
// of the clock until the next redraw, which is the accepted trade
// (Brazil dropped DST in 2019, so no live zone does this).
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// Flying east and getting TIMEZONE_CHANGED must re-arm to the new local
// midnight — the arithmetic follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
}