Compare commits
85 Commits
v2.18.0
...
2359efaef6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2359efaef6 | |||
|
|
398cfc8906 | ||
| c0b7ad29ef | |||
| bc49730ea5 | |||
| a1d1894f84 | |||
|
|
a2172dce12 | ||
|
|
4099f66e91 | ||
| 49112b11b3 | |||
| 1218bb523c | |||
| 17600a3905 | |||
|
|
db5104a7d0 | ||
|
|
6306064fc3 | ||
|
|
d5a4761f2c | ||
|
|
66d6681798 | ||
|
|
fb61169656 | ||
|
|
62079fba0b | ||
|
|
cc0fc8edc3 | ||
|
|
62174fcd53 | ||
|
|
55c1b2ac25 | ||
|
|
0ccc610144 | ||
|
|
5d009b7dc0 | ||
|
|
28924daf02 | ||
|
|
e71fb2a625 | ||
|
|
aa5f76fc1b | ||
|
|
5b099714f6 | ||
| 163d821d1f | |||
|
|
fe98b1ed66 | ||
|
|
c0270ab3a0 | ||
|
|
6019d6c0b8 | ||
|
|
19bc5967a4 | ||
|
|
ddab3454bb | ||
|
|
247c9ee6cd | ||
|
|
6be170cae2 | ||
|
|
8133584f1e | ||
|
|
4cdb42afba | ||
|
|
19a4c05e93 | ||
|
|
aba06affbf | ||
|
|
2b132a5b7a | ||
|
|
52ecce03b8 | ||
|
|
0eb76db12d | ||
|
|
d03985eada | ||
|
|
34108b9c50 | ||
| 49a0c114c2 | |||
|
|
b1ce6f85cc | ||
|
|
ca5f87af11 | ||
|
|
7445092767 | ||
|
|
5ef66120bf | ||
|
|
bda86cd73d | ||
|
|
56953b0c50 | ||
|
|
7c8cd1e145 | ||
|
|
bce36956d1 | ||
|
|
e0fdadc7ca | ||
|
|
136167b54c | ||
| 02fd599e6b | |||
| b0412a96f3 | |||
|
|
06dbb89868 | ||
|
|
b002f45f29 | ||
|
|
c1eb702384 | ||
|
|
307517e85a | ||
|
|
a8ba227e8b | ||
|
|
cbfdfb9710 | ||
| d85003416e | |||
|
|
61c516149e | ||
|
|
560225cb8c | ||
|
|
2e8367afd4 | ||
| 8c40109341 | |||
|
|
4259c996b6 | ||
| d4218227bd | |||
|
|
6a4c5084a5 | ||
|
|
e38b9cde1f | ||
|
|
dbae8b5bcf | ||
|
|
2d06b7ad32 | ||
|
|
10a674deba | ||
|
|
93b9aa8f34 | ||
| ca826737ed | |||
| 5365cc67eb | |||
|
|
25e3640ba0 | ||
| 3f85bac74e | |||
| 5b83b9da73 | |||
| 3c8c555b94 | |||
| a6a6c0c714 | |||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 | ||
|
|
5764bd889b |
@@ -551,12 +551,11 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.detect.outputs.version }}
|
||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||
# Where the bundle lands. `internal` by default so a release reaches
|
||||
# testers rather than the public, and promotion to production stays a
|
||||
# deliberate human action in the Play Console — the same posture as
|
||||
# holding UI releases for on-device review. Override with the PLAY_TRACK
|
||||
# repo variable once the flow is trusted.
|
||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
|
||||
# Where the bundle lands. `production` — the release itself is already the
|
||||
# gate (a bumped versionName only reaches main after on-device review), so
|
||||
# a second manual promotion in the Play Console bought nothing but delay.
|
||||
# Override with the PLAY_TRACK repo variable to stage a release instead.
|
||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
||||
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
||||
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
|
||||
# it instead of committing — used to rehearse the first upload.
|
||||
|
||||
170
CHANGELOG.md
170
CHANGELOG.md
@@ -7,6 +7,162 @@ 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]).
|
||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
||||
including Google ones that still show as on-device, and any local calendar —
|
||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
||||
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
||||
Android can only attach a single-occurrence change to its series once the
|
||||
calendar has been synced at least once, so on those calendars the change had
|
||||
nowhere to go. Calendula now removes that one occurrence from the series and
|
||||
saves the edit as its own event instead, which is what you see either way. A
|
||||
save that does fail also says so for longer, rather than flashing past
|
||||
([#234]).
|
||||
|
||||
## [2.19.3] — 2026-08-22
|
||||
|
||||
### Added
|
||||
- **Czech, Japanese and Russian**, all three contributed as community
|
||||
translations through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
They are early — Czech covers about a third of the app, Russian a fifth,
|
||||
Japanese has just started — so untranslated parts still show in English until
|
||||
they fill out. Pick any of them under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to James Trivette (Czech), ame0011
|
||||
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
|
||||
|
||||
### Changed
|
||||
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
|
||||
bkrz for carrying it the rest of the way.
|
||||
|
||||
## [2.19.2] — 2026-08-17
|
||||
|
||||
### Changed
|
||||
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||
week, day, agenda, search and both widgets — and no longer schedules a
|
||||
reminder. Declining an invitation in Google Calendar left the event looking
|
||||
like any other in Calendula, and it still notified you about a meeting you had
|
||||
said no to. It stays visible rather than disappearing: the organiser still
|
||||
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||
|
||||
### Fixed
|
||||
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||
the only way to reach an event from the month was to open its day first and
|
||||
find it again there. Tapping anywhere else in the cell still opens the day
|
||||
([#187]).
|
||||
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||
description to an event and opening it again showed the sheet as it was before
|
||||
the save, because the detail was only re-read when a different occurrence was
|
||||
opened ([#196]).
|
||||
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||
more than the launcher's buffer takes. The third update overran it, and Android
|
||||
responded by dropping the whole widget host, which killed updates for *every*
|
||||
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||
instead of clipping ([#214]).
|
||||
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||
instead of asking Android to update all of them, which does nothing in a
|
||||
process the tap has just woken from cold ([#18]).
|
||||
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||
leaving a sliver of the previous row on screen ([#191]).
|
||||
- Day and week view no longer run their events flush against the right edge
|
||||
([#192]).
|
||||
|
||||
## [2.19.1] — 2026-08-11
|
||||
|
||||
### Added
|
||||
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
|
||||
reschedule*, on by default. Off, no event can be picked up in the month, week
|
||||
or day view — for anyone who kept moving appointments by accident while
|
||||
scrolling ([#173]).
|
||||
- An early Brazilian Portuguese translation. Calendula has started speaking
|
||||
Brazilian Portuguese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
About a third of the app is covered, so the rest still shows in English — you
|
||||
can pick it under Settings → Language or in Android's per-app language
|
||||
settings. Thanks to
|
||||
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Fixed
|
||||
- **The language picker forgot which language was picked.** Android hands the
|
||||
applied language back with its script filled in — Simplified Chinese goes in
|
||||
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
|
||||
Settings → Language showed as selected, and the row above it named the
|
||||
language the long way round. Only languages that imply a script were
|
||||
affected.
|
||||
|
||||
## [2.19.0] — 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
||||
week or day view and drop it on another time or day — the only way to move an
|
||||
appointment used to be opening it and editing its start and end by hand. The
|
||||
block follows your finger, the timeline scrolls when you carry it to an edge,
|
||||
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
||||
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
||||
view it moves the event to the day you dropped it on and leaves the time
|
||||
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
||||
can be taken back in one step. Events you can't edit — read-only calendars, and
|
||||
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
||||
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
||||
every event that can be moved ([#68]).
|
||||
- Dragging a **recurring** event asks what it should apply to, the same *this
|
||||
event / this and following / all events* choice a save does. Where moving the
|
||||
whole series would change which weekday it falls on, the rule is rewritten to
|
||||
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
||||
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
||||
offers to move just the one occurrence, rather than quietly leaving the
|
||||
series behind ([#68]).
|
||||
- **Delete several search results at once.** Long-press a hit in search to enter
|
||||
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
||||
action — finding and removing, say, every leftover event with the same title
|
||||
no longer means opening them one at a time. Recurring hits in the batch ask
|
||||
once for the whole selection instead of once each, results on read-only
|
||||
calendars can't be selected, and the deletion is confirmed before it runs
|
||||
([#80]).
|
||||
|
||||
### Changed
|
||||
- **First launch is now a short wizard** instead of a single permission screen.
|
||||
It counts its steps, each one can be stepped back out of, and beyond granting
|
||||
calendar access it offers two things worth deciding once: turning on automatic
|
||||
backup — chosen inline, folder picker and all, since events that live only on
|
||||
the phone are lost with it — and choosing which view Calendula opens on, with
|
||||
a live preview of the month, week, day and agenda layouts rather than four
|
||||
names in a list. Both are skippable, everything chosen is changeable in
|
||||
Settings afterwards, and the backup step is offered only where nothing
|
||||
writable is being synced anywhere. Existing installs are not re-onboarded —
|
||||
revoking and re-granting the calendar permission later does not restart the
|
||||
wizard ([#163]).
|
||||
- The one-time notice about calendars switched off for this device is now a step
|
||||
in the flow rather than a dialog thrown over the app on first open.
|
||||
|
||||
### Fixed
|
||||
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
||||
attributes the Play Console flags on Android 15 and up, where they no longer
|
||||
do anything. The launch backdrop now picks its status-bar icons from the
|
||||
light/dark resource instead, so they stay legible over it either way
|
||||
([#123]).
|
||||
|
||||
## [2.18.1] — 2026-08-06
|
||||
|
||||
### Fixed
|
||||
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||
— or changing **Hour height** in Settings — left the tap still being measured
|
||||
against the old spacing, so the new event landed at some other hour entirely
|
||||
([#148]).
|
||||
|
||||
## [2.18.0] — 2026-07-31
|
||||
|
||||
### Added
|
||||
@@ -1323,3 +1479,17 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
||||
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||
[#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
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
|
||||
22
README.md
22
README.md
@@ -18,6 +18,8 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
<p>
|
||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
@@ -86,7 +88,7 @@ Pick whichever channel you already use — they all install the same app:
|
||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
||||
| [Google Play](#google-play-coming-soon) | — | Coming soon |
|
||||
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
|
||||
| [Build from source](#build-from-source) | Whenever you build | Full control |
|
||||
|
||||
### F-Droid (recommended)
|
||||
@@ -131,18 +133,14 @@ or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
||||
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
||||
one appears.
|
||||
|
||||
### Google Play (coming soon)
|
||||
### Google Play
|
||||
|
||||
Calendula is on its way to Google Play as an additional channel. It isn't live
|
||||
yet — this section gets a link once it is. Play builds will be signed with
|
||||
Google's key rather than mine, so switching between Play and any other channel
|
||||
will require an uninstall.
|
||||
Calendula is live on Google Play:
|
||||
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
|
||||
|
||||
> **Testers wanted.** Play requires a round of closed testing before the app can
|
||||
> go public, and I'm still looking for testers. If you'd like to help, email
|
||||
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
|
||||
> Google account address you want to use — that address is what I need to add you
|
||||
> to the closed test.
|
||||
Play builds are signed with Google's key rather than mine, so switching between
|
||||
Play and any other channel requires an uninstall (and with it, a fresh start for
|
||||
app settings — your events live in the system calendar and are unaffected).
|
||||
|
||||
### Build from source
|
||||
|
||||
@@ -151,7 +149,7 @@ The build is a plain Gradle build with no proprietary dependencies — see
|
||||
|
||||
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
||||
the same signing key, so you can switch freely between them without
|
||||
reinstalling.</sub>
|
||||
reinstalling. Google Play is the exception — see above.</sub>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
|
||||
@@ -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 = 21800
|
||||
versionName = "2.18.0"
|
||||
versionCode = 21903
|
||||
versionName = "2.19.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,3 +50,9 @@
|
||||
# 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: the two receivers are nearly as alike, and the
|
||||
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
||||
# manifest-derived rules cover them), but #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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -330,9 +330,13 @@
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||
and the system broadcasts the date/time ones at midnight / clock
|
||||
changes so "today" highlighting rolls over. -->
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||
a free extra only, since Android 8+ withholds it from manifest
|
||||
receivers. The four below re-arm that alarm: TIME_SET /
|
||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
android:exported="true">
|
||||
@@ -346,6 +350,8 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -44,6 +45,19 @@ class CalendulaApp : Application() {
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
reconcileWidgetRollover()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||
* dropped by a force-stop is armed again the next time the app is opened.
|
||||
* Off the main thread: a handful of binder calls on every process start.
|
||||
*/
|
||||
private fun reconcileWidgetRollover() {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||
|
||||
/**
|
||||
* Master/one-off events whose title, description or location contains
|
||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
||||
* Events table directly so the search is unbounded in time; exception rows
|
||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
||||
* Master/one-off events that may match [query], across all calendars: every
|
||||
* whitespace-separated token has to appear in the title, description or
|
||||
* location. Reads the Events table directly so the search is unbounded in
|
||||
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||
* superset — [EventSearch] makes the final call and orders the hits.
|
||||
* [query] is assumed non-blank.
|
||||
*/
|
||||
fun searchEvents(query: String): List<EventInstance>
|
||||
fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
@@ -228,10 +232,15 @@ interface CalendarDataSource {
|
||||
): Long
|
||||
|
||||
/**
|
||||
* Change a single occurrence of a recurring event by inserting a
|
||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||
* `Events._ID` of the row now holding them.
|
||||
*
|
||||
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
||||
* standalone event carrying the edits — an exception cannot link to its
|
||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
*/
|
||||
fun updateOccurrence(
|
||||
eventId: Long,
|
||||
@@ -585,20 +594,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<EventInstance> {
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
ensureObserversRegistered()
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
||||
// itself instead of acting as a wildcard.
|
||||
val escaped = trimmed
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
val like = "%$escaped%"
|
||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||
// ones the cap above dropped.
|
||||
val patterns = tokens.map { likePatterns(it) }
|
||||
val match = patterns.joinToString(" AND ") { variants ->
|
||||
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||
}
|
||||
}
|
||||
val args = patterns
|
||||
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||
.toTypedArray()
|
||||
val selection = "($match) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||
@@ -606,19 +618,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
SearchProjection.COLUMNS,
|
||||
selection,
|
||||
arrayOf(like, like, like),
|
||||
args,
|
||||
CalendarContract.Events.DTSTART + " DESC",
|
||||
)?.use { c ->
|
||||
val reader = CursorColumnReader(c)
|
||||
val out = ArrayList<EventInstance>(c.count)
|
||||
val out = ArrayList<SearchCandidate>(c.count)
|
||||
while (c.moveToNext()) {
|
||||
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||
val base = reader.toSearchResult() ?: continue
|
||||
// A recurring master's DTSTART is the series start; show its
|
||||
// nearest occurrence instead so the date is the one the user
|
||||
// actually cares about (and sorting reflects it).
|
||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
||||
out += if (recurring) {
|
||||
// A recurring master's DTSTART is the series start; date the row
|
||||
// by its nearest occurrence instead.
|
||||
val event = if (base.isRecurring) {
|
||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||
base.copy(
|
||||
start = begin.toKotlinInstantFromEpochMillis(),
|
||||
@@ -628,11 +638,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
base
|
||||
}
|
||||
// The raw title, so matching can't catch on the placeholder an
|
||||
// untitled event is drawn with.
|
||||
out += SearchCandidate(
|
||||
event = event,
|
||||
description = description,
|
||||
title = reader.getString(SearchProjection.IDX_TITLE),
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||
*/
|
||||
private fun likePatterns(token: String): List<String> {
|
||||
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
||||
return listOf(likePattern(token))
|
||||
}
|
||||
val lower = token.lowercase()
|
||||
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
||||
.distinct()
|
||||
.map(::likePattern)
|
||||
}
|
||||
|
||||
private fun likePattern(token: String): String {
|
||||
val sb = StringBuilder("%")
|
||||
for (c in token) {
|
||||
// A literal wildcard from the query matches itself.
|
||||
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||
sb.append(c)
|
||||
}
|
||||
return sb.append('%').toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||
@@ -1156,6 +1199,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
val row = querySeriesRow(eventId)
|
||||
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
||||
// something on a row that recurs, so a non-recurring one keeps the
|
||||
// exception path rather than getting a recurrence set written onto it.
|
||||
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
||||
}
|
||||
// The provider clones the series row and applies these values on top.
|
||||
val values = buildOccurrenceExceptionValues(
|
||||
form = form,
|
||||
@@ -1174,6 +1224,89 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
/**
|
||||
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
||||
* occurrence from the parent with EXDATE and insert the edited values as a
|
||||
* standalone event on the same calendar.
|
||||
*
|
||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
||||
*
|
||||
* The detached row keeps no stored link back to its series, so: it no longer
|
||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
||||
* #47 delete resurrects the same way); and it is built from the form, not
|
||||
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
||||
* preserves are dropped — the same limitation as [moveEvent].
|
||||
*
|
||||
* Insert first, so a failure leaves the series untouched
|
||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
||||
* replacement, i.e. an edit that quietly deletes.
|
||||
*/
|
||||
private fun detachOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
row: SeriesRow,
|
||||
form: EventForm,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): Long {
|
||||
// Already detached (or deleted) from a stale screen still pointing at the
|
||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
||||
// changed row, quietly leaving a *second* standalone copy.
|
||||
if (exdateContains(row.exdate, beginMillis, isAllDay = row.allDay != 0)) {
|
||||
throw NoSuchEventException(eventId)
|
||||
}
|
||||
// Reminders, guests and colour come along like any new event, and so does
|
||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
||||
// parent's would collide with it in .ics restore dedup.
|
||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||
val values = buildOccurrenceExdateValues(
|
||||
existingExdate = row.exdate,
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
||||
// provider's expansion fails to match.
|
||||
val updatedRows = try {
|
||||
resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(), null, null,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
rollBackDetached(detachedId)
|
||||
throw t
|
||||
}
|
||||
if (updatedRows == 0) {
|
||||
rollBackDetached(detachedId)
|
||||
throw WriteFailedException(
|
||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||
)
|
||||
}
|
||||
return detachedId
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
||||
* update failed. Best effort: the caller is already throwing, and the worst
|
||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
||||
*/
|
||||
private fun rollBackDetached(detachedId: Long) {
|
||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
||||
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
@@ -1593,5 +1726,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
* next fires beyond it falls back to its series-start date.
|
||||
*/
|
||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||
* pasted paragraph; the ranker still requires every token, so a capped
|
||||
* search returns fewer rows to check, never more hits.
|
||||
*/
|
||||
const val MAX_SEARCH_TOKENS = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
* Events whose title, description or location contains [query], with hidden
|
||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
||||
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||
* [query] is blank. Searches the whole history/future and leaves matching
|
||||
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||
*/
|
||||
suspend fun searchEvents(query: String): List<EventInstance>
|
||||
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
|
||||
@@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,21 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* The form as a **detached occurrence**: the same edited values, with the
|
||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
||||
*
|
||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
||||
* exception row can't attach to its parent at all (Codeberg #234).
|
||||
*
|
||||
* The exception path gets the rule dropped for free — the provider clears the
|
||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
||||
*/
|
||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||
|
||||
/**
|
||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||
@@ -435,6 +450,23 @@ internal fun buildOccurrenceExdateValues(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
||||
*
|
||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
||||
* merge folds the repeat away silently and the update still reports one row
|
||||
* changed, so a second save would leave a second standalone copy.
|
||||
*/
|
||||
internal fun exdateContains(
|
||||
existingExdate: String?,
|
||||
occurrenceMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
): Boolean {
|
||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
|
||||
}
|
||||
|
||||
/**
|
||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
@@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ internal object InstanceProjection {
|
||||
CalendarContract.Instances.EVENT_COLOR,
|
||||
CalendarContract.Instances.CALENDAR_COLOR,
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_INSTANCE_ID = 0
|
||||
@@ -64,6 +65,7 @@ internal object InstanceProjection {
|
||||
const val IDX_EVENT_COLOR = 7
|
||||
const val IDX_CALENDAR_COLOR = 8
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 10
|
||||
}
|
||||
|
||||
internal object EventDetailProjection {
|
||||
@@ -89,6 +91,9 @@ internal object EventDetailProjection {
|
||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||
// needs it to render a series opened without a named occurrence.
|
||||
CalendarContract.Events.DURATION,
|
||||
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
||||
// can't tell an exception from a master (#68).
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -110,6 +115,7 @@ internal object EventDetailProjection {
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
const val IDX_ORIGINAL_ID = 19
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +182,9 @@ internal object SearchProjection {
|
||||
// display its nearest occurrence, not the series-start DTSTART.
|
||||
CalendarContract.Events.RRULE,
|
||||
CalendarContract.Events.RDATE,
|
||||
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -190,6 +199,8 @@ internal object SearchProjection {
|
||||
const val IDX_LOCATION = 9
|
||||
const val IDX_RRULE = 10
|
||||
const val IDX_RDATE = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
@@ -40,5 +41,9 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -301,6 +301,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether events can be dragged to another slot in the calendar views (#68,
|
||||
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
||||
* rescheduling then goes through the edit form.
|
||||
*/
|
||||
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setDragToReschedule(enabled: Boolean) {
|
||||
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
@@ -550,8 +563,87 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setReminderOnboardingDone() {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
||||
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this install is a fresh one still owing the wizard's optional
|
||||
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||
* has not finished the reminder step, and cleared again by
|
||||
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||
* the permission isn't re-onboarded.
|
||||
*/
|
||||
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun armOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||
* install has anything to announce at all.
|
||||
*/
|
||||
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||
}
|
||||
|
||||
/** Whether the wizard's closing screen has been seen. */
|
||||
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the wizard for good: the closing screen has been seen and the
|
||||
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||
*/
|
||||
suspend fun finishOnboardingWizard() {
|
||||
store.edit { prefs ->
|
||||
prefs[ONBOARDING_DONE_KEY] = true
|
||||
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -866,6 +958,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
@@ -882,6 +975,14 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
booleanPreferencesKey("onboarding_month_style_done")
|
||||
internal val ONBOARDING_VISIBILITY_KEY =
|
||||
booleanPreferencesKey("onboarding_visibility_done")
|
||||
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||
|
||||
@@ -44,9 +44,16 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
// `visible` is the flag the app's one visibility model writes (#75).
|
||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||
// An invitation the user declined is answered — it stays on the calendar
|
||||
// struck through, but it plans nothing (#180). NULL again means "no
|
||||
// answer recorded", which is not a "no".
|
||||
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
|
||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
|
||||
"${CalendarContract.Instances.STATUS} != " +
|
||||
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
|
||||
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
|
||||
return context.contentResolver.query(
|
||||
uri, OCCURRENCE_PROJECTION, selection, null, null,
|
||||
)?.use { c ->
|
||||
|
||||
@@ -44,6 +44,14 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
||||
* notes) yet must never move, since the next contacts sync would put it back.
|
||||
*/
|
||||
val CalendarSource.allowsEventMove: Boolean
|
||||
get() = canModifyContents && !isManaged
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** An event with the free text the search may have matched it on. */
|
||||
data class SearchCandidate(
|
||||
val event: EventInstance,
|
||||
val description: String? = null,
|
||||
/** Null when the event has none: [EventInstance.title] is then a display
|
||||
* placeholder, which the query must not catch on. */
|
||||
val title: String? = event.title,
|
||||
)
|
||||
|
||||
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||
data class MatchSpan(val start: Int, val end: Int)
|
||||
|
||||
/** The part of a description a token matched, elided to what fits a row. */
|
||||
data class DescriptionSnippet(
|
||||
val text: String,
|
||||
val spans: List<MatchSpan>,
|
||||
)
|
||||
|
||||
/** One result: the event, plus where the query matched it. */
|
||||
data class SearchHit(
|
||||
val event: EventInstance,
|
||||
val titleSpans: List<MatchSpan> = emptyList(),
|
||||
val locationSpans: List<MatchSpan> = emptyList(),
|
||||
val descriptionSnippet: DescriptionSnippet? = null,
|
||||
/** Already over, so the row can say so. Finished-today counts. */
|
||||
val isPast: Boolean = false,
|
||||
)
|
||||
|
||||
/** One month's worth of hits, as the results list draws them under a header. */
|
||||
data class SearchMonth(
|
||||
val year: Int,
|
||||
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||
val monthNumber: Int,
|
||||
val hits: List<SearchHit>,
|
||||
)
|
||||
|
||||
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||
data class SearchResults(
|
||||
val months: List<SearchMonth>,
|
||||
val inDescriptions: List<SearchHit>,
|
||||
) {
|
||||
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||
|
||||
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a typed query into the results list.
|
||||
*
|
||||
* An event has to carry *every* whitespace-separated token, each in any of
|
||||
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||
*
|
||||
* Results are a calendar, not a ranking: months from the current one, then
|
||||
* forwards, then backwards, and inside a month what is still to come before
|
||||
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||
* only reached through its description is held back in
|
||||
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||
*/
|
||||
object EventSearch {
|
||||
|
||||
/** Characters of description kept around the match in a row's snippet. */
|
||||
private const val SNIPPET_LENGTH = 96
|
||||
|
||||
/** Characters of lead-in shown before the match, when there is room. */
|
||||
private const val SNIPPET_LEAD = 24
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||
fun tokenize(query: String): List<String> =
|
||||
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||
* [zone] places each event on the calendar through the span rule, so an
|
||||
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||
*/
|
||||
fun search(
|
||||
candidates: List<SearchCandidate>,
|
||||
query: String,
|
||||
now: Instant,
|
||||
todayStart: Instant,
|
||||
zone: TimeZone,
|
||||
): SearchResults {
|
||||
val tokens = tokenize(query)
|
||||
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
)
|
||||
}
|
||||
|
||||
/** Months from the current one, then forwards, then backwards. */
|
||||
private fun groupByMonth(
|
||||
dated: List<Matched>,
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
now: Instant,
|
||||
zone: TimeZone,
|
||||
): Matched? {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||
return Matched(
|
||||
hit = SearchHit(
|
||||
event = candidate.event,
|
||||
titleSpans = titleSpans,
|
||||
locationSpans = locationSpans,
|
||||
descriptionSnippet = description
|
||||
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||
?.let { snippet(it, descriptionSpans) },
|
||||
isPast = candidate.event.dayEnd(zone) < now,
|
||||
),
|
||||
// Nothing in the name or the place caught the query: it is only here
|
||||
// for something in its notes.
|
||||
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||
val raw = mutableListOf<MatchSpan>()
|
||||
for (token in tokens) {
|
||||
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||
while (index >= 0) {
|
||||
raw += MatchSpan(index, index + token.length)
|
||||
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val sorted = raw.sortedBy { it.start }
|
||||
val merged = mutableListOf(sorted.first())
|
||||
for (span in sorted.drop(1)) {
|
||||
val last = merged.last()
|
||||
if (span.start <= last.end) {
|
||||
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||
} else {
|
||||
merged += span
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* A window of [description] around its first match, elided at both ends,
|
||||
* with the spans rebased onto it and any falling outside dropped.
|
||||
*/
|
||||
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||
if (description.length <= SNIPPET_LENGTH) {
|
||||
return DescriptionSnippet(description, spans)
|
||||
}
|
||||
val first = spans.first().start
|
||||
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||
val prefix = if (start > 0) "…" else ""
|
||||
val suffix = if (end < description.length) "…" else ""
|
||||
val text = prefix + description.substring(start, end) + suffix
|
||||
val shift = prefix.length - start
|
||||
val rebased = spans
|
||||
.filter { it.start >= start && it.end <= end }
|
||||
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||
return DescriptionSnippet(text, rebased)
|
||||
}
|
||||
|
||||
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||
val event: EventInstance get() = hit.event
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||
*/
|
||||
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||
|
||||
/** The moment the event's last day is over in [zone], exclusive. */
|
||||
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone this form's wall-clock times mean, matching what the data layer
|
||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||
*
|
||||
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
||||
* untouched.
|
||||
*/
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form moved [days] calendar days, keeping its time of day and its span.
|
||||
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||
* duration, for the same reason [shiftedTo] does.
|
||||
*/
|
||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
if (days == 0) return this
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
@@ -62,6 +62,18 @@ data class EventInstance(
|
||||
val isAllDay: Boolean,
|
||||
val color: Int,
|
||||
val location: String?,
|
||||
/**
|
||||
* Only search results, which read the series master, fill this in — the
|
||||
* Instances query already yields one row per occurrence.
|
||||
*/
|
||||
val isRecurring: Boolean = false,
|
||||
/**
|
||||
* This device user answered "no" to the invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||
* still an appointment someone expects an answer about — but every surface
|
||||
* strikes it through, and it plans no reminders (#180).
|
||||
*/
|
||||
val isDeclined: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -124,6 +136,12 @@ data class EventDetail(
|
||||
val eventColor: Int? = null,
|
||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||
val eventColorKey: String? = null,
|
||||
/**
|
||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||
* set) rather than a master, so a reschedule takes the plain whole-row path
|
||||
* whatever [rrule] a sync adapter left on it.
|
||||
*/
|
||||
val isException: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||
*
|
||||
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
||||
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||
* would not move at all — `BYDAY` is re-derived from [newStart].
|
||||
*
|
||||
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
||||
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
||||
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
||||
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
||||
*/
|
||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||
if (oldStart == newStart) return rrule
|
||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return null
|
||||
var weekly = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
"FREQ" -> {
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
}
|
||||
"BYDAY" -> {
|
||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||
if (!value.equals(old, ignoreCase = true)) return null
|
||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
@@ -294,6 +299,33 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
||||
EventMoveScope(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = reschedule::move,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
@@ -311,6 +343,7 @@ fun CalendarHost(
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = fadeThrough()
|
||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
@@ -367,6 +400,11 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// right after the calendar views so later overlays cover it.
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
|
||||
@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
||||
* Off until the stored value arrives, even though the preference itself defaults
|
||||
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
||||
* start takes to read DataStore, but re-arms the one thing a disabled user
|
||||
* switched off to be rid of.
|
||||
*/
|
||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -22,13 +25,22 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
private sealed interface RootTarget {
|
||||
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||
data object Loading : RootTarget
|
||||
data object App : RootTarget
|
||||
data class Step(val step: OnboardingStep) : RootTarget
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -76,58 +88,87 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
// One-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), armed by the reconciler.
|
||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
||||
// Runs on entry however the permission was granted, including via
|
||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
||||
LaunchedEffect(Unit) {
|
||||
visibilityNotice.reconcile()
|
||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
||||
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||
// The grant is reported however it was taken, including via Android's
|
||||
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||
// hangs off that, not off the in-app callback.
|
||||
LaunchedEffect(hasPermission) {
|
||||
onboarding.setHasPermission(hasPermission)
|
||||
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||
}
|
||||
|
||||
// Arms the one-time explainer for the switch to the device's own calendar
|
||||
// visibility (#75), which the wizard then carries as a step of its own.
|
||||
LaunchedEffect(hasPermission) {
|
||||
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||
}
|
||||
|
||||
val current = plan
|
||||
val target: RootTarget = when {
|
||||
current == null -> RootTarget.Loading
|
||||
current.current == null -> RootTarget.App
|
||||
else -> RootTarget.Step(current.current)
|
||||
}
|
||||
|
||||
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||
// after every step, so finishing the wizard reads as one more move forward.
|
||||
val ordinal = when (target) {
|
||||
RootTarget.Loading -> Int.MIN_VALUE
|
||||
RootTarget.App -> Int.MAX_VALUE
|
||||
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||
}
|
||||
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||
SideEffect { lastOrdinal = ordinal }
|
||||
|
||||
val slideSpec = rememberOnboardingSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
transitionSpec = {
|
||||
// Coming off the blank first frame is not a step change.
|
||||
if (initialState == RootTarget.Loading) {
|
||||
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
} else {
|
||||
onboardingSlideTransition(
|
||||
slideDir = slideDir,
|
||||
spec = slideSpec,
|
||||
fadeSpec = fadeSpec,
|
||||
reduceMotion = reduceMotion,
|
||||
)
|
||||
}
|
||||
if (onboardingDone == true && noticePending) {
|
||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
||||
}
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
},
|
||||
label = "onboardingGate",
|
||||
) { shown ->
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
is RootTarget.Step -> current?.let { plan ->
|
||||
OnboardingSteps(
|
||||
step = shown.step,
|
||||
plan = plan,
|
||||
viewModel = onboarding,
|
||||
onPermissionGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -129,8 +131,8 @@ internal fun AgendaEventRow(
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event, day, zone),
|
||||
title = declinedTitle(title, event.isDeclined),
|
||||
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = {
|
||||
|
||||
@@ -276,7 +276,7 @@ private fun AgendaRangeBanner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
internal fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
showToday: Boolean,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda preview's stand-in window runs. */
|
||||
private const val PREVIEW_WINDOW_DAYS = 14
|
||||
|
||||
/**
|
||||
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||
* the day headers are real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
AgendaContent(
|
||||
state = state,
|
||||
// Stand-in events are all upcoming, so the past-event preference
|
||||
// cannot change what the preview shows — keep it at the default.
|
||||
pastDisplay = PastEventDisplay.SHOW,
|
||||
showToday = true,
|
||||
onRetry = {},
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
rangeIsOverride = false,
|
||||
rangeEnd = windowEnd,
|
||||
// The range bar belongs to the screen, not the list — the preview shows
|
||||
// the list only.
|
||||
showRangeBar = false,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The one-time notice that Calendula now follows the device's per-calendar
|
||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val reconciler: CalendarVisibilityReconciler,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
* Reconcile whenever the app comes up with the calendar permission held,
|
||||
* rather than off one grant route: a permission granted on Android's
|
||||
* app-settings screen never reaches the permission screen's callback.
|
||||
*/
|
||||
fun reconcile() {
|
||||
viewModelScope.launch { reconciler.run() }
|
||||
}
|
||||
|
||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
||||
.map { it == true }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
fun dismiss() {
|
||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
||||
@Composable
|
||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/**
|
||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
label = "block-time",
|
||||
modifier = modifier,
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a timed block sits in its column, after tweening. */
|
||||
@Immutable
|
||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||
* the caller keying each block by identity; a block composed for the first time
|
||||
* starts at its target, so nothing flies in on the first frame.
|
||||
*
|
||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||
LocalTimelineZoom.current.isPinching
|
||||
) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** How far the finger may wander during the hold — well under touch slop. */
|
||||
private val PICKUP_TOLERANCE = 6.dp
|
||||
|
||||
/**
|
||||
* Pick an event block up with a long press and drag it, without
|
||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||
* ancestor consumes or the finger leaves the block, which a
|
||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberEventDragSource(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||
true
|
||||
},
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
)
|
||||
|
||||
/**
|
||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||
* layer. [onPickUp] receives the press position local to this node and answers
|
||||
* whether anything is there; false abandons the gesture.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragSurface(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val currentPickUp by rememberUpdatedState(onPickUp)
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentDrop by rememberUpdatedState(onDrop)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
|
||||
return Modifier
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(key) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val origin = down.position
|
||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||
while (true) {
|
||||
val main = awaitPointerEvent()
|
||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
if (main.changes.count { it.pressed } > 1) break
|
||||
if ((change.position - origin).getDistance() > tolerance) break
|
||||
// An ancestor's claim only becomes visible once the whole
|
||||
// main pass has run, so look again on the final pass.
|
||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (final.changes.any { it.isConsumed }) break
|
||||
}
|
||||
} == null
|
||||
if (!heldStill) return@awaitEachGesture
|
||||
|
||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||
val took = currentPickUp(
|
||||
down.position,
|
||||
layout.localToRoot(down.position),
|
||||
layout.positionInRoot(),
|
||||
layout.size,
|
||||
)
|
||||
if (!took) return@awaitEachGesture
|
||||
var dropped = false
|
||||
try {
|
||||
// Driven on the initial pass, which runs parent → child: an
|
||||
// ancestor that outranks us (the pinch) has already consumed
|
||||
// by the time we look, and descendants — the month grid's
|
||||
// full-bleed tap layer — see our consumption.
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
dropped = true
|
||||
break
|
||||
}
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
coordinates[0]?.takeIf { it.isAttached }
|
||||
?.let { currentMove(it.localToRoot(change.position)) }
|
||||
}
|
||||
} finally {
|
||||
// Also reached when the pointer node is disposed mid-drag —
|
||||
// a cancel, not a drop, and must never write.
|
||||
if (dropped) currentDrop() else currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||
@Composable
|
||||
fun DragSnapHaptics(slot: Any?) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val previous = remember { arrayOfNulls<Any>(1) }
|
||||
LaunchedEffect(slot) {
|
||||
val had = previous[0]
|
||||
previous[0] = slot
|
||||
when {
|
||||
slot == null -> Unit
|
||||
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Locale
|
||||
|
||||
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
|
||||
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||
|
||||
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||
|
||||
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||
private val FAB_BAND = 88.dp
|
||||
|
||||
/**
|
||||
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
|
||||
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
|
||||
val locale = currentLocale()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
|
||||
prompt?.let { pending ->
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_move_recurring_title),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
reason = stringResource(R.string.event_move_occurrence_only)
|
||||
.takeIf { pending.occurrenceOnly },
|
||||
)
|
||||
}
|
||||
|
||||
val moved = outcome as? MoveOutcome.Moved
|
||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||
val message = when (outcome) {
|
||||
null -> null
|
||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||
}
|
||||
|
||||
// Held past the outcome being consumed so the chip has something to draw
|
||||
// while it springs back out. Updated in composition rather than from an
|
||||
// effect, which would land a frame late and open the chip on stale text.
|
||||
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||
shown.value = ChipContent(message, moved?.undo)
|
||||
}
|
||||
val content = shown.value
|
||||
// Restarted by the undo tick and held while a write is in flight: an undo
|
||||
// tapped in the last moments of the window leaves the outcome at Moved on
|
||||
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
||||
// just before the same chip has to say "undone".
|
||||
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
||||
if (outcome == null || writeInFlight) return@LaunchedEffect
|
||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||
viewModel.consumeOutcome()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val chipMaxWidth = maxWidth - FAB_BAND
|
||||
// A FAB-height band at the bottom start with the FAB's own margin, so
|
||||
// the chip lines up beside the bottom-end FAB.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// The action goes away while a write runs: undo() refuses a second
|
||||
// write anyway, so offering it would be a button that does nothing.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
onAction = undo?.let { { viewModel.undo(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
||||
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
||||
*/
|
||||
private fun formatMovedTo(
|
||||
startMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
use24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
||||
val skeleton = when {
|
||||
isAllDay -> "EEEdMMM"
|
||||
use24Hour -> "EEEdMMMHm"
|
||||
else -> "EEEdMMMhm"
|
||||
}
|
||||
return localizedDateFormatter(locale, skeleton)
|
||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||
* whichever event block is being composed. Null means moving is off entirely and
|
||||
* blocks register no drag gesture at all.
|
||||
*/
|
||||
@Immutable
|
||||
class EventMoveScope(
|
||||
/**
|
||||
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
||||
* the repository writes whatever it is handed — so this gate is load-bearing.
|
||||
*/
|
||||
val movableCalendarIds: Set<Long>,
|
||||
/**
|
||||
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
|
||||
* TalkBack action below stay, since invoking a menu item can't cause the
|
||||
* accidental drags the setting exists to stop.
|
||||
*/
|
||||
val dragEnabled: Boolean,
|
||||
/** False when the drop was refused outright, so nothing will be written. */
|
||||
val move: (MoveRequest) -> Boolean,
|
||||
/**
|
||||
* True while a dropped event is being written, including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move — every visible block reads it as a composition local.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||
val edit: (EventInstance) -> Unit,
|
||||
) {
|
||||
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
||||
}
|
||||
|
||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||
* this composable came on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
/**
|
||||
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||
* copy is handed back, then animated up rather than switched, so the block
|
||||
* appears to travel to its new slot instead of vanishing and reappearing.
|
||||
*/
|
||||
@Composable
|
||||
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||
label = "ghost-alpha",
|
||||
).value
|
||||
|
||||
/**
|
||||
* Whether [event] can be picked up and dragged — its calendar allows moving and
|
||||
* the gesture is switched on. Not the same question as [eventMoveAction], which
|
||||
* survives the setting.
|
||||
*/
|
||||
@Composable
|
||||
fun eventDragAllowed(event: EventInstance): Boolean {
|
||||
val move = LocalEventMove.current ?: return false
|
||||
return move.dragEnabled && move.allows(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||
* pointer-only. Null when this event can't be moved.
|
||||
*/
|
||||
@Composable
|
||||
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
||||
val move = LocalEventMove.current ?: return null
|
||||
if (!move.allows(event)) return null
|
||||
val label = stringResource(R.string.event_move_action)
|
||||
return remember(event.instanceId, label, move) {
|
||||
CustomAccessibilityAction(label) {
|
||||
move.edit(event)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||
val GUTTER_WIDTH = 48.dp
|
||||
|
||||
/**
|
||||
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
||||
* matching the icon button's centre.
|
||||
*/
|
||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
|
||||
/**
|
||||
* End inset for everything that lines up with the day columns — the week's day
|
||||
* header, the all-day strip and the timeline itself. Without it the last column
|
||||
* runs flush into the screen edge while the gutter gives the other side room
|
||||
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
|
||||
*/
|
||||
val TIMELINE_CONTENT_END_INSET = 8.dp
|
||||
|
||||
private val BADGE_HEIGHT = 20.dp
|
||||
|
||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||
private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||
|
||||
/**
|
||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||
* the hour labels and floats a badge with the drag's current start time at the
|
||||
* row the block would land on.
|
||||
*/
|
||||
@Composable
|
||||
fun HourGutter(
|
||||
scrollState: ScrollState,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||
val dragStartMin by remember(dragController) {
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
label = "hourLabelAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Column {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = hourAlpha),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStartMin?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = top)
|
||||
.height(BADGE_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 4.dp)
|
||||
// The dragged block already announces this time.
|
||||
.clearAndSetSemantics { },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = formatGutterTime(startMin, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
|
||||
/**
|
||||
* How a declined invitation's title is struck through (#180) — the mark every
|
||||
* surface uses to say "you answered no", chosen over hiding the event because it
|
||||
* is still something the organiser expects you at. Null for everything else, so
|
||||
* it drops straight into a `Text`'s `textDecoration`.
|
||||
*/
|
||||
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
|
||||
if (isDeclined) TextDecoration.LineThrough else null
|
||||
|
||||
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
|
||||
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
|
||||
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
|
||||
} else {
|
||||
AnnotatedString(title)
|
||||
}
|
||||
|
||||
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
|
||||
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
|
||||
if (!isDeclined) {
|
||||
title
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(title)
|
||||
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Stand-in events for the view previews. A preview has no business querying the
|
||||
* provider for a thumbnail, so every chooser renders the same invented week.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a theme token here would misrepresent what the views actually draw.
|
||||
*/
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
||||
private class SampleEvents(private val zone: TimeZone) {
|
||||
private var id = 0L
|
||||
|
||||
fun timed(
|
||||
day: LocalDate,
|
||||
hour: Int,
|
||||
minute: Int = 0,
|
||||
lengthMinutes: Int,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val start = day.atTime(hour, minute).toInstant(zone)
|
||||
return build(
|
||||
start = start,
|
||||
end = start + lengthMinutes.minutes,
|
||||
isAllDay = false,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
}
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
colorIndex = colorIndex,
|
||||
)
|
||||
|
||||
private fun build(
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean,
|
||||
colorIndex: Int,
|
||||
): EventInstance {
|
||||
val next = ++id
|
||||
return EventInstance(
|
||||
instanceId = next,
|
||||
eventId = next,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = start,
|
||||
end = end,
|
||||
isAllDay = isAllDay,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
||||
* multi-day bar bridging cells and a busy today for the split pane.
|
||||
*/
|
||||
internal fun sampleMonthEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
||||
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
||||
* is centred on — gets the busy set, including one overlapping pair so the
|
||||
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
||||
* all-day bar is only added when there is more than one column to span.
|
||||
*/
|
||||
internal fun sampleTimelineEvents(
|
||||
days: List<LocalDate>,
|
||||
anchor: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
if (days.size > 1) {
|
||||
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
||||
} else {
|
||||
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
||||
}
|
||||
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
||||
// under 09:00–10:00 so the overlap resolves into two lanes.
|
||||
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
||||
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
||||
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
||||
// Skip every third day so the week doesn't read as a solid block.
|
||||
if (index % 3 == 2) return@forEachIndexed
|
||||
add(
|
||||
sample.timed(
|
||||
day = day,
|
||||
hour = 8 + (index * 3) % 9,
|
||||
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
||||
colorIndex = index,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
||||
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
||||
* a full day and the gaps between days.
|
||||
*/
|
||||
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||
val sample = SampleEvents(zone)
|
||||
return buildList {
|
||||
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
||||
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
||||
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
||||
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
||||
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
||||
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
||||
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
|
||||
/**
|
||||
* How far a write to a recurring event should reach: this occurrence, it and
|
||||
* everything after (a series split), or the whole series. Shared by the edit
|
||||
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
||||
* the full-screen picker rule.
|
||||
*
|
||||
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||
* rule); [allowSeries] drops the two wider options, for a rule whose days can't
|
||||
* be recalculated from one moved occurrence — [reason] then says why.
|
||||
*/
|
||||
@Composable
|
||||
fun RecurringScopeDialog(
|
||||
title: String,
|
||||
onSelect: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
allowOccurrence: Boolean = true,
|
||||
allowSeries: Boolean = true,
|
||||
reason: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (reason != null) {
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (allowOccurrence) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||
)
|
||||
}
|
||||
if (allowSeries) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
/** Where a dragged event should land. */
|
||||
sealed interface MoveTarget {
|
||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||
data class Start(val instant: Instant) : MoveTarget
|
||||
|
||||
/**
|
||||
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
||||
* A delta rather than a target date, so the screen and this view model can't
|
||||
* disagree by a day over which zone the event's first day is resolved in.
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
||||
*/
|
||||
data class MoveRequest(
|
||||
val eventId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val target: MoveTarget,
|
||||
)
|
||||
|
||||
/**
|
||||
* A recurring drop waiting for the user to pick how far it reaches.
|
||||
* [occurrenceOnly] means a wider write would leave the rule and the series
|
||||
* anchor disagreeing — either the rule names days one moved occurrence can't
|
||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||
* across the same midnights the occurrence crossed.
|
||||
*/
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
val eventId: Long,
|
||||
/** The form as it now stands (the moved state). */
|
||||
val moved: EventForm,
|
||||
/** The form as it stood before the move. */
|
||||
val restored: EventForm,
|
||||
)
|
||||
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
||||
*/
|
||||
data class Moved(
|
||||
val startMillis: Long,
|
||||
val isAllDay: Boolean,
|
||||
val undo: MoveUndo?,
|
||||
) : MoveOutcome
|
||||
|
||||
data object Undone : MoveOutcome
|
||||
|
||||
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
||||
data object WriteDenied : MoveOutcome
|
||||
|
||||
/** The event vanished (sync, another device) between the read and the write. */
|
||||
data object Gone : MoveOutcome
|
||||
|
||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||
data object BlockedSeriesEnd : MoveOutcome
|
||||
|
||||
data object Failed : MoveOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
||||
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
||||
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
||||
* instance serves every calendar view and survives view switches. The full
|
||||
* prefilled form is carried through the write — a stripped one would wipe the
|
||||
* reminders and attendees the occurrence-exception path reconciles.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RescheduleViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
||||
|
||||
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
||||
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
||||
|
||||
private var pending: PreparedMove? = null
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Ticks the moment an undo write begins, before the provider has anything to
|
||||
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||
* let it reappear on the old day.
|
||||
*/
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event inside that window would compute their shift
|
||||
* from the same pre-move occurrence, and the shifts would compound.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
field = value
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||
* this, so a block outside this set registers no drag gesture at all.
|
||||
*/
|
||||
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||
.catch { emit(emptySet<Long>()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Everything a drop needs, resolved before anything is written. */
|
||||
private data class PreparedMove(
|
||||
val request: MoveRequest,
|
||||
val original: EventForm,
|
||||
val updated: EventForm,
|
||||
/** True for a series master with a rule — an exception row is not one. */
|
||||
val isRecurring: Boolean,
|
||||
/** False when the rule names days this move can't re-derive. */
|
||||
val canRealign: Boolean,
|
||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||
val newAnchorDate: LocalDate,
|
||||
)
|
||||
|
||||
/**
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
* be written and the caller must let its held copy go now — waiting on
|
||||
* [inFlight] would strand it until the settle timeout instead.
|
||||
*/
|
||||
fun move(request: MoveRequest): Boolean {
|
||||
if (busy || _scopePrompt.value != null) return false
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
if (!prepared.isRecurring) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
fun moveWithScope(scope: RecurringWriteScope) {
|
||||
val prepared = pending ?: return
|
||||
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome stands until the
|
||||
* inverse write reports back, so the one chip changes what it says rather
|
||||
* than closing and reopening. False when another write is already running —
|
||||
* the chip hides its action for that window, so this is the backstop.
|
||||
*/
|
||||
fun undo(undo: MoveUndo): Boolean {
|
||||
if (busy) return false
|
||||
busy = true
|
||||
_undoStarted.value += 1
|
||||
viewModelScope.launch {
|
||||
_outcome.value = try {
|
||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||
MoveOutcome.Undone
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
MoveOutcome.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
MoveOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clear the outcome once the screen has shown it. */
|
||||
fun consumeOutcome() {
|
||||
_outcome.value = null
|
||||
}
|
||||
|
||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||
val detail = try {
|
||||
repository.eventDetail(request.eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
_outcome.value = MoveOutcome.Gone
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
}
|
||||
// A zero-distance drop is not a write.
|
||||
if (shifted == original) return null
|
||||
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
// An exception row stands alone whatever rule a sync adapter left on it.
|
||||
val isRecurring = original.rrule != null && !detail.isException
|
||||
val movedDay = shifted.start.date != original.start.date
|
||||
// The series anchor moves by the same *wall-clock* shift as the dragged
|
||||
// occurrence, so only a whole-day shift carries it across the same number
|
||||
// of midnights whatever time of day it sits at. A same-date drag has its
|
||||
// own, narrower check — see anchorKeepsItsDay.
|
||||
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
||||
val realigned = if (isRecurring && movedDay) {
|
||||
if (wholeDayShift) {
|
||||
realignRecurrence(
|
||||
requireNotNull(original.rrule),
|
||||
original.start.date,
|
||||
shifted.start.date,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
original.rrule
|
||||
}
|
||||
return PreparedMove(
|
||||
request = request,
|
||||
original = original,
|
||||
// A rule we can't re-derive stays verbatim: the only scope offered
|
||||
// then is the single occurrence, whose exception row carries no rule.
|
||||
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
||||
isRecurring = isRecurring,
|
||||
canRealign = when {
|
||||
!isRecurring -> true
|
||||
movedDay -> realigned != null
|
||||
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
||||
},
|
||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||
// the same shift. Only read under canRealign, which is what
|
||||
// guarantees the anchor really does travel this many days.
|
||||
newAnchorDate = anchorDate(detail, original, zone)
|
||||
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
return
|
||||
}
|
||||
_outcome.value = try {
|
||||
when {
|
||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.updateOccurrence(
|
||||
request.eventId,
|
||||
request.beginMillis,
|
||||
prepared.updated,
|
||||
)
|
||||
|
||||
else -> repository.updateEventFromOccurrence(
|
||||
eventId = request.eventId,
|
||||
beginMillis = request.beginMillis,
|
||||
original = prepared.original,
|
||||
updated = prepared.updated,
|
||||
)
|
||||
}
|
||||
MoveOutcome.Moved(
|
||||
startMillis = movedStartMillis(prepared),
|
||||
isAllDay = prepared.updated.isAllDay,
|
||||
undo = undoFor(prepared, scope),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
MoveOutcome.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
MoveOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
||||
* it now applies to — the provider then generates nothing at all. Which date
|
||||
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
||||
* series *anchor*, a split starts at the moved occurrence, and a single
|
||||
* occurrence becomes an exception row that no `UNTIL` constrains.
|
||||
*/
|
||||
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
||||
val rule = prepared.updated.rrule ?: return false
|
||||
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
||||
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
||||
prepared.newAnchorDate
|
||||
} else {
|
||||
prepared.updated.start.date
|
||||
}
|
||||
return end.date < firstDay
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a same-date drag leaves the series anchor on its own day too. The
|
||||
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
||||
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
||||
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
||||
* carry the anchor across a midnight the occurrence never crossed and leave
|
||||
* `BYDAY` naming the wrong weekday.
|
||||
*/
|
||||
private fun anchorKeepsItsDay(
|
||||
detail: EventDetail,
|
||||
original: EventForm,
|
||||
shifted: EventForm,
|
||||
zone: TimeZone,
|
||||
): Boolean {
|
||||
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||
val delta = (
|
||||
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||
) / MILLIS_PER_MINUTE
|
||||
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||
}
|
||||
|
||||
/**
|
||||
* The series row's own start date — for a recurring master, `EventDetail`
|
||||
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
||||
* same anchoring the write path uses: UTC for an all-day series.
|
||||
*/
|
||||
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
||||
detail.instance.start
|
||||
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
||||
.date
|
||||
|
||||
/**
|
||||
* Undo is offered only where the inverse is one symmetric write: a
|
||||
* non-recurring event, and a whole-series move. "This event" leaves an
|
||||
* exception row behind and "this and following" splits the series with an
|
||||
* UNTIL truncation — neither is undone by shifting back.
|
||||
*/
|
||||
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
||||
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
||||
MoveUndo(
|
||||
eventId = prepared.request.eventId,
|
||||
moved = prepared.updated,
|
||||
restored = prepared.original,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||
* read dates back in: UTC midnight for an all-day event.
|
||||
*/
|
||||
private fun movedStartMillis(prepared: PreparedMove): Long {
|
||||
val form = prepared.updated
|
||||
return if (form.isAllDay) {
|
||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
} else {
|
||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The time shown in the timeline gutter while a block is dragged: 24h →
|
||||
* "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
|
||||
* already carry it, and the gutter is too narrow.
|
||||
*/
|
||||
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
||||
return LocalTime.of(clamped / 60, clamped % 60)
|
||||
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
const val DRAG_SNAP_MINUTES: Int = 15
|
||||
|
||||
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||
private val AUTO_SCROLL_EDGE = 64.dp
|
||||
|
||||
/** Fastest auto-scroll step, per frame, right at the edge. */
|
||||
private val AUTO_SCROLL_STEP = 16.dp
|
||||
|
||||
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
||||
data class TimelineDrag(
|
||||
val event: EventInstance,
|
||||
val date: LocalDate,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
val topLeftInRoot: Offset,
|
||||
val sizePx: IntSize,
|
||||
) {
|
||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||
}
|
||||
|
||||
/** Where a finished drag asks its event to go. */
|
||||
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
||||
|
||||
/**
|
||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||
* it per frame anyway.
|
||||
*/
|
||||
class TimelineGeometry {
|
||||
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
||||
var grid: LayoutCoordinates? = null
|
||||
|
||||
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
||||
var viewport: LayoutCoordinates? = null
|
||||
var scroll: ScrollState? = null
|
||||
var hourPx: Float = 0f
|
||||
|
||||
/** Column pitch — one column plus the gap after it. */
|
||||
var columnWidthPx: Float = 0f
|
||||
var columnGapPx: Float = 0f
|
||||
var edgePx: Float = 0f
|
||||
var stepPx: Float = 0f
|
||||
var days: List<LocalDate> = emptyList()
|
||||
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||
*/
|
||||
var isRtl: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||
*/
|
||||
@Stable
|
||||
class TimelineDragController {
|
||||
val geometry = TimelineGeometry()
|
||||
|
||||
var drag: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A dropped block, held at the slot it landed on while the write runs — the
|
||||
* grid behind it still shows the old time until the query re-reads.
|
||||
*/
|
||||
var settling: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||
* ghost" don't recompose that often. Stays set through [settling].
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the settled drop came from, and which event row it belongs to. The
|
||||
* instance id alone can't identify the source block for the length of the
|
||||
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
||||
* *old* time can arrive under a new instance id.
|
||||
*/
|
||||
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||
|
||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||
* The copy may only be handed back once this is true, or the source ghost
|
||||
* flashes back to full opacity in its old slot.
|
||||
*/
|
||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
|
||||
/**
|
||||
* The slot the block already occupied when it was picked up — not the same
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
*/
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
|
||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||
source = block
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
source = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||
* ghost: the instance the finger picked up, or once dropped whatever now
|
||||
* sits in the slot it left.
|
||||
*/
|
||||
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId &&
|
||||
date == originDate &&
|
||||
block.startMin == originMin
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
* or its title, since a single-occurrence move writes a new `eventId`.
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid || landed.date != date) return
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == landed.startMin &&
|
||||
(
|
||||
block.event.eventId == landed.event.eventId ||
|
||||
block.event.title == landed.event.title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag, handing back where it landed — null when it never resolved,
|
||||
* or when it landed back on the slot it started from. A real drop keeps its
|
||||
* copy on the target as [settling] until [release].
|
||||
*/
|
||||
fun finish(): TimelineDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
settling = landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||
* the block dim under the fading copy and then brighten it: a visible dip.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own block. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
||||
* than accumulated from `positionChange()`, because a stationary finger emits
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
||||
|
||||
val origin = grid.positionInRoot()
|
||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
// Clamp the start into the target day; a tail past midnight is fine.
|
||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||
// The column the finger is over on screen, and the day that column shows.
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = days[dayIndex],
|
||||
startMin = startMin,
|
||||
endMin = startMin + span,
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + column * columnPx,
|
||||
y = origin.y + startMin / 60f * hourPx,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
height.roundToInt(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the scroll for the whole drag and nudge it once per frame while the
|
||||
* finger sits near an edge. One `scroll` call, since a loop of
|
||||
* `animateScrollBy` would re-acquire the mutex and cancel the last.
|
||||
*/
|
||||
suspend fun autoScroll() {
|
||||
val scroll = geometry.scroll ?: return
|
||||
scroll.scroll(MutatePriority.UserInput) {
|
||||
while (true) {
|
||||
withFrameNanos { }
|
||||
recompute()
|
||||
val step = edgeStep()
|
||||
if (step != 0f) scrollBy(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun edgeStep(): Float {
|
||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||
val edge = geometry.edgePx
|
||||
if (edge <= 0f) return 0f
|
||||
val bounds = viewport.boundsInRoot()
|
||||
val fromTop = pointer.y - bounds.top
|
||||
val fromBottom = bounds.bottom - pointer.y
|
||||
return when {
|
||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTimelineDragController(): TimelineDragController {
|
||||
val controller = remember { TimelineDragController() }
|
||||
val density = LocalDensity.current
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [block] actually begins on [day] rather than being the tail of an
|
||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||
* event's start, so dragging it would move the event to a time it never had.
|
||||
*/
|
||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.start.toLocalDateTime(zone).date == day
|
||||
|
||||
/**
|
||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
||||
* forward by the missing hour, and the block visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||
|
||||
/**
|
||||
* A beat after the grid draws the drop, so its own block is under way before the
|
||||
* copy starts dissolving.
|
||||
*/
|
||||
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never confirms the drop — it landed on a day
|
||||
* this timeline doesn't show, or the write failed. The copy has to go either way.
|
||||
*/
|
||||
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
|
||||
/**
|
||||
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||
* in under it. Kept short, because a full-width copy sits over any neighbour it
|
||||
* now shares a lane with until it is gone.
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
/**
|
||||
* The floating block, drawn over the whole calendar so it is free of the day
|
||||
* column's clip and of the scroll viewport's rounded corners.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val density = LocalDensity.current
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.slot)
|
||||
LaunchedEffect(controller.isDragging) {
|
||||
if (controller.isDragging) controller.autoScroll()
|
||||
}
|
||||
// Hold the landed copy until the write is done — including the time a
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a block still in the tree behind it; announcing it again
|
||||
// would duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val landed = controller.drag == null
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||
label = "drag-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
val fill = eventFill(drag.event.color, dark, soften)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// An end past midnight wraps rather than saturating, so a four-hour
|
||||
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||
drag.endMin % MINUTES_PER_DAY
|
||||
} else {
|
||||
drag.endMin
|
||||
}
|
||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
IntOffset(
|
||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { drag.sizePx.width.toDp() },
|
||||
height = with(density) { drag.sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
}
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,18 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||
* to the hour it landed in.
|
||||
*
|
||||
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||
* to read, so it answers midnight rather than dividing by it.
|
||||
*/
|
||||
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||
|
||||
/**
|
||||
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||
|
||||
@@ -41,7 +41,14 @@ class TimelineZoom(
|
||||
var scale: TimelineScale by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
private var pinching = false
|
||||
/**
|
||||
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
||||
* anything that tweens off [scale] can stand down for the gesture: the
|
||||
* height changes every pointer frame, and a spring would spend the whole
|
||||
* pinch chasing a target that has already moved.
|
||||
*/
|
||||
var isPinching: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||
@@ -49,11 +56,11 @@ class TimelineZoom(
|
||||
* snap the timeline back while the user is still pinching.
|
||||
*/
|
||||
fun adopt(stored: TimelineScale) {
|
||||
if (!pinching) scale = stored
|
||||
if (!isPinching) scale = stored
|
||||
}
|
||||
|
||||
fun beginPinch() {
|
||||
pinching = true
|
||||
isPinching = true
|
||||
}
|
||||
|
||||
fun pinchTo(hourHeight: Dp) {
|
||||
@@ -61,7 +68,7 @@ class TimelineZoom(
|
||||
}
|
||||
|
||||
fun endPinch() {
|
||||
pinching = false
|
||||
isPinching = false
|
||||
persist(scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||
* height keeps them comparable — each shows the same slice of screen.
|
||||
*/
|
||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||
* pass the *real* view composables, so a preview cannot drift from what it
|
||||
* depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// view's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||
* rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FRAME_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FRAME_INSET),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real view at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FRAME_INSET = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -42,9 +42,11 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -53,14 +55,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -74,7 +81,22 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -84,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -93,9 +116,12 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -106,16 +132,11 @@ import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDay.isEmpty()) return 0.dp
|
||||
val lanes = allDay.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -298,42 +319,67 @@ private fun DayContent(
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
val took = move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now
|
||||
// rather than hold it out for a settle that never comes.
|
||||
if (took != true) dragController.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaySuccess(
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
@@ -352,8 +398,10 @@ private fun DaySuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -425,7 +473,11 @@ private fun AllDayStrip(
|
||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||
// inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
.padding(
|
||||
top = ALL_DAY_VERTICAL_PADDING,
|
||||
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day column.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
@@ -479,6 +531,7 @@ private fun AllDayBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -487,13 +540,15 @@ private fun AllDayBar(
|
||||
private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||
// timeline's own viewport height, which is only known here — below the top
|
||||
@@ -512,39 +567,20 @@ private fun Timeline(
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
@@ -552,11 +588,26 @@ private fun Timeline(
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -570,13 +621,25 @@ private fun DayColumnCard(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -597,29 +660,47 @@ private fun DayColumnCard(
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// a column holding two occurrences of one series.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
@@ -634,7 +715,10 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -658,12 +742,35 @@ private fun EventBlock(
|
||||
val showTitle = available >= titleLineHeight
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
// A block clipped at the top continues from the previous day: its top edge
|
||||
// is midnight, not the event's start, so dragging it would invent a time.
|
||||
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = modifier
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
@@ -673,14 +780,12 @@ private fun EventBlock(
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
@@ -696,7 +801,12 @@ private fun DayLoading() {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||
// doesn't resize the moment the real day arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
||||
* real; only the events are stand-ins.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DayViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
||||
val days = listOf(today)
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return DayUiState.Success(
|
||||
date = today,
|
||||
today = today,
|
||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timed = layoutDay(events, today, zone),
|
||||
)
|
||||
}
|
||||
@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
// Bumped by retry() to re-run the load for the same target.
|
||||
// Bumped by retry() and by re-opening the target already shown, to re-run
|
||||
// the load without changing the target.
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
// Last target whose content is already on screen; a re-read of it skips the
|
||||
// Loading skeleton so the sheet doesn't blank out between two identical reads.
|
||||
private var loadedTarget: Target? = null
|
||||
|
||||
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
|
||||
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
|
||||
combine(_target, _reload) { target, _ -> target }
|
||||
.flatMapLatest { target ->
|
||||
if (target == null) {
|
||||
loadedTarget = null
|
||||
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
|
||||
} else {
|
||||
flow {
|
||||
emit(EventDetailUiState.Loading)
|
||||
emit(loadDetail(target))
|
||||
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
||||
val loaded = loadDetail(target)
|
||||
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
||||
emit(loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,9 +86,15 @@ class EventDetailViewModel @Inject constructor(
|
||||
* the occurrence's own times (from `CalendarContract.Instances`); they
|
||||
* override the series DTSTART/DTEND so recurring events show the correct
|
||||
* date instead of the first occurrence.
|
||||
*
|
||||
* Re-opening the *same* occurrence always re-reads it. The view model
|
||||
* outlives the sheet, and a `StateFlow` conflates an identical value away,
|
||||
* so assigning the target alone would leave an edit that changed no time —
|
||||
* adding a description, say (#196) — showing the state from before the save.
|
||||
*/
|
||||
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||
_target.value = Target(eventId, beginMillis, endMillis)
|
||||
val target = Target(eventId, beginMillis, endMillis)
|
||||
if (_target.value == target) _reload.value += 1 else _target.value = target
|
||||
}
|
||||
|
||||
/** Re-run the current load after a failure. */
|
||||
|
||||
@@ -64,6 +64,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -127,6 +128,7 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
@@ -263,13 +265,16 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onSaved()
|
||||
}
|
||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
||||
// the only sign anything happened — long rather than the default
|
||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
||||
SaveUiState.Failed -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
SaveUiState.NeedsPermission -> {
|
||||
viewModel.consumeSaveResult()
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||
}
|
||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||
else -> Unit
|
||||
@@ -452,30 +457,11 @@ private fun SaveScopeDialog(
|
||||
onSelect: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!recurrenceChanged) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_edit_recurring_title),
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
allowOccurrence = !recurrenceChanged,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -56,6 +57,8 @@ import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* want different reminder handling (#49), and differ in whether they own the
|
||||
@@ -751,7 +754,15 @@ class EventEditViewModel @Inject constructor(
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
SaveUiState.NeedsPermission
|
||||
} catch (e: NoSuchEventException) {
|
||||
// The event or occurrence is already gone: the same answer the
|
||||
// pre-check gives, and better than a bare "couldn't save".
|
||||
SaveUiState.Gone
|
||||
} catch (e: Exception) {
|
||||
// The user only gets a generic snackbar, so without this a failed
|
||||
// write leaves nothing to report (Codeberg #234). Scope and event
|
||||
// id only — never the form's content.
|
||||
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
||||
SaveUiState.Failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
/** One week row's live geometry, republished on every layout while it is on screen. */
|
||||
class MonthRowGeometry(
|
||||
val days: List<LocalDate>,
|
||||
/** The row's day-column box — the space chip offsets are measured in. */
|
||||
val cell: LayoutCoordinates,
|
||||
/** The lane band inside that box, where the chips themselves are seated. */
|
||||
val band: LayoutCoordinates?,
|
||||
val columnWidthPx: Float,
|
||||
val laneHeightPx: Float,
|
||||
val laneCount: Int,
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||
*/
|
||||
val isRtl: Boolean,
|
||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||
) {
|
||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||
fun seat(col: Int, lane: Int): Offset? {
|
||||
val band = band?.takeIf { it.isAttached } ?: return null
|
||||
val origin = band.positionInRoot()
|
||||
val column = if (isRtl) days.lastIndex - col else col
|
||||
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||
*/
|
||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||
} ?: return null
|
||||
return seat(col, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||
* move writes a new event id — the title survives both.
|
||||
*/
|
||||
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||
chip.eventId == moved.eventId || chip.title == moved.title
|
||||
|
||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||
data class MonthChipDrag(
|
||||
val event: EventInstance,
|
||||
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate?,
|
||||
val topLeftInRoot: Offset,
|
||||
val sizePx: IntSize,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
data class MonthChipDrop(
|
||||
val event: EventInstance,
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hoisted drag state for the month grid (#68). Every visible week row registers
|
||||
* itself so a chip can be carried across rows, the target resolved against
|
||||
* whichever registered row the finger is over. Rows are keyed by an identity
|
||||
* token rather than a date: the continuous style can show the same week twice.
|
||||
*/
|
||||
@Stable
|
||||
class MonthDragController {
|
||||
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
||||
|
||||
var drag: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A dropped chip, held on the day it landed on while the write runs — the
|
||||
* grid behind it still shows the old day until the query re-reads.
|
||||
*/
|
||||
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||
* [drag], which changes every frame, so the chips and rows that only need
|
||||
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the grid itself now draws the drop, in root coordinates — published
|
||||
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||
* finger was while the grid seats it in a lane, so without this it would
|
||||
* cross-fade across the gap instead of gliding onto its seat.
|
||||
*/
|
||||
var settledInRoot: Offset? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||
* [handOver] rather than [release], or the chip stays dim under a copy that
|
||||
* has already faded and brightens afterwards: a visible dip.
|
||||
*/
|
||||
private var settledGhost: Boolean by mutableStateOf(false)
|
||||
|
||||
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo long after the copy is gone.
|
||||
*/
|
||||
private var undoable: MonthChipDrag? = null
|
||||
|
||||
private var event: EventInstance? = null
|
||||
private var grabDate: LocalDate? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
private var sizePx = IntSize.Zero
|
||||
|
||||
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
||||
rows[token] = geometry
|
||||
}
|
||||
|
||||
fun removeRow(token: Any) {
|
||||
rows.remove(token)
|
||||
}
|
||||
|
||||
fun begin(
|
||||
event: EventInstance,
|
||||
grabDate: LocalDate,
|
||||
pointerInRoot: Offset,
|
||||
chipInRoot: Offset,
|
||||
size: IntSize,
|
||||
) {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
pointer = pointerInRoot
|
||||
sizePx = size
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
event = null
|
||||
grabDate = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
*/
|
||||
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||
if (days == null || landed.targetDate !in days) return false
|
||||
return isSameEvent(event, landed.event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the row keyed [token] whether it now seats the settled chip. Both
|
||||
* copies of a week the continuous style shows twice will answer; the seat
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
val current = settledInRoot
|
||||
val closer = current == null ||
|
||||
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||
if (closer) settledInRoot = at
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a chip on the journey an undo's inverse write is about to make, so it
|
||||
* travels back rather than teleporting. Writes nothing itself. False when
|
||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||
* seat the moved event where the chip would have to start from.
|
||||
*/
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag, handing back where it landed — null when it never resolved,
|
||||
* or when it landed back on the day it came from. A real drop keeps its chip
|
||||
* on the target day as [settling] until [release], slid over to that day's
|
||||
* column so it lands where the grid is about to draw it.
|
||||
*/
|
||||
fun finish(): MonthChipDrop? {
|
||||
val landed = drag
|
||||
val left = landed?.targetDate?.let(::columnLeft)
|
||||
cancel()
|
||||
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
|
||||
settling = landed.copy(
|
||||
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
undoable = settling
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||
* at [release] instead leaves the chip dim and then brightens it.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
handOver()
|
||||
}
|
||||
|
||||
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||
?: return@firstNotNullOfOrNull null
|
||||
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||
bounds.left + column * row.columnWidthPx
|
||||
}
|
||||
|
||||
private fun recompute() {
|
||||
val event = event ?: return
|
||||
val grabbed = grabDate ?: return
|
||||
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||
?: return@firstNotNullOfOrNull null
|
||||
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
||||
.toInt()
|
||||
.coerceIn(0, row.days.lastIndex)
|
||||
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
||||
}
|
||||
}
|
||||
drag = MonthChipDrag(
|
||||
event = event,
|
||||
grabDate = grabbed,
|
||||
// Off the grid entirely: keep the last cell it was over, so a
|
||||
// wobble past the edge doesn't drop the target.
|
||||
targetDate = resolved ?: drag?.targetDate,
|
||||
topLeftInRoot = pointer - grab,
|
||||
sizePx = sizePx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
||||
|
||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -17,6 +19,8 @@ import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -59,9 +63,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -77,11 +83,37 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -109,6 +141,7 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -126,6 +159,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -384,13 +418,23 @@ fun MonthScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||
// overlay, clear of the week row's and the list viewport's clips.
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too. The
|
||||
// signal comes from the write, not from the chip that offers it.
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
CompositionLocalProvider(
|
||||
LocalDimCutoff provides dimCutoff,
|
||||
LocalMonthDrag provides chipDrag,
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
state = continuousState,
|
||||
@@ -399,6 +443,7 @@ fun MonthScreen(
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else if (viewStyle == MonthViewStyle.Split) {
|
||||
SplitMonthContent(
|
||||
@@ -423,14 +468,108 @@ fun MonthScreen(
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||
* "+N" overflow or on a day this month doesn't show, or the write failed.
|
||||
*/
|
||||
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
|
||||
/** The chip in flight, drawn over the grid and following the finger. */
|
||||
@Composable
|
||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate)
|
||||
// Hold the landed chip until the write is done — including the time a
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||
// the grid seats it in a lane.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
delay(SETTLE_GRACE_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a chip still in the tree behind it; announcing it again
|
||||
// would duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||
label = "chip-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
@@ -440,6 +579,7 @@ private fun MonthContent(
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
@@ -466,6 +606,7 @@ private fun MonthContent(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -484,6 +625,7 @@ private fun ContinuousMonthContent(
|
||||
showWeekNumbers: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
// The scrolling styles get their own skeleton rather than the paged
|
||||
@@ -498,6 +640,7 @@ private fun ContinuousMonthContent(
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else {
|
||||
ContinuousMonthGrid(
|
||||
@@ -505,6 +648,7 @@ private fun ContinuousMonthContent(
|
||||
listState = listState,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -603,7 +747,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
||||
|
||||
/** Width of the split style's selected-day outline. */
|
||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||
private const val MAX_EVENT_ROWS = 3
|
||||
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||
internal const val MAX_EVENT_ROWS = 3
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -621,11 +766,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
*/
|
||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||
|
||||
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||
private val DENSE_HEADER_GAP = 4.dp
|
||||
|
||||
@Composable
|
||||
internal fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
@@ -646,6 +795,7 @@ internal fun MonthGrid(
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -676,6 +826,7 @@ internal fun ContinuousMonthGrid(
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val monthCount = remember { continuousMonthCount() }
|
||||
@@ -703,6 +854,7 @@ internal fun ContinuousMonthGrid(
|
||||
today = state.today,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -722,6 +874,7 @@ private fun ContinuousMonthBlock(
|
||||
today: LocalDate,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
|
||||
Column(
|
||||
@@ -744,6 +897,7 @@ private fun ContinuousMonthBlock(
|
||||
blankOutside = true,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(CONTINUOUS_ROW_HEIGHT),
|
||||
@@ -802,6 +956,7 @@ internal fun DenseMonthGrid(
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||
@@ -809,10 +964,15 @@ internal fun DenseMonthGrid(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
// The gap under the weekday header is a real margin, not content
|
||||
// padding: a lazy list scrolls its rows *through* the before-content
|
||||
// padding, so scrolling to a week landed its row that far down with
|
||||
// the tail of the previous one showing above it (#191).
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(top = DENSE_HEADER_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
items(count = weekCount, key = { it }) { index ->
|
||||
val week = state.weeksByIndex[index]
|
||||
@@ -827,6 +987,7 @@ internal fun DenseMonthGrid(
|
||||
inMonth = { true },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
labelMonthOnFirst = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1054,6 +1215,9 @@ private fun SplitMonthBody(
|
||||
onSelectDay(it)
|
||||
onSetExpanded(false)
|
||||
},
|
||||
// A tapped chip is asking for that event, not for its day, so it
|
||||
// opens the detail from here too rather than collapsing (#187).
|
||||
onEventClick = onEventClick,
|
||||
onCollapse = { onSetExpanded(false) },
|
||||
)
|
||||
} else {
|
||||
@@ -1154,6 +1318,7 @@ private fun SplitMonthExpanded(
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCollapse: () -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
@@ -1176,6 +1341,7 @@ private fun SplitMonthExpanded(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onPickDay,
|
||||
onEventClick = onEventClick,
|
||||
selected = sel,
|
||||
)
|
||||
}
|
||||
@@ -1656,7 +1822,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Spacer(Modifier.height(DENSE_HEADER_GAP))
|
||||
}
|
||||
// More rows than a viewport holds; the clip takes the overflow.
|
||||
repeat(6) {
|
||||
@@ -1704,6 +1870,7 @@ private fun MonthWeekRow(
|
||||
inMonth: (LocalDate) -> Boolean,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
blankOutside: Boolean = false,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
@@ -1720,6 +1887,50 @@ private fun MonthWeekRow(
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
val morphing = morphInFlight()
|
||||
|
||||
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||
// row's day-column box hit-tests them geometrically instead.
|
||||
val moveScope = LocalEventMove.current
|
||||
val dragController = LocalMonthDrag.current
|
||||
val rowToken = remember { Any() }
|
||||
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val density = LocalDensity.current
|
||||
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val dragging = dragController?.isDragging == true
|
||||
DisposableEffect(rowToken, dragController) {
|
||||
onDispose { dragController?.removeRow(rowToken) }
|
||||
}
|
||||
// Republished on layout *and* on every recomposition: the coordinates change
|
||||
// only on the former, but what the row draws — which the controller reads to
|
||||
// find a moved chip's seat — changes on the latter.
|
||||
val publish = {
|
||||
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||
if (dragController != null && cell != null) {
|
||||
dragController.putRow(
|
||||
rowToken,
|
||||
MonthRowGeometry(
|
||||
days = week.days,
|
||||
cell = cell,
|
||||
band = bandCoordinates[0],
|
||||
columnWidthPx = cell.size.width / 7f,
|
||||
laneHeightPx = rowHeightPx,
|
||||
laneCount = MAX_EVENT_ROWS,
|
||||
isRtl = isRtl,
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
SideEffect { publish() }
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||
// which is what changes when the re-read lands.
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
dragController?.noteSettled(rowToken)
|
||||
}
|
||||
|
||||
Row(modifier) {
|
||||
// Optional calendar-week gutter, sized so the seven day columns below
|
||||
// divide the remaining width — the absolute bar offsets stay correct
|
||||
@@ -1735,7 +1946,21 @@ private fun MonthWeekRow(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coords ->
|
||||
cellCoordinates[0] = coords
|
||||
publish()
|
||||
}
|
||||
.then(
|
||||
monthChipDragModifier(
|
||||
week = week,
|
||||
moveScope = moveScope,
|
||||
controller = dragController,
|
||||
band = bandCoordinates,
|
||||
rowHeightPx = rowHeightPx,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
),
|
||||
) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
@@ -1800,7 +2025,13 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
||||
.onGloballyPositioned {
|
||||
bandCoordinates[0] = it
|
||||
publish()
|
||||
}
|
||||
// A dragged chip travels to another row, so the clip
|
||||
// yields for it as it does for a morph.
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
@@ -1810,6 +2041,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -1879,6 +2111,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -1936,11 +2169,17 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||
// Tap layer: a tap on a chip opens that event, anything else opens the
|
||||
// day (#187). The chips take no pointer input of their own — this
|
||||
// layer covers them — so the lane under the finger is resolved
|
||||
// geometrically, exactly as the drag pickup above does it; the down
|
||||
// position is read on the initial pass, which consumes nothing and so
|
||||
// leaves both the click and a pickup in flight untouched. Padded and
|
||||
// clipped to the background pill so the ripple matches it. A blanked
|
||||
// cell isn't part of this month, so it takes no taps either.
|
||||
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
week.days.forEachIndexed { col, d ->
|
||||
if (blankOutside && !inMonth(d)) {
|
||||
Spacer(Modifier.weight(1f).fillMaxHeight())
|
||||
} else {
|
||||
@@ -1948,9 +2187,33 @@ private fun MonthWeekRow(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(col) {
|
||||
awaitEachGesture {
|
||||
downY[col] = awaitFirstDown(
|
||||
requireUnconsumed = false,
|
||||
pass = PointerEventPass.Initial,
|
||||
).position.y
|
||||
}
|
||||
}
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.clickable { onOpenDay(d) },
|
||||
.clickable {
|
||||
// Cleared on read: a click with no fresh down
|
||||
// (TalkBack, D-pad) would otherwise resolve the
|
||||
// previous tap's position and reopen its chip.
|
||||
val cellY = downY[col]
|
||||
downY[col] = NO_DOWN_Y
|
||||
val chip = week.chipAtCellY(
|
||||
col = col,
|
||||
cellY = cellY,
|
||||
bandTopInCell = bandTopInCell(
|
||||
cellCoordinates,
|
||||
bandCoordinates,
|
||||
),
|
||||
rowHeightPx = rowHeightPx,
|
||||
)
|
||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1959,6 +2222,110 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the event band sits below the top of the row's day-column box, or null
|
||||
* while either is unmeasured. Read off the live coordinates rather than summed
|
||||
* from the padding constants, so it can't drift from what the row actually drew.
|
||||
*/
|
||||
private fun bandTopInCell(
|
||||
cell: Array<LayoutCoordinates?>,
|
||||
band: Array<LayoutCoordinates?>,
|
||||
): Float? {
|
||||
val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||
return bandTop - cellTop
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
|
||||
*/
|
||||
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
|
||||
|
||||
/**
|
||||
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
|
||||
* the row's day-column box. Null for a tap above the band (the day number), on an
|
||||
* empty lane, or on the overflow dots — all of which mean "open the day", the
|
||||
* dots included: their point is that the day holds more than fits.
|
||||
*/
|
||||
internal fun MonthWeek.chipAtCellY(
|
||||
col: Int,
|
||||
cellY: Float,
|
||||
bandTopInCell: Float?,
|
||||
rowHeightPx: Float,
|
||||
): EventInstance? {
|
||||
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||
val bandY = cellY - bandTopInCell
|
||||
if (bandY < 0f) return null
|
||||
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||
* space (or an unmovable calendar) so tapping a day still opens it.
|
||||
*/
|
||||
@Composable
|
||||
private fun monthChipDragModifier(
|
||||
week: MonthWeek,
|
||||
moveScope: EventMoveScope?,
|
||||
controller: MonthDragController?,
|
||||
band: Array<LayoutCoordinates?>,
|
||||
rowHeightPx: Float,
|
||||
isRtl: Boolean,
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||
key = week.days.first(),
|
||||
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
||||
val columnPx = size.width / 7f
|
||||
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
||||
val lane = (bandY / rowHeightPx).toInt()
|
||||
// The column under the finger on screen, and the day that column shows.
|
||||
val column = (local.x / columnPx).toInt()
|
||||
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||
}
|
||||
if (event == null || moveScope?.allows(event) != true) {
|
||||
false
|
||||
} else {
|
||||
controller?.begin(
|
||||
event = event,
|
||||
grabDate = week.days[dayIndex],
|
||||
pointerInRoot = pointerInRoot,
|
||||
chipInRoot = Offset(
|
||||
x = nodeInRoot.x + column * columnPx,
|
||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||
),
|
||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||
)
|
||||
true
|
||||
}
|
||||
},
|
||||
onMove = { controller?.move(it) },
|
||||
onDrop = {
|
||||
controller?.finish()?.let { drop ->
|
||||
// How many columns the finger crossed — grabbing the middle of a
|
||||
// multi-day bar shifts by what it travelled, not to where it landed.
|
||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||
val took = moveScope?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now rather than
|
||||
// hold the chip out for a settle that can never arrive.
|
||||
if (took != true) controller?.release()
|
||||
}
|
||||
},
|
||||
onCancel = { controller?.cancel() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||
@@ -2051,12 +2418,24 @@ private fun MonthBar(
|
||||
continuesLeft: Boolean,
|
||||
continuesRight: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||
* has to ghost until the copy lands on it, and the instance id can't find it:
|
||||
* the provider hands the re-read instance a new one.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -2065,9 +2444,13 @@ private fun MonthBar(
|
||||
)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
.semantics {
|
||||
contentDescription = title
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
@@ -2076,6 +2459,7 @@ private fun MonthBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
* chooser.
|
||||
*
|
||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||
* event bar's colour and every preview follows automatically. The trick is
|
||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||
* out at a full phone width and a plausible viewport height, and a
|
||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||
* and onboarding choosers.
|
||||
*
|
||||
* The month, today's position and the week start are all real; only the events
|
||||
* are stand-ins, since the settings screen has no business querying the
|
||||
* provider for a thumbnail.
|
||||
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
||||
* measured at phone size and shrunk into the card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStylePreview(
|
||||
@@ -57,108 +34,64 @@ internal fun MonthStylePreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// grid's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure the grid at a full phone viewport but report the scaled
|
||||
// size, so the node occupies exactly what it draws.
|
||||
//
|
||||
// Modifier.requiredSize would be the obvious way to force the larger
|
||||
// measurement, but it *centres* content that overflows the incoming
|
||||
// constraints — which pushed the grid to a negative offset and left
|
||||
// only its bottom-right corner inside the clip.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a
|
||||
// stream running past the viewport rather than one
|
||||
// beginning at its top edge.
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
// Start a week above today's, so the preview shows a stream
|
||||
// running past the viewport rather than one beginning at its
|
||||
// top edge.
|
||||
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport the preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||
* three styles comparable — each shows the same slice of screen.
|
||||
*/
|
||||
private val VIRTUAL_HEIGHT = 440.dp
|
||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||
|
||||
private class SampleMonth(
|
||||
@@ -170,9 +103,6 @@ private class SampleMonth(
|
||||
* A month's worth of stand-in events, laid out through the same
|
||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a token here would misrepresent what the grid actually renders.
|
||||
*/
|
||||
private fun sampleMonthState(
|
||||
today: LocalDate,
|
||||
@@ -181,7 +111,7 @@ private fun sampleMonthState(
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleEvents(first, today, zone)
|
||||
val events = sampleMonthEvents(first, today, zone)
|
||||
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||
val month = MonthUiState.Success(
|
||||
@@ -213,72 +143,3 @@ private fun sampleMonthState(
|
||||
)
|
||||
return SampleMonth(month, continuous)
|
||||
}
|
||||
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private fun sampleEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun next() = ++id
|
||||
|
||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
// Spread across the month so most weeks carry something, with one multi-day
|
||||
// bar to show a span bridging cells and a busy today for the split pane.
|
||||
return buildList {
|
||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(timed(today, 9, 1, 0))
|
||||
add(timed(today, 12, 1, 1))
|
||||
add(timed(today, 15, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
|
||||
@@ -67,6 +67,23 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
||||
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Wizard step shown only when everything you write to is a device-only calendar
|
||||
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
||||
*
|
||||
* The primary action picks a folder and switches automatic backup on in one go.
|
||||
* Everything it sets is a normal Backup setting, adjustable afterwards.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BackupStep(
|
||||
onEnable: (Uri) -> Unit,
|
||||
onSkip: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
// Cancelling the picker decides nothing and leaves the step open.
|
||||
uri?.let(onEnable)
|
||||
}
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.FolderOpen,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Schedule,
|
||||
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
||||
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -16,20 +17,25 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** What, if anything, the brand mark is wearing in its corner. */
|
||||
internal enum class HeroBadge { Denied, Done }
|
||||
|
||||
/**
|
||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||
* A lock badge overlays the corner when permission has been [denied].
|
||||
* A [badge] overlays the corner where the screen has a verdict to give: blocked,
|
||||
* or finished.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrandHero(denied: Boolean) {
|
||||
internal fun BrandHero(badge: HeroBadge? = null) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -43,24 +49,56 @@ internal fun BrandHero(denied: Boolean) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (denied) {
|
||||
// A small lock badge sits over the corner to signal "blocked".
|
||||
if (badge != null) {
|
||||
val container = when (badge) {
|
||||
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
|
||||
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
val onContainer = when (badge) {
|
||||
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
|
||||
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 10.dp, y = 10.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
.background(container),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
imageVector = when (badge) {
|
||||
HeroBadge.Denied -> Icons.Filled.Lock
|
||||
HeroBadge.Done -> Icons.Filled.Check
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
tint = onContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
||||
* for the wizard steps that stand for a feature rather than for the app itself.
|
||||
*/
|
||||
@Composable
|
||||
internal fun IconHero(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* The wizard's closing screen (#163): the flow ends on a full screen saying so
|
||||
* rather than dropping into the calendar mid-gesture, and the brand mark wears
|
||||
* the check that the last step earned.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DoneStep(
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero(HeroBadge.Done) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
|
||||
* composable scope so it can be captured by `AnimatedContent`'s non-composable
|
||||
* transitionSpec lambda.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||
|
||||
/**
|
||||
* Moving between wizard steps (#163): the steps sit side by side and the whole
|
||||
* width swipes across, position on a spring so the arriving step settles with a
|
||||
* little give rather than stopping dead.
|
||||
*
|
||||
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
|
||||
* nothing in common, so fading one through the other smears. Both are opaque and
|
||||
* travel in lockstep, showing one screen pushing the other off.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back.
|
||||
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
|
||||
* @param fadeSpec effects spec used for the whole transition under reduced
|
||||
* motion.
|
||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||
*/
|
||||
internal fun onboardingSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
fadeSpec: FiniteAnimationSpec<Float>,
|
||||
reduceMotion: Boolean,
|
||||
): ContentTransform {
|
||||
if (reduceMotion) {
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return ContentTransform(
|
||||
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
|
||||
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
|
||||
// Clipping to the animating container would shear both steps against the
|
||||
// viewport edge as they pass; there is no size change here to contain.
|
||||
sizeTransform = SizeTransform(clip = false),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
||||
enum class OnboardingStep {
|
||||
/** Required: nothing works without the calendar grant. */
|
||||
Permission,
|
||||
|
||||
/** Whether Calendula delivers reminder notifications itself. */
|
||||
Reminders,
|
||||
|
||||
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
||||
Backup,
|
||||
|
||||
/** Which view the app opens on, chosen from a live preview. */
|
||||
View,
|
||||
|
||||
/** How the Month view lays itself out, chosen from a live preview. */
|
||||
MonthStyle,
|
||||
|
||||
/** Only on an install that has calendars the device is hiding (#75). */
|
||||
Visibility,
|
||||
|
||||
/** The closing screen: everything is answered and the app is ready. */
|
||||
Done,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which steps this install owes and where it has got to. [steps] is the whole
|
||||
* flow — completed steps included — so the counter doesn't renumber as steps
|
||||
* are finished; [current] is null once nothing is left and the app opens.
|
||||
*/
|
||||
data class OnboardingPlan(
|
||||
val steps: List<OnboardingStep>,
|
||||
val current: OnboardingStep?,
|
||||
) {
|
||||
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
||||
val index: Int get() = steps.indexOf(current) + 1
|
||||
|
||||
val total: Int get() = steps.size
|
||||
|
||||
/** A one-step flow is not a wizard: there is nothing to count down. */
|
||||
val showsProgress: Boolean get() = total > 1
|
||||
|
||||
/** The step before [current], or null at the start (and once finished). */
|
||||
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
|
||||
|
||||
/**
|
||||
* Whether the wizard can step back. Everything is re-askable except the
|
||||
* calendar grant, which belongs to the system — once given there is nothing
|
||||
* for a back press to return to.
|
||||
*/
|
||||
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the flow from what is stored.
|
||||
*
|
||||
* The optional steps belong to installs still working through their first run.
|
||||
* Before the grant that can only be guessed, so one that has not answered the
|
||||
* reminder step either counts as fresh — the same condition
|
||||
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
||||
* commits to storage a moment later, and clears again on the closing screen so
|
||||
* a later re-grant is only the permission step.
|
||||
*
|
||||
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
||||
* whole permission step; the backup step is assumed to apply until proven
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* counter had not accounted for.
|
||||
*
|
||||
* The month-style step is *not* conditional on Month being the chosen view:
|
||||
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||
* and went as the view is picked would move the counter under the user on the
|
||||
* step right before it.
|
||||
*
|
||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||
* screen.
|
||||
*/
|
||||
fun onboardingPlan(
|
||||
hasPermission: Boolean,
|
||||
remindersDone: Boolean,
|
||||
wizardArmed: Boolean,
|
||||
backupDone: Boolean,
|
||||
viewDone: Boolean,
|
||||
monthStyleDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
): OnboardingPlan {
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) {
|
||||
add(OnboardingStep.View)
|
||||
add(OnboardingStep.MonthStyle)
|
||||
}
|
||||
if (visibilityArmed) add(OnboardingStep.Visibility)
|
||||
if (fresh) add(OnboardingStep.Done)
|
||||
}
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
OnboardingStep.MonthStyle -> !monthStyleDone
|
||||
OnboardingStep.Visibility -> !visibilityDone
|
||||
OnboardingStep.Done -> !doneShown
|
||||
}
|
||||
}
|
||||
return OnboardingPlan(steps, current)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||
|
||||
/**
|
||||
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
||||
* through [viewModel], which then hands the host the next one.
|
||||
*/
|
||||
@Composable
|
||||
fun OnboardingSteps(
|
||||
step: OnboardingStep,
|
||||
plan: OnboardingPlan,
|
||||
viewModel: OnboardingViewModel,
|
||||
onPermissionGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Collected for every step, not just the two that draw a preview: starting
|
||||
// it when the picker composes would leave that step blank as it slides in.
|
||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||
|
||||
// Coerced because the outgoing half of a transition may be a step the live
|
||||
// plan has since dropped — the backup step goes once the calendars say it
|
||||
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
||||
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
|
||||
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
OnboardingProgress(
|
||||
step = index,
|
||||
total = plan.total,
|
||||
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The system back gesture does the same thing as the button, and stays off
|
||||
// on the first step so back still leaves the app.
|
||||
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
|
||||
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
IconButton(onClick = viewModel::goBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.onboarding_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> PermissionScreen(
|
||||
onGranted = onPermissionGranted,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
)
|
||||
OnboardingStep.Reminders -> ReminderStep(
|
||||
onFinished = viewModel::finishReminders,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Backup -> BackupStep(
|
||||
onEnable = viewModel::enableAutoBackup,
|
||||
onSkip = viewModel::skipBackup,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
// The choice is null only until DataStore's first emission; rendering
|
||||
// nothing for that frame beats a preview built on the wrong defaults.
|
||||
OnboardingStep.View -> choice?.let {
|
||||
ViewStep(
|
||||
choice = it,
|
||||
onSelect = viewModel::setDefaultView,
|
||||
onFinished = viewModel::finishView,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
OnboardingStep.MonthStyle -> choice?.let {
|
||||
MonthStyleStep(
|
||||
choice = it,
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onFinished = viewModel::finishMonthStyle,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
OnboardingStep.Visibility -> VisibilityStep(
|
||||
onFinished = viewModel::finishVisibility,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
OnboardingStep.Done -> DoneStep(
|
||||
onFinished = viewModel::finishOnboarding,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the first-launch wizard (#163): decides which steps this install owes
|
||||
* (see [onboardingPlan]) and commits each answer.
|
||||
*
|
||||
* [plan] is null until the first stored emission, so the host renders nothing
|
||||
* for that frame rather than flashing a step the user has already answered.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class OnboardingViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val repository: CalendarRepository,
|
||||
private val scanner: ReminderScanner,
|
||||
private val visibilityReconciler: CalendarVisibilityReconciler,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Null until the host reports it — assuming either way would flash a screen. */
|
||||
private val permissionReported = MutableStateFlow<Boolean?>(null)
|
||||
|
||||
/**
|
||||
* The grant as the rest of the flow sees it: published only once the wizard
|
||||
* has been armed for it, since arming is a stored write and the frames
|
||||
* before it lands would collapse the plan to its one-step form.
|
||||
*/
|
||||
private val hasPermission: StateFlow<Boolean?> = permissionReported
|
||||
.mapLatest { granted ->
|
||||
if (granted == true) armWizardIfFresh()
|
||||
granted
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the backup step applies: something of yours is worth exporting and
|
||||
* nothing you write to is synced anywhere. Null while the calendar list
|
||||
* cannot be read: every frame before the grant, and any read that fails —
|
||||
* an empty list is also how a null cursor arrives, and guessing false there
|
||||
* would drop the step for the whole session.
|
||||
*/
|
||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
repository.calendars()
|
||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||
.catch { emit(null) }
|
||||
.flowOn(io)
|
||||
}
|
||||
}
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
prefs.onboardingBackupDone,
|
||||
prefs.onboardingViewDone,
|
||||
prefs.onboardingMonthStyleDone,
|
||||
::OnboardingFlags,
|
||||
)
|
||||
|
||||
/** The closing steps' own flags, and whether the notice applies at all. */
|
||||
private val closingFlags: Flow<ClosingFlags> = combine(
|
||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||
prefs.onboardingVisibilityDone,
|
||||
prefs.onboardingDoneShown,
|
||||
::ClosingFlags,
|
||||
)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
remindersDone = stored.remindersDone,
|
||||
wizardArmed = stored.wizardArmed,
|
||||
backupDone = stored.backupDone,
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
backupApplies = backup,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** The current view preferences, for the view step's initial selection. */
|
||||
val viewChoice: StateFlow<ViewChoice?> = combine(
|
||||
prefs.defaultView,
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
::ViewChoice,
|
||||
).stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
fun setHasPermission(granted: Boolean) {
|
||||
permissionReported.value = granted
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the wizard for an install that has yet to answer the reminder step,
|
||||
* whatever route the grant took. Gating this on the in-app grant would
|
||||
* strand anyone who granted on Android's own settings screen and came back
|
||||
* to a restarted process, leaving them the reminder step alone.
|
||||
*/
|
||||
private suspend fun armWizardIfFresh() {
|
||||
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the in-app grant. The launch scan ran before it and bailed out
|
||||
* without arming anything, so the first alarm would otherwise wait on the
|
||||
* daily worker.
|
||||
*/
|
||||
fun onPermissionGranted() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
|
||||
/** Close the reminder step, recording whether notifications stay on. */
|
||||
fun finishReminders(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-ask the step before the current one, by clearing its answer. The plan
|
||||
* derives the position from those answers, so un-setting one *is* the back
|
||||
* navigation. The calendar grant belongs to the system and cannot be
|
||||
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
||||
*/
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
||||
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
||||
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
||||
OnboardingStep.Permission, null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the backup step without setting anything up. */
|
||||
fun skipBackup() {
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn automatic backup on, writing to the folder the user just picked
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
* close the step.
|
||||
*/
|
||||
fun enableAutoBackup(folder: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
folder,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
prefs.setAutoBackupFolderUri(folder.toString())
|
||||
prefs.setAutoBackupEnabled(true)
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = true,
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = true,
|
||||
)
|
||||
// Immediate feedback: the first backup lands while onboarding runs.
|
||||
BackupScheduler.runNow(context)
|
||||
prefs.setOnboardingBackupDone()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||
}
|
||||
|
||||
/** Close a picker step — the choice itself is saved as it is made. */
|
||||
fun finishView() {
|
||||
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||
}
|
||||
|
||||
fun finishMonthStyle() {
|
||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||
}
|
||||
|
||||
/** Acknowledge the calendar-visibility notice (#75). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingVisibilityDone()
|
||||
// Retiring the notice drops its step, so it waits until nothing
|
||||
// follows — the count must not fall on the way to a later screen.
|
||||
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the wizard from its last screen. */
|
||||
fun finishOnboarding() {
|
||||
viewModelScope.launch {
|
||||
prefs.finishOnboardingWizard()
|
||||
retireVisibilityNotice()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the one-time visibility notice away for good — nothing else clears it
|
||||
* now that the wizard owns it, and left standing it would keep its step in
|
||||
* every later plan.
|
||||
*/
|
||||
private suspend fun retireVisibilityNotice() {
|
||||
if (calendarPrefs.visibilityNoticePending.first() == true) {
|
||||
calendarPrefs.setVisibilityNoticePending(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the retired app-local calendar visibility into the device's own, and
|
||||
* arm the notice if this install has anything to announce. Runs whenever the
|
||||
* app comes up holding the permission, however it was granted — one taken on
|
||||
* Android's app-settings screen never reaches the permission step's callback.
|
||||
*/
|
||||
fun reconcileCalendarVisibility() {
|
||||
viewModelScope.launch { visibilityReconciler.run() }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored answers the plan is derived from. */
|
||||
private data class OnboardingFlags(
|
||||
val remindersDone: Boolean,
|
||||
val wizardArmed: Boolean,
|
||||
val backupDone: Boolean,
|
||||
val viewDone: Boolean,
|
||||
val monthStyleDone: Boolean,
|
||||
)
|
||||
|
||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
val visibilityDone: Boolean,
|
||||
val doneShown: Boolean,
|
||||
)
|
||||
|
||||
/** What the view step starts from, and renders its previews with. */
|
||||
data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val weekStart: WeekStartPref,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this device holds events that live nowhere else: at least one local
|
||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||
* A read-only subscription is not a backup, so it does not count.
|
||||
*/
|
||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||
return exportable && !syncedTarget
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -10,19 +10,15 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -32,13 +28,11 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -52,6 +46,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
||||
fun PermissionScreen(
|
||||
onGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
viewModel: PermissionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -76,6 +71,7 @@ fun PermissionScreen(
|
||||
when (state) {
|
||||
is PermissionUiState.Rationale -> RationaleContent(
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Denied -> DeniedContent(
|
||||
@@ -83,6 +79,7 @@ fun PermissionScreen(
|
||||
viewModel.onRetry()
|
||||
launcher.launch(CALENDAR_PERMISSIONS)
|
||||
},
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Granted -> {
|
||||
@@ -94,11 +91,14 @@ fun PermissionScreen(
|
||||
@Composable
|
||||
private fun RationaleContent(
|
||||
onRequest: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = false) },
|
||||
progress = progress,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero() },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onRequest,
|
||||
@@ -116,16 +116,8 @@ private fun RationaleContent(
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
PrivacyFootnote()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_rationale_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -139,14 +131,8 @@ private fun RationaleContent(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = stringResource(R.string.permission_benefit_private_title),
|
||||
body = stringResource(R.string.permission_benefit_private_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.CalendarMonth,
|
||||
title = stringResource(R.string.permission_benefit_sync_title),
|
||||
@@ -154,9 +140,9 @@ private fun RationaleContent(
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
||||
body = stringResource(R.string.permission_benefit_privacy_body),
|
||||
icon = Icons.Filled.Lock,
|
||||
title = stringResource(R.string.permission_benefit_private_title),
|
||||
body = stringResource(R.string.permission_benefit_private_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,12 +150,14 @@ private fun RationaleContent(
|
||||
@Composable
|
||||
private fun DeniedContent(
|
||||
onRetry: () -> Unit,
|
||||
progress: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BrandHero(denied = true) },
|
||||
progress = progress,
|
||||
hero = { BrandHero(HeroBadge.Denied) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -208,25 +196,3 @@ private fun DeniedContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrivacyFootnote() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_privacy_footnote),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
sealed interface PermissionUiState {
|
||||
data object Rationale : PermissionUiState
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
@@ -8,34 +8,25 @@ import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* One-time onboarding step after the calendar grant (v1.4): explains that
|
||||
* Wizard step after the calendar grant (v1.4): explains that
|
||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||
* when a second calendar app has notifications on, and requests
|
||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||
@@ -45,9 +36,11 @@ import de.jeanlucmakiola.calendula.R
|
||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderOnboardingScreen(
|
||||
fun ReminderStep(
|
||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
@@ -55,7 +48,10 @@ fun ReminderOnboardingScreen(
|
||||
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
hero = { BellHero() },
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -80,13 +76,6 @@ fun ReminderOnboardingScreen(
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
letterSpacing = 2.sp,
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -100,7 +89,7 @@ fun ReminderOnboardingScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.NotificationsActive,
|
||||
@@ -113,30 +102,5 @@ fun ReminderOnboardingScreen(
|
||||
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||
)
|
||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||
BenefitRow(
|
||||
icon = Icons.Filled.Tune,
|
||||
title = stringResource(R.string.reminder_benefit_reversible_title),
|
||||
body = stringResource(R.string.reminder_benefit_reversible_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
||||
@Composable
|
||||
private fun BellHero() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.NotificationsActive,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
* Wizard step: which view the app opens on (#163). Built like the Settings
|
||||
* picker it mirrors — a live preview over connected grouped rows. Selecting
|
||||
* applies immediately and leaves the step open; the preview is the confirmation.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewStep(
|
||||
choice: ViewChoice,
|
||||
onSelect: (CalendarView) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||
|
||||
StepPickerScaffold(
|
||||
title = stringResource(R.string.onboarding_view_title),
|
||||
onFinished = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.defaultView) { view ->
|
||||
when (view) {
|
||||
CalendarView.Month -> MonthStylePreview(
|
||||
style = choice.monthViewStyle,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(view) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard step: how the Month view lays itself out (#163). The same shape as
|
||||
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
|
||||
* style's own blurb sitting under its preview rather than on every row.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStyleStep(
|
||||
choice: ViewChoice,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||
val options = MonthViewStyle.entries
|
||||
|
||||
StepPickerScaffold(
|
||||
title = stringResource(R.string.onboarding_month_style_title),
|
||||
onFinished = onFinished,
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
) {
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
}
|
||||
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == choice.monthViewStyle
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell both picker steps share: the wizard chrome around a title and a
|
||||
* full-bleed column, since grouped rows carry their own inset.
|
||||
*/
|
||||
@Composable
|
||||
private fun StepPickerScaffold(
|
||||
title: String,
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.xs,
|
||||
contentPadding = 0.dp,
|
||||
// The preview gets the room it needs; Continue scrolls in under the
|
||||
// options rather than pinning to the bottom.
|
||||
scrollingActions = true,
|
||||
hero = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
// Full-bleed step: the button takes the grouped rows' own inset
|
||||
// and ends up as wide as the options above it.
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_view_continue_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The live preview, framed like the picker's. The wider gap below keeps the rows
|
||||
* reading as a group of their own rather than as the preview's caption.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
|
||||
ViewPreviewFrame(
|
||||
selected = selected,
|
||||
label = "step-preview",
|
||||
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** The same size the Settings chooser gives it. */
|
||||
private val PREVIEW_HEIGHT: Dp = 280.dp
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
/**
|
||||
* Wizard step for the one-time notice that Calendula now follows the device's
|
||||
* own per-calendar visibility and some of yours are switched off (#75, #163).
|
||||
*
|
||||
* Nothing to decide here — the app does not switch those calendars back on,
|
||||
* since that would un-hide them everywhere else too.
|
||||
*/
|
||||
@Composable
|
||||
internal fun VisibilityStep(
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
navigationIcon: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
OnboardingScaffold(
|
||||
modifier = modifier,
|
||||
progress = progress,
|
||||
navigationIcon = navigationIcon,
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_message),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
||||
* grant. [onboardingDone] is null until DataStore's first emission so the
|
||||
* step neither flashes for users who completed it nor gets skipped.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ReminderOnboardingViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val scanner: ReminderScanner,
|
||||
) : ViewModel() {
|
||||
|
||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
||||
.map { done -> done as Boolean? }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Close the step, recording whether reminder notifications stay on. */
|
||||
fun finish(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
||||
* before the grant and bails out without arming anything, so without this
|
||||
* the first alarm waits on the daily worker.
|
||||
*/
|
||||
fun rearmAfterGrant() {
|
||||
scanner.scanInBackground()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant as JavaInstant
|
||||
@@ -69,11 +111,13 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/**
|
||||
* Full-text event search (top-bar entry). Type a query → matching events
|
||||
* (title / location / description) across the whole calendar, newest-relevant
|
||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
* Full-text event search over title / location / description: tap a result to
|
||||
* open its detail, long-press to select several and delete them at once (#80).
|
||||
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -85,63 +129,79 @@ fun SearchScreen(
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val inSelection = selection.isNotEmpty()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
||||
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
||||
// stays composed under the detail), so backing out keeps the query.
|
||||
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
||||
// search, its selection and any delete receipt still up. Peeking a result
|
||||
// doesn't re-run this — the screen stays composed under the detail.
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.setQuery("")
|
||||
viewModel.clearSelection()
|
||||
viewModel.consumeDeleteResult()
|
||||
}
|
||||
// The field only exists out of selection mode, so a recreation that lands
|
||||
// mid-selection has to wait for the clear above before it can be focused.
|
||||
var focusPending by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(inSelection) {
|
||||
if (inSelection || !focusPending) return@LaunchedEffect
|
||||
focusPending = false
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
// back. The two handlers are mutually exclusive on [inSelection].
|
||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
SearchTopBar(
|
||||
inSelection = inSelection,
|
||||
query = query,
|
||||
selectedCount = selection.size,
|
||||
focusRequester = focusRequester,
|
||||
onQueryChange = viewModel::setQuery,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
// imePadding shrinks the content by the keyboard, so the centered
|
||||
// idle/empty message re-centres in the space above it (and the results
|
||||
// list lifts clear of the keyboard too).
|
||||
// imePadding so the idle/empty message re-centres above the keyboard and
|
||||
// the results list lifts clear of it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -158,52 +218,314 @@ fun SearchScreen(
|
||||
text = stringResource(R.string.search_empty, s.query),
|
||||
)
|
||||
is SearchUiState.Results -> SearchResults(
|
||||
events = s.events,
|
||||
results = s,
|
||||
selection = selection,
|
||||
inSelection = inSelection,
|
||||
onEventClick = onEventClick,
|
||||
onToggle = viewModel::toggleSelection,
|
||||
)
|
||||
}
|
||||
DeleteOutcomeChip(
|
||||
deleteState = deleteState,
|
||||
onConsume = viewModel::consumeDeleteResult,
|
||||
modifier = Modifier.align(Alignment.BottomStart),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
events: List<EventInstance>,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = events,
|
||||
key = { _, event -> event.eventId },
|
||||
) { index, event ->
|
||||
SearchResultRow(
|
||||
event = event,
|
||||
position = positionOf(index, events.size),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
if (showDeleteDialog) {
|
||||
val count = selection.size
|
||||
// One decision for the whole batch: the scope question is only asked when
|
||||
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||
if (viewModel.selectionHasRecurring()) {
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_delete_recurring_title),
|
||||
onSelect = { scope ->
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(scope)
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = {
|
||||
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||
},
|
||||
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteDialog = false
|
||||
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The search field and the contextual selection bar as one bar: the bar stays
|
||||
* in place across the swap, easing its colour and fading its slots through.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SearchTopBar(
|
||||
inSelection: Boolean,
|
||||
query: String,
|
||||
selectedCount: Int,
|
||||
focusRequester: FocusRequester,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onCloseSelection: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val container by animateColorAsState(
|
||||
targetValue = if (inSelection) {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "search-top-bar-color",
|
||||
)
|
||||
// Held past the selection emptying, so the count has something to draw while
|
||||
// it fades out.
|
||||
var lastCount by remember { mutableIntStateOf(0) }
|
||||
if (selectedCount > 0) lastCount = selectedCount
|
||||
|
||||
// Read here so the non-composable transitionSpec lambdas can capture it.
|
||||
val slotSwap = fadeThrough()
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-title",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.search_selected_count,
|
||||
lastCount,
|
||||
lastCount,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = onImeAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-navigation",
|
||||
) { selecting ->
|
||||
if (selecting) {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_selection_close),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.search_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedContent(
|
||||
targetState = inSelection,
|
||||
transitionSpec = { slotSwap },
|
||||
label = "search-top-bar-actions",
|
||||
) { selecting ->
|
||||
Row {
|
||||
if (selecting) {
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(R.string.search_select_all),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(
|
||||
R.string.search_delete_selected,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.search_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResults(
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// No horizontal inset: GroupedRow already insets itself.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
results.results.months.forEach { month ->
|
||||
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||
SearchSectionHeader(text = monthLabel(month))
|
||||
}
|
||||
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||
}
|
||||
// Held back from the calendar above: matched only in the notes.
|
||||
if (results.results.inDescriptions.isNotEmpty()) {
|
||||
stickyHeader(key = "in-descriptions") {
|
||||
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||
}
|
||||
hitRows(
|
||||
results.results.inDescriptions,
|
||||
results,
|
||||
selection,
|
||||
inSelection,
|
||||
onEventClick,
|
||||
onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One section's rows, sharing a grouped-card run. */
|
||||
private fun LazyListScope.hitRows(
|
||||
hits: List<SearchHit>,
|
||||
results: SearchUiState.Results,
|
||||
selection: Set<Long>,
|
||||
inSelection: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onToggle: (Long) -> Unit,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = hits,
|
||||
key = { _, hit -> hit.event.eventId },
|
||||
) { index, hit ->
|
||||
val event = hit.event
|
||||
val deletable = results.isDeletable(event)
|
||||
SearchResultRow(
|
||||
hit = hit,
|
||||
position = positionOf(index, hits.size),
|
||||
modifier = animateItemMotion(),
|
||||
selected = event.eventId in selection,
|
||||
// A read-only calendar's row can't join a batch, so in selection
|
||||
// mode it goes quiet rather than offering a checkbox that fails.
|
||||
inSelection = inSelection,
|
||||
selectable = deletable,
|
||||
onClick = when {
|
||||
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||
inSelection -> null
|
||||
else -> ({ onEventClick(event) })
|
||||
},
|
||||
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pinned section label, styled like the agenda's day headers. */
|
||||
@Composable
|
||||
private fun SearchSectionHeader(text: String) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "August 2026" in the locale's own field order. */
|
||||
@Composable
|
||||
private fun monthLabel(month: SearchMonth): String {
|
||||
val locale = currentLocale()
|
||||
return remember(month.year, month.monthNumber, locale) {
|
||||
localizedDateFormatter(locale, "LLLLy")
|
||||
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
event: EventInstance,
|
||||
hit: SearchHit,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
selected: Boolean = false,
|
||||
inSelection: Boolean = false,
|
||||
selectable: Boolean = true,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val event = hit.event
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
// On a picked row the headline is already recoloured for the secondary
|
||||
// container, so the match is carried by weight alone there.
|
||||
val highlight = SpanStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = event.title,
|
||||
summary = searchSummary(event),
|
||||
// Faded like a past event anywhere else in the app — search reaches back
|
||||
// through the whole history.
|
||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||
summary = searchSummary(hit, highlight),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
selected = selected,
|
||||
dimmed = inSelection && !selectable,
|
||||
leading = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -212,21 +534,97 @@ private fun SearchResultRow(
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
trailing = if (inSelection && selectable) {
|
||||
{
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
||||
/**
|
||||
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
||||
* provider can't put a deleted event back.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(event: EventInstance): String {
|
||||
private fun DeleteOutcomeChip(
|
||||
deleteState: BulkDeleteUiState,
|
||||
onConsume: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val message = when (deleteState) {
|
||||
is BulkDeleteUiState.Done -> when {
|
||||
deleteState.failed > 0 -> stringResource(
|
||||
R.string.search_delete_partial,
|
||||
deleteState.deleted,
|
||||
deleteState.failed,
|
||||
)
|
||||
else -> pluralStringResource(
|
||||
R.plurals.search_delete_done,
|
||||
deleteState.deleted,
|
||||
deleteState.deleted,
|
||||
)
|
||||
}
|
||||
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
|
||||
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
|
||||
} else {
|
||||
stringResource(R.string.event_delete_write_denied)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
// Held past the state being consumed so the chip has something to draw while
|
||||
// it springs back out.
|
||||
val shown = remember { mutableStateOf("") }
|
||||
if (message != null && shown.value != message) shown.value = message
|
||||
|
||||
LaunchedEffect(deleteState) {
|
||||
if (message == null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onConsume()
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(visible = message != null, message = shown.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** [text] with every matched run emphasised. */
|
||||
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||
if (spans.isEmpty()) {
|
||||
AnnotatedString(text)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { addStyle(style, it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
||||
* description when that is where the query matched.
|
||||
*/
|
||||
@Composable
|
||||
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||
val event = hit.event
|
||||
val locale = currentLocale()
|
||||
val zone = remember { ZoneId.systemDefault() }
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
||||
// midnight and would name the day before west of UTC (#82). The clock time
|
||||
// below stays device-zone — it is only rendered for timed events.
|
||||
// From the span rule, not [start]: an all-day event sits at UTC midnight and
|
||||
// would name the day before west of UTC (#82).
|
||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
@@ -237,8 +635,23 @@ private fun searchSummary(event: EventInstance): String {
|
||||
} else {
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
return buildAnnotatedString {
|
||||
append("$dateText · $timeText")
|
||||
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
append(" · ")
|
||||
val offset = length
|
||||
append(location)
|
||||
hit.locationSpans.forEach {
|
||||
addStyle(highlight, offset + it.start, offset + it.end)
|
||||
}
|
||||
}
|
||||
hit.descriptionSnippet?.let { snippet ->
|
||||
append("\n")
|
||||
val offset = length
|
||||
append(snippet.text)
|
||||
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,6 +6,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,8 +44,30 @@ sealed interface SearchUiState {
|
||||
/** A query ran but matched nothing. */
|
||||
data class Empty(val query: String) : SearchUiState
|
||||
|
||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
||||
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||
data class Results(
|
||||
val results: SearchResults,
|
||||
/** Rows from these are kept out of selection rather than failing later. */
|
||||
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||
) : SearchUiState {
|
||||
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||
|
||||
fun isDeletable(event: EventInstance): Boolean =
|
||||
event.calendarId !in readOnlyCalendarIds
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of deleting the selected results (#80). */
|
||||
sealed interface BulkDeleteUiState {
|
||||
data object Idle : BulkDeleteUiState
|
||||
|
||||
data object Deleting : BulkDeleteUiState
|
||||
|
||||
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||
|
||||
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
||||
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = _query
|
||||
.debounce(250L)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||
private val _reload = MutableStateFlow(0)
|
||||
|
||||
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||
|
||||
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = combine(
|
||||
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||
_reload,
|
||||
) { q, _ -> q }
|
||||
.mapLatest { q ->
|
||||
if (q.length < MIN_QUERY_LENGTH) {
|
||||
SearchUiState.Idle
|
||||
} else {
|
||||
val results = repository.searchEvents(q)
|
||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
||||
else SearchUiState.Results(sortNearestFirst(results))
|
||||
val now = Clock.System.now()
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val results = EventSearch.search(
|
||||
candidates = repository.searchEvents(q),
|
||||
query = q,
|
||||
now = now,
|
||||
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||
zone = zone,
|
||||
)
|
||||
if (results.isEmpty) {
|
||||
SearchUiState.Empty(q)
|
||||
} else {
|
||||
SearchUiState.Results(
|
||||
results = results,
|
||||
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { emit(SearchUiState.Idle) }
|
||||
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||
|
||||
fun setQuery(value: String) {
|
||||
if (value != _query.value) _selection.value = emptySet()
|
||||
_query.value = value
|
||||
}
|
||||
|
||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
||||
val now = Clock.System.now()
|
||||
val (upcoming, past) = events.partition { it.end >= now }
|
||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||
fun toggleSelection(eventId: Long) {
|
||||
val current = _selection.value
|
||||
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||
}
|
||||
|
||||
/** Pick every result that lives in a writable calendar. */
|
||||
fun selectAll() {
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||
}
|
||||
|
||||
/** Leave selection mode. */
|
||||
fun clearSelection() {
|
||||
_selection.value = emptySet()
|
||||
}
|
||||
|
||||
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
||||
fun selectionHasRecurring(): Boolean {
|
||||
val results = state.value as? SearchUiState.Results ?: return false
|
||||
val picked = _selection.value
|
||||
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every selected event. [scope] reaches the recurring ones only; a
|
||||
* one-off always goes whole. One at a time, so a single failure doesn't take
|
||||
* the rest with it — the tally lands in [deleteState].
|
||||
*/
|
||||
fun deleteSelected(scope: RecurringWriteScope) {
|
||||
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||
val results = state.value as? SearchUiState.Results ?: return
|
||||
val picked = _selection.value
|
||||
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||
if (targets.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = BulkDeleteUiState.Deleting
|
||||
var deleted = 0
|
||||
var failed = 0
|
||||
var denied = false
|
||||
for (event in targets) {
|
||||
try {
|
||||
withContext(io) { deleteOne(event, scope) }
|
||||
deleted++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
denied = true
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
_selection.value = emptySet()
|
||||
_reload.value += 1
|
||||
_deleteState.value = if (denied) {
|
||||
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
||||
} else {
|
||||
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset [deleteState] after the screen showed the outcome. */
|
||||
fun consumeDeleteResult() {
|
||||
_deleteState.value = BulkDeleteUiState.Idle
|
||||
}
|
||||
|
||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||
val begin = event.start.toEpochMilliseconds()
|
||||
when {
|
||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(event.eventId, begin)
|
||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||
else -> repository.deleteEvent(event.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||
repository.calendars().first()
|
||||
.filterNot { it.canModifyContents }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
@@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
|
||||
/**
|
||||
@@ -51,39 +39,21 @@ internal fun MonthViewStylePicker(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = MonthViewStyle.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "month-style-preview",
|
||||
) { shown ->
|
||||
// The preview renders the real grid at phone size and scales it
|
||||
// down, so its own corners are square — the frame rounds it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
ViewPreviewFrame(
|
||||
selected = selected,
|
||||
label = "month-style-preview",
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) { shown ->
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
// The selected style's own blurb lives here, under its preview, rather
|
||||
// than on every row — the rows stay single-line and dense, and the words
|
||||
@@ -109,4 +79,3 @@ internal fun MonthViewStylePicker(
|
||||
}
|
||||
|
||||
private val PREVIEW_HEIGHT = 280.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@@ -302,13 +302,14 @@ private fun ReportProblemRow(position: Position) {
|
||||
@Composable
|
||||
private fun LanguageRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||
// row updates instantly even before the recreation lands.
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
||||
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
val options = remember(supported) { listOf<String?>(null) + supported }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
|
||||
@@ -42,6 +42,8 @@ data class SettingsUiState(
|
||||
val showWeekNumbers: Boolean = false,
|
||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||
val todayButtonInToolbar: Boolean = false,
|
||||
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||
val dragToReschedule: Boolean = true,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
|
||||
@@ -145,9 +145,15 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
prefs.todayButtonInToolbar,
|
||||
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||
combine(
|
||||
prefs.todayButtonInToolbar,
|
||||
prefs.dragToReschedule,
|
||||
::Pair,
|
||||
),
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
||||
DisplayToggles(
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
||||
)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
@@ -157,6 +163,7 @@ class SettingsViewModel @Inject constructor(
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
dragToReschedule = toggles.dragToReschedule,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -189,6 +196,7 @@ class SettingsViewModel @Inject constructor(
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
dragToReschedule = views.dragToReschedule,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -289,6 +297,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
@@ -297,6 +306,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -534,6 +544,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||
}
|
||||
|
||||
fun setDragToReschedule(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||
* on what the component state reports; the writes are binder round-trips and
|
||||
|
||||
@@ -114,7 +114,7 @@ internal fun ViewsScreen(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
@@ -123,6 +123,18 @@ internal fun ViewsScreen(
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dragToReschedule,
|
||||
onCheckedChange = viewModel::setDragToReschedule,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_month_header))
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/**
|
||||
@@ -46,38 +34,22 @@ internal fun WeekStartPicker(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val resolved = selected.resolveFirstDay(locale)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = resolved,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "week-start-preview",
|
||||
) { day ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
ViewPreviewFrame(
|
||||
selected = resolved,
|
||||
label = "week-start-preview",
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) { day ->
|
||||
MonthStylePreview(
|
||||
style = monthStyle,
|
||||
weekStart = day,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||
options.forEachIndexed { index, option ->
|
||||
@@ -107,4 +79,3 @@ internal fun WeekStartPicker(
|
||||
|
||||
/** Short enough to leave the first options on screen under it. */
|
||||
private val PREVIEW_HEIGHT = 200.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@@ -47,10 +47,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -62,13 +64,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -84,6 +90,22 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
@@ -104,9 +126,13 @@ import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
@@ -121,16 +147,13 @@ import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
if (allDaySpans.isEmpty()) return 0.dp
|
||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||
@@ -331,44 +354,69 @@ private fun WeekContent(
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
val took = move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
// Refused, so nothing will land: let the copy go now
|
||||
// rather than hold it out for a settle that never comes.
|
||||
if (took != true) dragController.release()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekSuccess(
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -385,8 +433,10 @@ private fun WeekSuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -458,7 +508,7 @@ private fun WeekDayHeader(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 8.dp),
|
||||
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||
) {
|
||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||
// badge lines up vertically with the date numbers. The start inset centres
|
||||
@@ -554,7 +604,11 @@ private fun AllDayStrip(
|
||||
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||
// padding sits inside it so the content area is lanes * row height.
|
||||
.height(height)
|
||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
||||
.padding(
|
||||
top = ALL_DAY_VERTICAL_PADDING,
|
||||
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||
end = TIMELINE_CONTENT_END_INSET,
|
||||
),
|
||||
) {
|
||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
@@ -614,6 +668,7 @@ private fun AllDayBar(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -622,13 +677,15 @@ private fun AllDayBar(
|
||||
private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
||||
// timeline's own viewport height, which is only known here — below the top
|
||||
@@ -648,45 +705,40 @@ private fun Timeline(
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
@@ -695,8 +747,10 @@ private fun Timeline(
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
@@ -715,13 +769,25 @@ private fun DayColumnCard(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -741,30 +807,48 @@ private fun DayColumnCard(
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
detectTapGestures { offset ->
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
onCreateAt(date, hour * 60)
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// a column holding two occurrences of one series.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
width = laneWidth,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
width = place.width,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
@@ -780,7 +864,10 @@ private fun EventBlock(
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -823,12 +910,35 @@ private fun EventBlock(
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
// A block clipped at the top continues from the previous day: its top edge
|
||||
// is midnight, not the event's start, so dragging it would invent a time.
|
||||
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
@@ -838,14 +948,12 @@ private fun EventBlock(
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
@@ -878,7 +986,12 @@ private fun WeekLoading() {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||
// columns don't resize the moment the real week arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* A live, scaled-down Week view for the onboarding view chooser. The week and
|
||||
* today's column are real; only the events are stand-ins.
|
||||
*
|
||||
* The timeline is scrolled to the working day rather than to midnight — the
|
||||
* live view centres on noon once it knows its own scroll range, which a preview
|
||||
* that is never interacted with would otherwise never do.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WeekViewPreview(
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
// Half the scroll range is noon — the same centring the live view does.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sample week state, laid out through the same helpers the live view uses. */
|
||||
private fun sampleWeekState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): WeekUiState.Success {
|
||||
val start = today.startOfWeek(weekStart)
|
||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||
val events = sampleTimelineEvents(days, today, zone)
|
||||
return WeekUiState.Success(
|
||||
weekStart = start,
|
||||
today = today,
|
||||
days = days,
|
||||
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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`, which is not an exempted
|
||||
* implicit broadcast — a manifest-declared receiver has not been given it since
|
||||
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
||||
*
|
||||
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
||||
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||
* survives doze (plain `set` does not), a rollover a few minutes late is
|
||||
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
||||
*/
|
||||
object WidgetRolloverScheduler {
|
||||
|
||||
/**
|
||||
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
||||
* would still read the old date and re-arm for an instant later.
|
||||
*/
|
||||
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].
|
||||
*
|
||||
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
||||
* midnight never happens (Havana) and where a date-line move skips a whole
|
||||
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
||||
*
|
||||
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
||||
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
||||
* No live tz entry does that (Brazil dropped DST in 2019) and
|
||||
* `updatePeriodMillis` covers it, so it isn't worth 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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
|
||||
@@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||
}
|
||||
GlanceTheme(colors = colors, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
||||
* reads the same on the home screen as it does inside the app (#180).
|
||||
*/
|
||||
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
|
||||
|
||||
@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
||||
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
||||
* midnight rollover alarm armed. 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.
|
||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
||||
* and the upcoming window roll over at midnight / on a clock change.
|
||||
* - [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` — the day boundary moved, so redraw *and*
|
||||
* re-arm.
|
||||
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
||||
* latter is also what arms installs upgrading into the fix.
|
||||
*
|
||||
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
||||
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
||||
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
||||
* provider XML and the month widget's refresh button.
|
||||
*
|
||||
* Exported for the system broadcasts; an extra redraw from another app is
|
||||
* harmless.
|
||||
*/
|
||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
// Exported, so anything can reach it with an explicit intent. Nothing
|
||||
// here crosses a trust boundary, but narrowing to the actions we 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, so the next day boundary is covered whatever the redraw
|
||||
// does. Every handled action either dropped, consumed or invalidated it.
|
||||
WidgetRolloverScheduler.sync(appContext)
|
||||
// The host sends APPWIDGET_UPDATE after both of these anyway, so
|
||||
// redrawing here would only repeat the work in a cold process, at the
|
||||
// moment the device is most contended.
|
||||
if (intent.action in REARM_ONLY_ACTIONS) return
|
||||
val pending = goAsync()
|
||||
// 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.
|
||||
@@ -38,4 +59,23 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -24,7 +25,6 @@ import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
@@ -74,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
@@ -113,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
||||
*/
|
||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
@@ -135,17 +135,23 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
||||
/**
|
||||
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
||||
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
||||
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
||||
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
||||
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
||||
*/
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().updateAll(context.applicationContext)
|
||||
AgendaWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||
* stays well inside the binder transaction limit regardless of range and calendar
|
||||
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
|
||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||
* risking a failed update for.
|
||||
*/
|
||||
@@ -416,7 +422,11 @@ private fun EventRow(
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||
style = TextStyle(
|
||||
color = titleColor,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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
|
||||
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
*/
|
||||
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] cancels only if
|
||||
* no month widget is left either.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,7 +26,6 @@ import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
@@ -33,6 +33,7 @@ import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
@@ -97,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*
|
||||
* Everything here is written to keep the published `RemoteViews` small. A widget
|
||||
* update reaches the launcher as a *oneway* binder call, and the host's async
|
||||
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
|
||||
* the system drops the whole host, freezing every widget on the home screen until
|
||||
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||
* row here emits only what it fills.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
/**
|
||||
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||
* Exact costs one serialised copy per host size, which is affordable now that
|
||||
* a rendering is ~190 views rather than ~740.
|
||||
*/
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
@@ -118,15 +136,35 @@ class MonthWidget : GlanceAppWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Step the displayed month by the `delta` action parameter (±1). */
|
||||
/**
|
||||
* Step the displayed month by the `delta` action parameter (±1).
|
||||
*
|
||||
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
|
||||
* never `updateAll`. `updateAll` resolves its targets through the provider-name
|
||||
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
|
||||
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
|
||||
* comes back empty and the redraw is dropped: the state write lands, nothing
|
||||
* recomposes, and the arrows read as dead until something else opens the app and
|
||||
* starts a session (#18). The tapped widget's own id needs no lookup.
|
||||
*/
|
||||
class ShiftMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
||||
val next = cur + delta
|
||||
// Landing back on the current month clears the key, so the widget
|
||||
// goes back to *following* the date rather than being pinned to
|
||||
// whichever month was current at the tap. Paging out and back is the
|
||||
// workaround #228's reporter used, and pinning it there would have
|
||||
// stuck them on that month once it stopped being the current one.
|
||||
if (next == currentMonthIndex(systemZone())) {
|
||||
prefs.remove(MONTH_INDEX_KEY)
|
||||
} else {
|
||||
prefs[MONTH_INDEX_KEY] = next
|
||||
}
|
||||
}
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
MonthWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -134,11 +172,11 @@ class ShiftMonthAction : ActionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump the displayed month back to the current month. */
|
||||
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
||||
class ResetMonthAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||
MonthWidget().updateAll(context.applicationContext)
|
||||
MonthWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,29 +324,75 @@ private fun WeekRow(
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Day numbers.
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
val hidden = week.hiddenPerDay()
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||
// every cell in the column carrying a click target of its own. The content
|
||||
// above is not clickable, so touches fall through to this layer; only an
|
||||
// event bar opts out to open its own detail.
|
||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.fillMaxHeight()
|
||||
.clickable(openDayAction(context, date)),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
// Day numbers. Fixed height so every week's number row lines up whether
|
||||
// or not it holds today's larger pill.
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// Lanes past the last one this week fills emit nothing at all.
|
||||
repeat(week.usedLanes()) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||
}
|
||||
OverflowRow(week = week, colW = colW)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||
* per day and most weeks leave the last two empty.
|
||||
*/
|
||||
private fun MonthWeek.usedLanes(): Int {
|
||||
for (lane in MAX_LANES - 1 downTo 0) {
|
||||
val filled = spans.any { it.lane == lane } ||
|
||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||
if (filled) return lane + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
@@ -318,64 +402,93 @@ private fun WeekRow(
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
/**
|
||||
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||
* Today keeps its filled circle, which does need the Box.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!isToday) {
|
||||
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||
return
|
||||
}
|
||||
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||
.background(GlanceTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
Text(text = date.day.toString(), style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||
|
||||
/**
|
||||
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||
*/
|
||||
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
fun closeGap() {
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
gap = 0
|
||||
}
|
||||
while (col < 7) {
|
||||
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||
when {
|
||||
span != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||
col = span.endCol + 1
|
||||
}
|
||||
timed != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(timed, 1)
|
||||
col += 1
|
||||
}
|
||||
else -> {
|
||||
gap += 1
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
closeGap()
|
||||
return cells
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||
col = span.endCol + 1
|
||||
week.laneCells(lane).forEach { cell ->
|
||||
if (cell.event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
}
|
||||
col += 1
|
||||
SpanBar(
|
||||
event = cell.event,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = colW * cell.cols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,41 +527,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||
style = TextStyle(
|
||||
color = ColorProvider(eventInk(fill)),
|
||||
fontSize = 9.sp,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||
// The overflow row is part of the day column too: tapping it (whether
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = "+$hidden",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
var gap = 0
|
||||
hidden.forEach { count ->
|
||||
if (count == 0) {
|
||||
gap += 1
|
||||
return@forEach
|
||||
}
|
||||
if (gap > 0) {
|
||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
gap = 0
|
||||
}
|
||||
Text(
|
||||
text = "+$count",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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
|
||||
@@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
*/
|
||||
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] cancels only if
|
||||
* no agenda widget is left either.
|
||||
*/
|
||||
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 alarm's self-heal: anything that drops a
|
||||
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
|
||||
* freeze) is repaired here rather than on the next app open. Re-arming
|
||||
* closer to midnight also narrows the inexact delivery window.
|
||||
*/
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
|
||||
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
|
||||
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
|
||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
|
||||
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك.</string>
|
||||
<string name="permission_request_button">منح الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
|
||||
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
|
||||
<string name="permission_retry_button">أعِد المحاولة</string>
|
||||
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
|
||||
<string name="permission_benefit_private_title">يبقى على جهازك</string>
|
||||
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
|
||||
<string name="permission_benefit_private_title">الخصوصية حسب التصميم</string>
|
||||
<string name="permission_benefit_private_body">لا يوجد إذن بالإنترنت، ولا تتبع عن بُعد — لا شيء يغادر هاتفك أبدًا.</string>
|
||||
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
|
||||
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
|
||||
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
|
||||
@@ -32,13 +32,13 @@
|
||||
<string name="month_prev">الشهر السابق</string>
|
||||
<string name="month_next">الشهر القادم</string>
|
||||
<string name="month_today_action">اليوم</string>
|
||||
<string name="month_action_settings">إعدادات</string>
|
||||
<string name="month_action_settings">الإعدادات</string>
|
||||
<string name="settings_title">الإعدادات</string>
|
||||
<string name="settings_theme">الثيم</string>
|
||||
<string name="settings_theme_system">النظام</string>
|
||||
<string name="settings_theme_light">فاتح</string>
|
||||
<string name="settings_theme_dark">داكن</string>
|
||||
<string name="month_open_menu">افتح القائمة</string>
|
||||
<string name="month_open_menu">فتح القائمة</string>
|
||||
<string name="month_more_actions">المزيد من الإجراءات</string>
|
||||
<string name="event_detail_edit">تعديل</string>
|
||||
<string name="event_detail_delete">حذف</string>
|
||||
@@ -126,6 +126,7 @@
|
||||
<string name="event_edit_recurrence_none">لا يتكرر</string>
|
||||
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="zero">تم استيراد هذا الحدث مع (%1$d) لا تذكيرات.</item>
|
||||
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
|
||||
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
|
||||
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
|
||||
@@ -137,7 +138,7 @@
|
||||
<string name="event_detail_calendar">التقويم</string>
|
||||
<string name="event_detail_calendar_unknown">تقويم غير معروف</string>
|
||||
<string name="event_detail_description">الوصف</string>
|
||||
<string name="event_detail_all_day">كل يوم</string>
|
||||
<string name="event_detail_all_day">طوال اليوم</string>
|
||||
<string name="event_detail_location">الموقع</string>
|
||||
<string name="event_detail_attendees">الحضور</string>
|
||||
<string name="event_detail_recurrence">التكرار</string>
|
||||
@@ -175,9 +176,9 @@
|
||||
<string name="reminder_at_time">في وقت الحدث</string>
|
||||
<string name="event_untitled">(بدون عنوان)</string>
|
||||
<string name="reminder_channel_name">تذكيرات الحدث</string>
|
||||
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
|
||||
<string name="reminder_channel_description">الإشعارات في أوقات التذكير بأحداثك</string>
|
||||
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
|
||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_onboarding_body">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||
@@ -219,8 +220,8 @@
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم متناسقة</string>
|
||||
<string name="settings_soften_colors_summary">حافظ على درجة لون كل تقويم لكن قم بتسوية سطوعه، بحيث كل حدث يظل مقروءًا و تتناسق الألوان معًا. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
<string name="settings_font_headings">خط العناوين</string>
|
||||
<string name="settings_font_body">خط النص</string>
|
||||
<string name="settings_font_system">افتراضي النظام</string>
|
||||
@@ -237,22 +238,22 @@
|
||||
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
|
||||
<string name="agenda_range_day">اليوم</string>
|
||||
<string name="settings_agenda_show_today">إظهار اليوم دائمًا</string>
|
||||
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم.</string>
|
||||
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و أداته المصغرة، حتى بعد عدم وجود أي شيء اليوم.</string>
|
||||
<string name="agenda_range_this_month">هذا الشهر</string>
|
||||
<string name="agenda_range_this_week">هذا الأسبوع</string>
|
||||
<string name="settings_past_events_show">إظهار</string>
|
||||
<string name="settings_past_events_hide">إخفاء</string>
|
||||
<string name="settings_agenda_header">جدول</string>
|
||||
<string name="settings_agenda_header">الجدول</string>
|
||||
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
||||
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
|
||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق ”%1$s“</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
||||
<string name="event_edit_timezone_recent">الأحدث</string>
|
||||
<string name="event_edit_timezone_recent">الأخيرة</string>
|
||||
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق</item>
|
||||
<item quantity="one">(%d) دقيقة واحده</item>
|
||||
<item quantity="one">%d دقيقة واحده</item>
|
||||
<item quantity="two">%d دقيقتان</item>
|
||||
<item quantity="few">%d دقائق</item>
|
||||
<item quantity="many">%d دقيقة</item>
|
||||
@@ -260,7 +261,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="zero">(%d) لا ساعات</item>
|
||||
<item quantity="one">(%d) ساعة واحده</item>
|
||||
<item quantity="one">%d ساعة واحده</item>
|
||||
<item quantity="two">%d ساعتان</item>
|
||||
<item quantity="few">%d ساعات</item>
|
||||
<item quantity="many">%d ساعة</item>
|
||||
@@ -268,7 +269,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">(%d) يوم واحد</item>
|
||||
<item quantity="one">%d يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
@@ -276,7 +277,7 @@
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع</item>
|
||||
<item quantity="one">(%d) أسبوع واحد</item>
|
||||
<item quantity="one">%d أسبوع واحد</item>
|
||||
<item quantity="two">%d أسبوعان</item>
|
||||
<item quantity="few">%d أسابيع</item>
|
||||
<item quantity="many">%d أسبوع</item>
|
||||
@@ -285,13 +286,13 @@
|
||||
<string name="agenda_span_starts">يبدأ %1$s</string>
|
||||
<string name="agenda_span_ends">ينتهي %1$s</string>
|
||||
<string name="today_jump_action">الانتقال إلى اليوم</string>
|
||||
<string name="widget_refresh">تحديث</string>
|
||||
<string name="widget_refresh">إعادة تحميل</string>
|
||||
<string name="settings_app_name">اسم التطبيق</string>
|
||||
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
|
||||
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_past_events_dim">تعتيم</string>
|
||||
<string name="settings_past_events">الأحداث السابقة</string>
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
@@ -309,4 +310,399 @@
|
||||
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||
<string name="reminder_custom_set">تعيين</string>
|
||||
<string name="settings_autofocus_title_hint">عند بدء حدث جديد، ضع المؤشر في حقل العنوان وافتح لوحة المفاتيح على الفور.</string>
|
||||
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
|
||||
<string name="settings_section_event_form">نموذج حدث جديد</string>
|
||||
<string name="settings_quick_switch_header">زر التبديل السريع</string>
|
||||
<string name="settings_theme_system_summary">الحالي %1$s</string>
|
||||
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
|
||||
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
|
||||
<string name="settings_time_format_auto_summary">اتباع النظام: %1$s</string>
|
||||
<string name="settings_hour_lines">خطوط الساعات</string>
|
||||
<string name="settings_hour_lines_summary">إظهار خط فاصل في كل ساعة في طريقة عرض الأسبوع واليوم</string>
|
||||
<string name="timeline_scale_fit_day_summary">جميع الـ 24 ساعة على شاشة واحدة، لا تمرير</string>
|
||||
<string name="timeline_scale_custom">مخصص</string>
|
||||
<string name="settings_widget_size_small">صغير</string>
|
||||
<string name="settings_widget_size_medium">متوسط</string>
|
||||
<string name="settings_widget_size_large">كبير</string>
|
||||
<string name="settings_widget_size_extra_large">كبير جدًا</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">%d يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
<item quantity="other">%d يوم</item>
|
||||
</plurals>
|
||||
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل المدى للجلسة</string>
|
||||
<string name="settings_agenda_range_bar">شريط المدى</string>
|
||||
<string name="settings_month_header">عرض الشهر</string>
|
||||
<string name="month_style_continuous">تمرير الشهور</string>
|
||||
<string name="month_style_dense">أسابيع سلسة</string>
|
||||
<string name="month_style_continuous_summary">كل شهر يقع تحت عنوانه الخاص، مع مساحة صغيرة تميزه عن الشهر التالي.</string>
|
||||
<string name="month_style_dense_summary">تمر الأسابيع دون انقطاع، ويتدفق كل شهر مباشرة إلى الشهر التالي دون وجود فجوة بينهما.</string>
|
||||
<string name="month_split_no_events">لا شيء مجدول</string>
|
||||
<string name="month_style_split_summary">شبكة مدمجة تحدد الأيام التي تتضمن أحداثًا، ويتم إدراج اليوم الذي تنقر عليه تحتها.</string>
|
||||
<string name="settings_autofocus_title">تركيز العنوان على حدث جديد</string>
|
||||
<string name="settings_event_duration">المدة الافتراضية</string>
|
||||
<string name="settings_event_duration_hint">مدة الحدث الجديد تبقى كما هي حتى أنت تغيير وقت نهايته. أما أحداث التي هي طوال اليوم فلن تتأثر.</string>
|
||||
<string name="settings_section_notifications">الإشعارات</string>
|
||||
<string name="settings_reminders_hint">ترى التذكيرات مرتين؟ هناك تطبيق تقويم آخر ينشرهم أيضًا — قم بإيقافهم في أحد من الاثنين.</string>
|
||||
<string name="settings_group_look">المظهر و السلوك</string>
|
||||
<string name="settings_group_data">البيانات</string>
|
||||
<string name="settings_group_app">التطبيق</string>
|
||||
<string name="settings_language">لغة التطبيق</string>
|
||||
<string name="settings_dynamic_color_summary">أخذ ألوان التطبيق من خلفيتك.</string>
|
||||
<string name="settings_group_about">حول</string>
|
||||
<string name="settings_about_author">بواسطة Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">المصدر</string>
|
||||
<string name="settings_about_privacy">سياسة الخصوصية</string>
|
||||
<string name="settings_about_support">دَعم التطوير</string>
|
||||
<string name="settings_about_version">الإصدار %1$s</string>
|
||||
<string name="settings_about_logo_desc">رمز تطبيق Calendula</string>
|
||||
<string name="crash_report_body_template">شكرًا لإبلاغ عن عطل في %1$s. يُرجى إضافة أي شيء تتذكره حول ما كنت تفعله، ثم إرسال.\n\n### ماذا حدث\n\n\n### تقرير العطل\n%2$s\n</string>
|
||||
<string name="settings_section_about">حول</string>
|
||||
<string name="settings_report_problem">الإبلاغ عن مشكلة</string>
|
||||
<string name="settings_report_problem_hint">أرسل تقرير الأعطال أو افتح متعقب المشكلات</string>
|
||||
<string name="settings_language_auto">افتراضي النظام</string>
|
||||
<string name="settings_section_backup">النسخ الاحتياطي والاستعادة</string>
|
||||
<string name="settings_special_dates_enable">إظهار تواريخ جهات الاتصال</string>
|
||||
<string name="settings_special_dates_enable_hint">اعكس أعياد ميلاد جهات اتصالك والتواريخ الأخرى إلى التقويمات المحلية. يقرأ جهات الاتصال على هذا الجهاز فقط — لا يتم رفع أي شئ، وجهات اتصالك لا يتم تغييرها أبدًا.</string>
|
||||
<string name="settings_section_language">اللغة</string>
|
||||
<string name="qs_tile_new_event_label">حدث جديد</string>
|
||||
<string name="shortcut_new_event_long">إنشاء حدث جديد</string>
|
||||
<string name="shortcut_new_event_short">حدث جديد</string>
|
||||
<string name="settings_qs_tile_hint">أضف مربع ”حدث جديد“ إلى لوحة الإعدادات السريعة.</string>
|
||||
<string name="calendars_local_header">تقويماتك</string>
|
||||
<string name="settings_section_special_dates">تواريخ جهات الاتصال الخاصة</string>
|
||||
<string name="calendars_title">التقويمات</string>
|
||||
<string name="calendars_add">إضافة تقويم</string>
|
||||
<string name="settings_section_calendars">التقويمات</string>
|
||||
<string name="settings_manage_calendars">إدارة التقويمات</string>
|
||||
<string name="event_edit_recurrence_next">التالي: %1$s</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق قبل</item>
|
||||
<item quantity="one">%d دقيقة واحده قبل</item>
|
||||
<item quantity="two">%d دقيقتان قبل</item>
|
||||
<item quantity="few">%d دقائق قبل</item>
|
||||
<item quantity="many">%d دقيقة قبل</item>
|
||||
<item quantity="other">%d دقيقة قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="zero">(%d) لا ساعات قبل</item>
|
||||
<item quantity="one">%d ساعة واحده قبل</item>
|
||||
<item quantity="two">%d ساعتان قبل</item>
|
||||
<item quantity="few">%d ساعات قبل</item>
|
||||
<item quantity="many">%d ساعة قبل</item>
|
||||
<item quantity="other">%d ساعة قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="zero">(%d) لا أيام قبل</item>
|
||||
<item quantity="one">%d يوم واحد قبل</item>
|
||||
<item quantity="two">%d يومان قبل</item>
|
||||
<item quantity="few">%d أيام قبل</item>
|
||||
<item quantity="many">%d يوم قبل</item>
|
||||
<item quantity="other">%d يوم قبل</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع قبل</item>
|
||||
<item quantity="one">%d أسبوع واحد قبل</item>
|
||||
<item quantity="two">%d أسبوعان قبل</item>
|
||||
<item quantity="few">%d أسابيع قبل</item>
|
||||
<item quantity="many">%d أسبوع قبل</item>
|
||||
<item quantity="other">%d أسبوع قبل</item>
|
||||
</plurals>
|
||||
<string name="reminder_benefit_reversible_body">يوجد مفتاح التبديل في الإعدادات، ضمن الإشعارات.</string>
|
||||
<string name="reminder_custom_amount">القيمة</string>
|
||||
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
|
||||
<string name="settings_snooze_duration">مدة التأجيل</string>
|
||||
<string name="settings_calendar_reminder_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_translate">المساعدة في الترجمة</string>
|
||||
<string name="settings_translate_hint">إضافة أو تحسين لغة على Weblate</string>
|
||||
<string name="settings_appearance_subtitle">الثيم، الألوان، الخطوط</string>
|
||||
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
|
||||
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
|
||||
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
|
||||
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و الذكرات السنوية</string>
|
||||
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
|
||||
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
|
||||
<string name="settings_license">الترخيص</string>
|
||||
<string name="settings_special_dates_disable_title">إيقاف تشغيل تواريخ جهات الاتصال؟</string>
|
||||
<string name="settings_special_dates_disable_confirm">إيقاف</string>
|
||||
<string name="settings_special_dates_sync_now">زامن الآن</string>
|
||||
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
|
||||
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات اتصالك، لذلك لا يتم تحديث هذه التقويمات.</string>
|
||||
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
|
||||
<string name="calendars_enable_all">تفعيل الكل</string>
|
||||
<string name="calendars_disable_all">تعطيل الكل</string>
|
||||
<string name="calendars_add_account">إضافة حساب</string>
|
||||
<string name="calendars_new_title">تقويم جديد</string>
|
||||
<string name="calendars_edit_title">تعديل التقويم</string>
|
||||
<string name="calendars_name_label">الاسم</string>
|
||||
<string name="calendars_color_label">اللون</string>
|
||||
<string name="calendars_description_hint">أضف وصفًا</string>
|
||||
<string name="calendars_delete_confirm_title">حذف التقويم؟</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" وجميع أحداثه ستتم إزالتها نهائيًا من هذا الجهاز.</string>
|
||||
<string name="calendars_write_error">تعذّر حفظ التغيير.</string>
|
||||
<string name="calendars_backup_header">النسخ الاحتياطي</string>
|
||||
<string name="calendars_backup_hint">التقويمات المحلية لا تتم مزامنتها في أي مكان، لذا قم بتصديرها إلى ملف .ics للاحتفاظ بنسخة.</string>
|
||||
<string name="dialog_save">حفظ</string>
|
||||
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في ملف .ics.</string>
|
||||
<string name="calendars_export_action">تصدير</string>
|
||||
<string name="calendars_restore_header">استعادة</string>
|
||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
|
||||
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
|
||||
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
|
||||
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
|
||||
<string name="calendars_auto_backup_every">كل %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">الحد الأدني ٣٠ دقيقة.</string>
|
||||
<string name="calendars_auto_backup_status_never">لا نسخ احتياطي تلقائي بعد</string>
|
||||
<string name="calendars_auto_backup_status_ok">آخر نسخة احتياطية: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">فشل آخر نسخ احتياطي: %1$s</string>
|
||||
<string name="backup_channel_name">النسخ الاحتياطي</string>
|
||||
<string name="backup_channel_description">يحذّر إذا تفشل النسخ الاحتياطي التلقائي بشكل متكرر.</string>
|
||||
<string name="backup_failed_title">فشل النسخ الاحتياطي التلقائي</string>
|
||||
<string name="backup_failed_text">Calendula تعذَّر في كتابة ملف النسخ الاحتياطي. تحقق من مجلد النسخ الاحتياطي في الإعدادات.</string>
|
||||
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
|
||||
<item quantity="one">تم تصدير %d حدث واحد.</item>
|
||||
<item quantity="two">تم تصدير %d حدثان.</item>
|
||||
<item quantity="few">تم تصدير %d أحداث.</item>
|
||||
<item quantity="many">تم تصدير %d حدث.</item>
|
||||
<item quantity="other">تم تصدير %d حدث.</item>
|
||||
</plurals>
|
||||
<string name="import_title">استيراد الأحداث</string>
|
||||
<string name="import_target_header">إضافة إلى تقويم</string>
|
||||
<string name="import_empty">لم يتم العثور على أحداث في هذا الملف.</string>
|
||||
<string name="import_failed">تعذَّر قراءة هذا الملف.</string>
|
||||
<string name="import_no_calendar">لا تقويم قابل للكتابة لاستيراد إليه. أنشئ تقويمًا محليًا أولاً.</string>
|
||||
<string name="import_done_title">اكتمل الاستيراد</string>
|
||||
<string name="import_done_dedup_note">تم تخطي الأحداث الموجودة بالفعل في التقويم.</string>
|
||||
<string name="settings_special_dates_reminders">التذكيرات</string>
|
||||
<string name="calendars_account_menu_a11y">المزيد من الخيارات لـ %1$s</string>
|
||||
<string name="calendars_synced_hint">تأتي هذه من الحسابات الموجودة على جهازك. يمكنك إنشاؤها وتعديلها في تطبيقها الخاص.</string>
|
||||
<string name="calendars_synced_header">التقويمات المتزامنة</string>
|
||||
<string name="calendars_local_empty">لا تقويمات محلية حتى الآن. أنشئ واحدًا للاحتفاظ بالأحداث على هذا الجهاز فقط.</string>
|
||||
<string name="calendars_auto_backup_interval">الفاصل الزمني</string>
|
||||
<string name="import_warning_no_start">تم تخطي حدث بدون وقت بدء.</string>
|
||||
<string name="import_warning_recurrence">تم تخطي بعض التكرارات التي تم تغييرها للأحداث المتكررة.</string>
|
||||
<string name="import_close">إغلاق</string>
|
||||
<string name="import_done_skipped_label">التكرارات</string>
|
||||
<string name="import_done_added_label">أُضيف</string>
|
||||
<string name="crash_dialog_title">%1$s تعطل</string>
|
||||
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
|
||||
<string name="import_button">استيراد</string>
|
||||
<string name="crash_dialog_dismiss">ليس الآن</string>
|
||||
<string name="crash_report_copied">تم نسخ التقرير إلى حافظتك</string>
|
||||
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في حافظتك.</string>
|
||||
<string name="crash_report_issue_title">تقرير العطل</string>
|
||||
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
|
||||
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من حافظتك هنا.)_</string>
|
||||
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
|
||||
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
|
||||
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
|
||||
<string name="event_access_public_summary">يري كل شخص لديه حق الوصول التفاصيل الكاملة</string>
|
||||
<string name="event_access_private_summary">الآخرون يرون فقط أنك مشغول</string>
|
||||
<string name="event_access_default_summary">أينما كان ما يفعله هذا التقويم عادةً</string>
|
||||
<string name="month_split_collapse">عرض أحداث اليوم</string>
|
||||
<string name="month_split_expand">إظهار الشهر بأكمله</string>
|
||||
<string name="settings_week_day_header">الأسبوع و اليوم</string>
|
||||
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
|
||||
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طُرق العرض والأدوات المصغّرة.</string>
|
||||
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
|
||||
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
|
||||
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
|
||||
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات اتصالك — يمكنك إدارة تقويماتك هنا.</string>
|
||||
<string name="calendars_state_read_only">للقراءة فقط</string>
|
||||
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
|
||||
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
|
||||
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات اتصالك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
|
||||
<string name="duration_custom_max">كحد أقصى %1$s</string>
|
||||
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
|
||||
<string name="reminder_benefit_delivery_title">التذكيرات، تُسلَّم</string>
|
||||
<string name="reminder_use_default">استخدام التذكير الافتراضي</string>
|
||||
<string name="settings_timeline_scale">ارتفاع الساعة</string>
|
||||
<string name="settings_timeline_scale_hint">كم مدى المساحة العمودية التي تأخذها الساعة الواحده في عرض الأسبوع واليوم. كلا العرضين يشتركا في هذا الإعداد. يمكنك أيضًا الضغط على المخطط الزمني بإصبعين لتعيين أي ارتفاع بين.</string>
|
||||
<string name="timeline_scale_fit_day">الملائمة لليوم بأكمله</string>
|
||||
<string name="timeline_scale_compact">مضغوط</string>
|
||||
<string name="timeline_scale_regular_summary">المساحة القياسية</string>
|
||||
<string name="timeline_scale_comfortable">مُريَّح</string>
|
||||
<string name="timeline_scale_compact_summary">المزيد من الساعات لكل شاشة، ومجموعات أصغر</string>
|
||||
<string name="timeline_scale_regular">العادي</string>
|
||||
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
|
||||
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
|
||||
<string name="event_edit_timezone_device_summary">يتابعك أينما كنت</string>
|
||||
<string name="event_access_confidential_summary">مُعَلَّم كـ سري؛ ما يعنيه ذلك متروك لحساب التقويم</string>
|
||||
<string name="settings_widget_size_summary">نص الحدث %1$d%%</string>
|
||||
<string name="settings_calendar_durations_hint">امنح تقويم طوله الافتراضي خاص به — للمثال. ٨ ساعات لمناوبات العمل.</string>
|
||||
<string name="settings_backup_subtitle">تصدير، استيراد، النسخ الاحتياطي التلقائي</string>
|
||||
<string name="settings_views_all_header">كل طُرق العرض</string>
|
||||
<string name="settings_calendar_durations_title">المدة لكل تقويم</string>
|
||||
<string name="event_move_done">تم النقل إلى %1$s</string>
|
||||
<string name="event_move_undo">تراجع</string>
|
||||
<string name="event_move_undone">تم التراجع عن النقل</string>
|
||||
<string name="event_move_failed">تعذّر نقل الحدث</string>
|
||||
<string name="event_move_write_denied">Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث</string>
|
||||
<string name="event_move_gone">لم يعد هذا الحدث موجودًا</string>
|
||||
<string name="onboarding_step_counter">الخطوة %1$d من %2$d</string>
|
||||
<string name="onboarding_backup_title">أحداثك تعيش فقط هنا</string>
|
||||
<string name="onboarding_backup_body">لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك.</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">مرة واحدة يوميًا، من تلقاء نفسه</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula يقم بتصدير تقويماتك المحلية في الخلفية. يمكنك تغيير مدى المرات في الإعدادات.</string>
|
||||
<string name="onboarding_backup_enable_button">اختر مجلد وانسخ احتياطيًا</string>
|
||||
<string name="onboarding_backup_skip_button">ليس الآن</string>
|
||||
<string name="onboarding_view_title">ما الذي يجب أن يفتح أولاً؟</string>
|
||||
<string name="onboarding_month_style_title">كيف يجب أن يبدو الشهر؟</string>
|
||||
<string name="onboarding_view_continue_button">متابعة</string>
|
||||
<string name="onboarding_back">رجوع</string>
|
||||
<string name="onboarding_visibility_button">فهمت</string>
|
||||
<string name="onboarding_done_title">تم إعدادك بالكامل</string>
|
||||
<string name="onboarding_done_body">تقويماتك جاهزه. يمكن تغيير كل ما اخترته للتو لاحقًا في الإعدادات.</string>
|
||||
<string name="onboarding_done_button">افتح تقويمي</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="zero">تم تحديد (%d)</item>
|
||||
<item quantity="one">تم تحديد %d</item>
|
||||
<item quantity="two">تم تحديد %d</item>
|
||||
<item quantity="few">تم تحديد %d</item>
|
||||
<item quantity="many">تم تحديد %d</item>
|
||||
<item quantity="other">تم تحديد %d</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">عُثِر في الأوصاف</string>
|
||||
<string name="search_selection_close">إلغاء التحديد</string>
|
||||
<string name="search_select_all">تحديد الكل</string>
|
||||
<string name="search_delete_selected">حذف المُحدّد</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="zero">حذف (%d) حدث؟</item>
|
||||
<item quantity="one">حذف %d حدث واحد؟</item>
|
||||
<item quantity="two">حذف %d حدثان؟</item>
|
||||
<item quantity="few">حذف %d أحداث؟</item>
|
||||
<item quantity="many">حذف %d حدث؟</item>
|
||||
<item quantity="other">حذف %d حدث؟</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="zero">تم حذف (%d) لا حدث</item>
|
||||
<item quantity="one">تم حذف %d حدث واحد</item>
|
||||
<item quantity="two">تم حذف %d حدثان</item>
|
||||
<item quantity="few">تم حذف %d أحداث</item>
|
||||
<item quantity="many">تم حذف %d حدث</item>
|
||||
<item quantity="other">تم حذف %d حدث</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">تم حذف %1$d، وتعذّر حذف %2$d</string>
|
||||
<string name="search_delete_denied_partial">تم حذف %1$d، ثم تم سحب صلاحية للكتابة</string>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="zero">تم تخطي (%d) بالفعل في هذا التقويم.</item>
|
||||
<item quantity="one">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="two">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="few">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="many">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
<item quantity="other">تم تخطي %d بالفعل في هذا التقويم.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="zero">تم استيراد (%d) لا أحداث.</item>
|
||||
<item quantity="one">تم استيراد %d حدث واحد.</item>
|
||||
<item quantity="two">تم استيراد %d حدثان.</item>
|
||||
<item quantity="few">تم استيراد %d أحداث.</item>
|
||||
<item quantity="many">تم استيراد %d حدث.</item>
|
||||
<item quantity="other">تم استيراد %d حدث.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="zero">استيراد (%d) لا أحداث</item>
|
||||
<item quantity="one">استيراد %d حدث واحد</item>
|
||||
<item quantity="two">استيراد %d حدثان</item>
|
||||
<item quantity="few">استيراد %d أحداث</item>
|
||||
<item quantity="many">استيراد %d حدث</item>
|
||||
<item quantity="other">استيراد %d حدث</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="zero">(%d) لا أحداث في هذا الملف.</item>
|
||||
<item quantity="one">%d حدث واحد في هذا الملف.</item>
|
||||
<item quantity="two">%d حدثان في هذا الملف.</item>
|
||||
<item quantity="few">%d أحداث في هذا الملف.</item>
|
||||
<item quantity="many">%d حدث في هذا الملف.</item>
|
||||
<item quantity="other">%d حدث في هذا الملف.</item>
|
||||
</plurals>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="zero">يتم استيراد (%d) لا أحداث</item>
|
||||
<item quantity="one">يتم استيراد %d حدث واحد</item>
|
||||
<item quantity="two">يتم استيراد %d حدثان</item>
|
||||
<item quantity="few">يتم استيراد %d أحداث</item>
|
||||
<item quantity="many">يتم استيراد %d حدث</item>
|
||||
<item quantity="other">يتم استيراد %d حدث</item>
|
||||
</plurals>
|
||||
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
|
||||
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
|
||||
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
|
||||
<string name="settings_drawer_order_header">قائمة التنقل</string>
|
||||
<string name="settings_default_reminder_allday">أحداث يوم كامل</string>
|
||||
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
|
||||
<string name="reminder_none">لا شئ</string>
|
||||
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Calendula لكي تصل في الوقت المحدد.</string>
|
||||
<string name="settings_calendar_reminders_title">التذكيرات لكل تقويم</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_special_dates_paused_title">متوقف</string>
|
||||
<string name="settings_special_dates_calendar_hint">اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات.</string>
|
||||
<string name="calendars_visibility_hint">قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت.</string>
|
||||
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
|
||||
<string name="settings_agenda_widget_range">مدى أداة الجدول</string>
|
||||
<string name="settings_widget_size">حجم أداة الجدول</string>
|
||||
<string name="settings_widgets_subtitle">أداة الجدول، عنصر الإعدادات السريعة</string>
|
||||
<string name="settings_drag_to_reschedule">السحب لتعديل الموعد</string>
|
||||
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
|
||||
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
|
||||
<string name="crash_dialog_report">تقرير</string>
|
||||
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
|
||||
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
|
||||
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
|
||||
<string name="settings_reliable_delivery">توصيل موثوق</string>
|
||||
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
|
||||
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
|
||||
<string name="settings_past_events_sample_morning">مراجعة الفريق</string>
|
||||
<string name="settings_past_events_sample_midday">غداء مع روبن</string>
|
||||
<string name="settings_past_events_sample_evening">ممارسة الجوقة</string>
|
||||
<string name="settings_special_dates_last_synced">آخر مزامنة %1$s</string>
|
||||
<string name="settings_special_dates_disable_type_message">يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه.</string>
|
||||
<string name="special_dates_default_title_birthday">عيد ميلاد {name} ({year})</string>
|
||||
<string name="event_move_action">نقل…</string>
|
||||
<string name="settings_font_specimen_heading">الخميس، 14 مايو</string>
|
||||
<string name="settings_font_specimen_body">مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز.</string>
|
||||
<string name="settings_section_widgets">الأدوات المصغّرة والعناصر</string>
|
||||
<string name="settings_widget_size_hint">مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه.</string>
|
||||
<string name="settings_agenda_widget_range_hint">كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث.</string>
|
||||
<string name="settings_agenda_range_hint">كم مدى يعرض شاشة الجدول الأحداث.</string>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="settings_special_dates_show_year">إظهار السنة</string>
|
||||
<string name="settings_special_dates_show_year_hint">تضمين {year} في العناوين عندما يكون معروفًا</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="special_dates_default_title_anniversary">ذكرى {name} السنوية ({year})</string>
|
||||
<string name="special_dates_calendar_anniversary">الذكرات السنوية</string>
|
||||
<string name="crash_dialog_message">أُغلق %1$s بشكل غير متوقع في المرة الأخيرة. يمكنك المساعدة في إصلاحه عن طريق إرسال هذا التقرير كمشكلة. سيبقى على جهازك حتى تختار مشاركته، ولا يتضمن أي بيانات شخصية أو محتوى تقويم — فقط التفاصيل الفنية أدناه.</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">مجلد تختاره</string>
|
||||
<string name="import_warning_timezone">منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">تعيين في تواريخ جهات الاتصال الخاصة</string>
|
||||
<string name="settings_special_dates_disable_all_message">يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
|
||||
<string name="settings_special_dates_type_anniversary">الذكرات السنوية</string>
|
||||
<string name="settings_special_dates_template_hint">استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة).</string>
|
||||
<string name="settings_special_dates_template">صيغة العنوان</string>
|
||||
<string name="settings_calendar_reminders_hint">تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
|
||||
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
|
||||
<string name="agenda_empty_title">أنت على اطلاع</string>
|
||||
</resources>
|
||||
|
||||
149
app/src/main/res/values-be/strings.xml
Normal file
149
app/src/main/res/values-be/strings.xml
Normal file
@@ -0,0 +1,149 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_tagline">Сучасны каляндар.</string>
|
||||
<string name="state_loading">Загрузка…</string>
|
||||
<string name="state_retry">Паўтарыць спробу</string>
|
||||
<string name="state_failure_unknown">Узнікла нейкая праблема.</string>
|
||||
<string name="state_failure_permission">Патрабуецца доступ да календара.</string>
|
||||
<string name="state_failure_permission_action">Дазволіць доступ</string>
|
||||
<string name="state_failure_no_calendars">Календары не сканфігураваныя.</string>
|
||||
<string name="state_failure_no_calendars_action">Адкрыць сыстэмныя налады календароў</string>
|
||||
<string name="state_failure_provider">Немагчыма прачытаць каляндар.</string>
|
||||
<string name="permission_rationale_title">Прагляд усіх падзеяў у прыгожым выглядзе</string>
|
||||
<string name="permission_rationale_body">Для паказу і кіраваньня падзеямі Calendula патрабуецца доступ да вашага календара.</string>
|
||||
<string name="permission_request_button">Дазволіць доступ да календара</string>
|
||||
<string name="permission_denied_title">Няма доступу да календара</string>
|
||||
<string name="permission_denied_body">Calendula ня можа паказваць падзеі бяз доступу да календара.</string>
|
||||
<string name="permission_open_settings_button">Адкрыць сыстэмныя налады</string>
|
||||
<string name="permission_retry_button">Паўтарыць спробу</string>
|
||||
<string name="permission_benefit_private_title">Распрацавана з акцэнтам на прыватнасьць</string>
|
||||
<string name="permission_benefit_private_body">Бяз доступу да Інтэрнэту, без тэлемэтрыі — нічога і ніколі не пакідае ваш тэлефон.</string>
|
||||
<string name="permission_benefit_sync_title">Усе вашы календары разам</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, лякальныя — паказваюцца ўсе, сынхранізаваныя з прыладай.</string>
|
||||
<string name="permission_benefit_privacy_title">Ніякага адсочваньня, ніколі</string>
|
||||
<string name="permission_benefit_privacy_body">Без тэлемэтрыі, без аналітыкі, без рэклямы.</string>
|
||||
<string name="permission_privacy_footnote">Усё застаецца на прыладзе, доступ да Інтэрнэту не патрабуецца</string>
|
||||
<string name="month_prev">Папярэдні месяц</string>
|
||||
<string name="month_next">Наступны месяц</string>
|
||||
<string name="month_today_action">Сёньня</string>
|
||||
<string name="month_more_actions">Іншыя дзеяньні</string>
|
||||
<string name="month_open_menu">Адкрыць мэню</string>
|
||||
<string name="month_action_settings">Налады</string>
|
||||
<string name="month_a11y_today_prefix">Сёньня</string>
|
||||
<string name="week_today_action">Гэты тыдзень</string>
|
||||
<string name="week_number_label">Тдз.</string>
|
||||
<string name="day_today_action">Сёньня</string>
|
||||
<string name="event_detail_back">Назад</string>
|
||||
<string name="event_detail_edit">Праўка</string>
|
||||
<string name="event_detail_delete">Выдаліць</string>
|
||||
<string name="event_detail_share">Падзяліцца</string>
|
||||
<string name="event_detail_duplicate">Дублікат</string>
|
||||
<string name="event_share_chooser_title">Падзяліцца падзеяй</string>
|
||||
<string name="event_share_failed">Не атрымалася падзяліцца гэтай падзеяй.</string>
|
||||
<string name="event_delete_title">Выдаліць падзею?</string>
|
||||
<string name="event_delete_body">Падзея выдаленая з вашага календара і з усіх прыладаў, зь якімі адбываецца сынхранізацыя.</string>
|
||||
<string name="event_delete_recurring_title">Выдаліць пэрыядычную падзею</string>
|
||||
<string name="event_delete_option_occurrence">Толькі гэтую падзею</string>
|
||||
<string name="event_delete_option_following">Гэтую і ўсе наступныя падзеі</string>
|
||||
<string name="event_delete_option_series">Усе падзеі ў пасьлядоўнасьці</string>
|
||||
<string name="event_edit_recurring_title">Рэдагаваць пэрыядычную падзею</string>
|
||||
<string name="event_delete_failed">Не атрымалася выдаліць падзею</string>
|
||||
<string name="event_delete_write_denied">Для выдаленьня падзеяў Calendula патрабуецца дазвол на запіс</string>
|
||||
<string name="event_move_action">Перанесці…</string>
|
||||
<string name="event_move_recurring_title">Перанесці пэрыядычную падзею</string>
|
||||
<string name="event_move_occurrence_only">Перанос усёй пасьлядоўнасьці зьменіць дні, на якія яна прыпадае, таму перанесці можна толькі гэтую падзею.</string>
|
||||
<string name="event_move_done">Перанесена на %1$s</string>
|
||||
<string name="event_move_undo">АдрабІць</string>
|
||||
<string name="event_move_undone">Перанос скасаваны</string>
|
||||
<string name="event_move_failed">Не атрымалася перанесьці падзею</string>
|
||||
<string name="event_move_write_denied">Для пераносу падзеяў Calendula патрабуецца дазвол на запіс</string>
|
||||
<string name="event_move_gone">Гэтай падзеі больш не існуе</string>
|
||||
<string name="event_move_blocked_series_end">Нельга перанесьці падзею на дату пасьля заканчэньня яе пасьлядоўнасьці</string>
|
||||
<string name="dialog_cancel">Адмена</string>
|
||||
<string name="dialog_ok">ОК</string>
|
||||
<string name="event_edit_new_title">Новая падзея</string>
|
||||
<string name="event_edit_close">Закрыць</string>
|
||||
<string name="event_edit_save">Захаваць</string>
|
||||
<string name="event_edit_title_hint">Дадаць загаловак</string>
|
||||
<string name="event_edit_managed_hint">Кіруецца «%1$s» — загаловак, дата і паўтор сынхранізуюцца з вашымі кантактамі. Напаміны, месцазнаходжаньне і нататкі даступныя вам для рэдагаваньня.</string>
|
||||
<string name="event_edit_starts">Пачатак</string>
|
||||
<string name="event_edit_ends">Заканчэньне</string>
|
||||
<string name="event_edit_error_end_before_start">Заканчваецца раней, чым пачынаецца</string>
|
||||
<string name="event_edit_error_no_calendar">Няма календара, у які дазволены запіс</string>
|
||||
<string name="event_edit_save_failed">Не атрымалася захаваць падзею</string>
|
||||
<string name="event_edit_write_denied">Для стварэньня падзеяў Calendula патрабуецца дазвол на запіс</string>
|
||||
<string name="event_edit_more_fields">Дадатковыя палі</string>
|
||||
<string name="event_edit_add">Дадаць</string>
|
||||
<string name="event_edit_add_reminder">Дадаць напамін</string>
|
||||
<string name="event_edit_remove_reminder">Выдаліць напамін</string>
|
||||
<string name="event_edit_attendees">Наведвальнікі</string>
|
||||
<string name="event_edit_add_guest">Дадаць наведвальніка</string>
|
||||
<string name="event_edit_add_guest_hint">Дадаць наведвальніка праз электронную пошту…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Дадаць з кантактаў</string>
|
||||
<string name="event_edit_location_from_contacts">Выбраць адрас з кантактаў</string>
|
||||
<string name="event_edit_remove_guest">Выдаліць наведвальніка</string>
|
||||
<string name="event_edit_attendee_required">Неабходны</string>
|
||||
<string name="event_edit_attendee_optional">Неабавязковы</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula не рассылае запрашэньні. Наведвальнікі могуць атрымаць электронныя лісты з вашага ўліковага запісу календара пры сынхранізацыі.</string>
|
||||
<string name="event_edit_attendees_note_local">Захавана на гэтай прыладзе. Ніхто не атрымаў абвесткі.</string>
|
||||
<string name="event_edit_reminder_custom">Карыстальніцкі</string>
|
||||
<string name="reminder_unit_minutes">хв.</string>
|
||||
<string name="reminder_unit_hours">гадз.</string>
|
||||
<string name="reminder_unit_days">сут.</string>
|
||||
<string name="reminder_unit_weeks">тыд.</string>
|
||||
<string name="event_edit_availability">Даступнасьць</string>
|
||||
<string name="event_edit_visibility">Бачнасьць</string>
|
||||
<string name="event_edit_timezone_device">Часавы пояс прылады</string>
|
||||
<string name="event_edit_timezone_device_summary">Сочыць за вашым месцазнаходжаньнем</string>
|
||||
<string name="event_edit_timezone_search">Пошук часавых паясоў</string>
|
||||
<string name="event_edit_timezone_recent">Апошнія</string>
|
||||
<string name="event_edit_timezone_all">Усе часавыя паясы</string>
|
||||
<string name="event_edit_timezone_none">Няма часавага поясу, які адпавядае «%1$s»</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s паводле вашага часу</string>
|
||||
<string name="event_edit_color">Колер</string>
|
||||
<string name="event_edit_color_default">Колер календара</string>
|
||||
<string name="event_edit_color_custom">Карыстальніцкі колер</string>
|
||||
<string name="event_edit_color_reset">Скід</string>
|
||||
<string name="event_edit_color_unsupported">Недаступна для гэтага календара</string>
|
||||
<string name="event_edit_color_unsupported_hint">Для гэтага календара не апублікаваны набор колераў. Для падобных календароў у наладах можна задаць карыстальніцкія колеры.</string>
|
||||
<string name="event_edit_color_sync_warning">Пры наступнай сынхранізацыі гэты каляндар можа страціць або перазапісаць колер.</string>
|
||||
<string name="event_edit_conflict_title">Падзея зьмянілася ў іншым месцы</string>
|
||||
<string name="event_edit_conflict_body">Падчас рэдагаваньня вамі гэтая падзея была зьменена ў выніку сынхранізацыі або іншай праграмай. Што адбудзецца з вашымі зьменамі?</string>
|
||||
<string name="event_edit_conflict_overwrite">Захаваць мае змены</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Толькі адрэдагаваныя вамі палі перазапішуць зьнешнія зьмены</string>
|
||||
<string name="event_edit_conflict_discard">Адкінуць мае зьмены</string>
|
||||
<string name="event_edit_conflict_discard_hint">Падзея застаецца такой, якой яна ёсьць зараз</string>
|
||||
<string name="event_edit_gone_title">Падзея выдалена</string>
|
||||
<string name="event_edit_gone_body">Гэтая падзея ўжо выдаленая, напрыклад на іншай прыладзе. Вашыя зьмены болей немагчыма захаваць.</string>
|
||||
<string name="import_reminder_prompt_title">Ужыць напамін па змаўчаньні?</string>
|
||||
<string name="import_reminder_prompt_body_none">Гэтая падзея была імпартаваная без напаміну.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
|
||||
<item quantity="few">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
|
||||
<item quantity="many">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
|
||||
<item quantity="other">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Ужыць змаўчаньні</string>
|
||||
<string name="import_reminder_prompt_keep">Пакінуць як ёсьць</string>
|
||||
<string name="event_edit_recurrence_none">Не паўтараецца</string>
|
||||
<string name="event_edit_recurrence_custom">Карыстальніцкі варыянт</string>
|
||||
<string name="event_edit_recurrence_every">Кожн.</string>
|
||||
<string name="recurrence_unit_days">сут.</string>
|
||||
<string name="recurrence_unit_weeks">тыд.</string>
|
||||
<string name="recurrence_unit_months">мес.</string>
|
||||
<string name="recurrence_unit_years">год/гады</string>
|
||||
<string name="event_edit_recurrence_incomplete">Увядзеце лік ад 1 да 999</string>
|
||||
<string name="event_edit_recurrence_next">Наступ.: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Гэтае правіла ніколі не паўтараецца</string>
|
||||
<string name="event_edit_recurrence_ends">Заканчэньне</string>
|
||||
<string name="event_edit_recurrence_end_never">Ніколі</string>
|
||||
<string name="event_edit_recurrence_end_until">На дату</string>
|
||||
<string name="event_edit_recurrence_end_count">Пасьля некалькіх разоў</string>
|
||||
<string name="event_edit_recurrence_times">разы(оў)</string>
|
||||
</resources>
|
||||
253
app/src/main/res/values-cs/strings.xml
Normal file
253
app/src/main/res/values-cs/strings.xml
Normal file
@@ -0,0 +1,253 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Moderní kalendář.</string>
|
||||
<string name="state_loading">Načítá se…</string>
|
||||
<string name="state_retry">Opakovat</string>
|
||||
<string name="state_failure_unknown">Něco se pokazilo.</string>
|
||||
<string name="state_failure_permission">Je nutný přístup ke kalendáři.</string>
|
||||
<string name="state_failure_permission_action">Udělit přístup</string>
|
||||
<string name="state_failure_no_calendars">Nejsou konfigurovány žádné kalendáře.</string>
|
||||
<string name="permission_request_button">Udělit přístup ke kalendáři</string>
|
||||
<string name="permission_rationale_body">Calendula potřebuje přístup k vašemu kalendáři, aby mohla zobrazovat a spravovat vaše události.</string>
|
||||
<string name="permission_benefit_sync_title">Všechny vaše kalendáře na jednom místě</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokální kalendáře — cokoliv synchronizovaného se zařízením.</string>
|
||||
<string name="permission_benefit_private_title">Navrženo pro soukromí</string>
|
||||
<string name="permission_benefit_private_body">Žádný přístup k internetu, žádná telemetrie — nikdy nedojde k úniku dat z vašeho telefonu.</string>
|
||||
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
|
||||
<string name="permission_rationale_title">Prohlížejte všechny události, hezky a přehledně</string>
|
||||
<string name="permission_denied_title">Přístup ke kalendáři byl odepřen</string>
|
||||
<string name="permission_denied_body">Calendula nemůže zobrazovat události bez uděleného přístupu ke kalendáři. Přístup můžete znovu udělit v nastavení systému.</string>
|
||||
<string name="state_failure_no_calendars_action">Otevřít nastavení kalendáře systému</string>
|
||||
<string name="permission_open_settings_button">Otevřít nastavení systému</string>
|
||||
<string name="permission_retry_button">Zkusit to znovu</string>
|
||||
<string name="state_failure_provider">Nepodařilo se načíst kalendář.</string>
|
||||
<string name="permission_benefit_privacy_title">Žádné sledování, nikdy</string>
|
||||
<string name="permission_benefit_privacy_body">Žádná telemetrie, žádné analytické nástroje, bez reklam.</string>
|
||||
<string name="permission_privacy_footnote">Zůstává na vašem zařízení · bez přístupu k internetu</string>
|
||||
<string name="month_prev">Předchozí měsíc</string>
|
||||
<string name="month_next">Následující měsíc</string>
|
||||
<string name="month_today_action">Dnes</string>
|
||||
<string name="month_more_actions">Další akce</string>
|
||||
<string name="month_open_menu">Otevřít nabídku</string>
|
||||
<string name="month_action_settings">Nastavení</string>
|
||||
<string name="month_a11y_today_prefix">Dnes</string>
|
||||
<string name="week_today_action">Tento týden</string>
|
||||
<string name="week_number_label">týd.</string>
|
||||
<string name="day_today_action">Dnes</string>
|
||||
<string name="event_detail_back">Zpět</string>
|
||||
<string name="event_detail_edit">Upravit</string>
|
||||
<string name="event_detail_delete">Vymazat</string>
|
||||
<string name="event_detail_share">Sdílet</string>
|
||||
<string name="event_detail_duplicate">Kopírovat</string>
|
||||
<string name="event_share_chooser_title">Sdílet událost</string>
|
||||
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
|
||||
<string name="event_delete_title">Odstranit událost?</string>
|
||||
<string name="event_delete_body">Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení.</string>
|
||||
<string name="event_delete_recurring_title">Smazat opakující se událost</string>
|
||||
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
|
||||
<string name="event_delete_option_following">Tuto a všechny následující události</string>
|
||||
<string name="event_delete_option_series">Všechny události v sérii</string>
|
||||
<string name="event_edit_recurring_title">Upravit opakující se událost</string>
|
||||
<string name="event_delete_failed">Tuto událost nelze odstranit</string>
|
||||
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla mazat události</string>
|
||||
<string name="event_move_action">Přesunout…</string>
|
||||
<string name="event_move_recurring_title">Přesunout opakující se událost</string>
|
||||
<string name="event_move_occurrence_only">Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost.</string>
|
||||
<string name="event_move_done">Přesunuto do %1$s</string>
|
||||
<string name="event_move_undo">Zpět</string>
|
||||
<string name="event_move_undone">Vráceno zpět</string>
|
||||
<string name="event_move_failed">Událost nelze přesunout</string>
|
||||
<string name="event_move_write_denied">Calendula k přesouvání události vyžaduje oprávnění k zápisu</string>
|
||||
<string name="event_move_gone">Tato událost již neexistuje</string>
|
||||
<string name="event_move_blocked_series_end">Událost nelze přesunout za konec dané série</string>
|
||||
<string name="dialog_cancel">Zrušit</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="event_edit_new_title">Nová událost</string>
|
||||
<string name="event_edit_close">Zavřít</string>
|
||||
<string name="event_edit_save">Uložit</string>
|
||||
<string name="event_edit_title_hint">Přidat název</string>
|
||||
<string name="event_edit_managed_hint">Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Upomínky, polohu a poznámky si můžete upravit sami.</string>
|
||||
<string name="event_edit_starts">Začátek</string>
|
||||
<string name="event_edit_ends">Konec</string>
|
||||
<string name="event_edit_error_end_before_start">Končí dříve, než začíná</string>
|
||||
<string name="event_edit_error_no_calendar">Není k dispozici žádný kalendář, který by umožňoval zápis</string>
|
||||
<string name="event_edit_save_failed">Nelze uložit událost</string>
|
||||
<string name="event_edit_write_denied">Calendula pro vytváření událostí vyžaduje oprávnění k zápisu</string>
|
||||
<string name="event_edit_more_fields">další pole</string>
|
||||
<string name="event_edit_add">Přidat</string>
|
||||
<string name="event_edit_add_reminder">Přidat upomínku</string>
|
||||
<string name="event_edit_remove_reminder">Vymazat upomínku</string>
|
||||
<string name="event_edit_attendees">Hosté</string>
|
||||
<string name="event_edit_add_guest">Přidat hosta</string>
|
||||
<string name="event_edit_add_guest_hint">Přidat hosta e-mailem…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Přidat z kontaktů</string>
|
||||
<string name="event_edit_location_from_contacts">Vybrat adresu z kontaktů</string>
|
||||
<string name="event_edit_remove_guest">Odstranit hosta</string>
|
||||
<string name="event_edit_attendee_required">Požadované</string>
|
||||
<string name="event_edit_attendee_optional">Volitelné</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula nezasílá pozvánky. Váš účet kalendáře může hosty zvát e-mailem při synchronizaci.</string>
|
||||
<string name="event_edit_attendees_note_local">Uloženo v tomto zařízení. Nikomu nejsou odesílány notifikace.</string>
|
||||
<string name="event_edit_reminder_custom">Vlastní</string>
|
||||
<string name="reminder_unit_minutes">minuty</string>
|
||||
<string name="reminder_unit_hours">hodiny</string>
|
||||
<string name="reminder_unit_days">dny</string>
|
||||
<string name="reminder_unit_weeks">týdny</string>
|
||||
<string name="event_edit_availability">Dostupnost</string>
|
||||
<string name="event_edit_visibility">Viditelnost</string>
|
||||
<string name="event_edit_timezone_device">Časové pásmo zařízení</string>
|
||||
<string name="event_edit_timezone_device_summary">Monitoruje vaši polohu, ať už jste kdekoliv</string>
|
||||
<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_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>
|
||||
<string name="event_edit_color_custom">Vlastní barva</string>
|
||||
<string name="event_edit_color_reset">Obnovit</string>
|
||||
<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_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>
|
||||
<string name="event_edit_conflict_discard">Zrušit změny</string>
|
||||
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
|
||||
<string name="event_edit_gone_title">Událost byla smazána</string>
|
||||
<string name="event_edit_gone_body">Během úpravy došlo ke smazání události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
|
||||
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
|
||||
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Tato událost byla importována s %1$d upomínkou.</item>
|
||||
<item quantity="few">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
<item quantity="many">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
<item quantity="other">Tato událost byla importována s %1$d upomínkami.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Nastavit výchozí</string>
|
||||
<string name="import_reminder_prompt_keep">Nechat beze změny</string>
|
||||
<string name="event_edit_recurrence_none">Neopakuje se</string>
|
||||
<string name="event_edit_recurrence_custom">Vlastní</string>
|
||||
<string name="event_edit_recurrence_every">Každý</string>
|
||||
<string name="recurrence_unit_days">dny</string>
|
||||
<string name="recurrence_unit_weeks">týdny</string>
|
||||
<string name="recurrence_unit_months">měsíce</string>
|
||||
<string name="recurrence_unit_years">roky</string>
|
||||
<string name="event_edit_recurrence_incomplete">Zadejte číslo v rozmezí 1 až 999</string>
|
||||
<string name="event_edit_recurrence_next">Následující: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Toto pravidlo se nikdy neopakuje</string>
|
||||
<string name="event_edit_recurrence_ends">Konec</string>
|
||||
<string name="event_edit_recurrence_end_never">Nikdy</string>
|
||||
<string name="event_edit_recurrence_end_until">K určitému datu</string>
|
||||
<string name="event_edit_recurrence_end_count">Po určitém počtu opakování</string>
|
||||
<string name="event_edit_recurrence_times">opakování</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Opakování končí před začátkem události</string>
|
||||
<string name="event_availability_busy">Zaneprázdněný</string>
|
||||
<string name="event_access_default">Výchozí</string>
|
||||
<string name="event_access_public">Veřejný</string>
|
||||
<string name="event_detail_all_day">Celý den</string>
|
||||
<string name="event_detail_calendar">Kalendář</string>
|
||||
<string name="event_detail_calendar_unknown">Neznámý kalendář</string>
|
||||
<string name="event_detail_location">Umístění</string>
|
||||
<string name="event_detail_description">Popis</string>
|
||||
<string name="event_detail_attendees">Účastníci</string>
|
||||
<string name="event_detail_recurrence">Opakování</string>
|
||||
<string name="event_detail_recurring">Opakující se událost</string>
|
||||
<string name="recurrence_daily">Každý den</string>
|
||||
<string name="recurrence_weekly">Každý týden</string>
|
||||
<string name="recurrence_monthly">Každý měsíc</string>
|
||||
<string name="recurrence_yearly">Každý rok</string>
|
||||
<string name="recurrence_every_n_days">Každý %1$d. den</string>
|
||||
<string name="recurrence_every_n_weeks">Každý %1$d. týden</string>
|
||||
<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_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>
|
||||
<string name="event_attendee_declined">Odmítnuto</string>
|
||||
<string name="event_attendee_tentative">Předběžné</string>
|
||||
<string name="event_attendee_needs_action">Bez odezvy</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Upomínky</string>
|
||||
<string name="event_detail_timezone">Časové pásmo</string>
|
||||
<string name="event_status_tentative">Přeběžné</string>
|
||||
<string name="event_status_cancelled">Zrušeno</string>
|
||||
<string name="event_availability_free">Volné</string>
|
||||
<string name="event_access_private">Soukromé</string>
|
||||
<string name="event_access_confidential">Utajené</string>
|
||||
<string name="event_access_default_summary">Běžná činnost kalendáře</string>
|
||||
<string name="event_access_public_summary">Kdokoliv s možností přístupu uvidí veškeré podrobnosti</string>
|
||||
<string name="event_access_private_summary">Ostatní uvidí pouze to, že jste zaneprázdněn</string>
|
||||
<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_detail_self_response">Vaše odpověď: %1$s</string>
|
||||
<string name="reminder_at_time">V době konání události</string>
|
||||
<string name="reminder_default">Výchozí upomínka</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minutu předtím</item>
|
||||
<item quantity="few">%d minuty předtím</item>
|
||||
<item quantity="many">%d minut předtím</item>
|
||||
<item quantity="other">%d minut předtím</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d hodinu předtím</item>
|
||||
<item quantity="few">%d hodiny předtím</item>
|
||||
<item quantity="many">%d hodin předtím</item>
|
||||
<item quantity="other">%d hodin předtím</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d den předtím</item>
|
||||
<item quantity="few">%d dny předtím</item>
|
||||
<item quantity="many">%d dnů předtím</item>
|
||||
<item quantity="other">%d dnů předtím</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d týden předtím</item>
|
||||
<item quantity="few">%d týdny předtím</item>
|
||||
<item quantity="many">%d týdnů předtím</item>
|
||||
<item quantity="other">%d týdnů předtím</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuta</item>
|
||||
<item quantity="few">%d minuty</item>
|
||||
<item quantity="many">%d minut</item>
|
||||
<item quantity="other">%d minut</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hodina</item>
|
||||
<item quantity="few">%d hodiny</item>
|
||||
<item quantity="many">%d hodin</item>
|
||||
<item quantity="other">%d hodin</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<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="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">Nanejvýš %1$s</string>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d týden</item>
|
||||
<item quantity="few">%d týdny</item>
|
||||
<item quantity="many">%d týdnů</item>
|
||||
<item quantity="other">%d týdnů</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Bez názvu)</string>
|
||||
<string name="onboarding_backup_title">Vaši událost naleznete pouze zde</string>
|
||||
<string name="onboarding_backup_body">V tomto telefonu se nesynchronizuje žádný účet, takže pokud ho ztratíte, přijdete i o svůj kalendář. Calendula může vytvořit lokální zálohu.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Složka, kterou si vyberete</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Jednou denně, automaticky</string>
|
||||
</resources>
|
||||
@@ -12,14 +12,14 @@
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
|
||||
<string name="permission_retry_button">Erneut versuchen</string>
|
||||
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
|
||||
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
|
||||
<string name="permission_benefit_private_title">Datenschutz by Design</string>
|
||||
<string name="permission_benefit_private_body">Keine Internetberechtigung, keine Telemetrie — nichts verlässt dein Telefon.</string>
|
||||
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
|
||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||
@@ -310,9 +310,9 @@
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||
<string name="settings_appearance_subtitle">Theme, Farben, Schriftarten</string>
|
||||
<string name="settings_event_form_subtitle">Standardfelder und Verhalten</string>
|
||||
<string name="settings_notifications_subtitle">Erinnerungen und Zustellung</string>
|
||||
<string name="settings_section_about">Über</string>
|
||||
<string name="settings_license">Lizenz</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -424,7 +424,7 @@
|
||||
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
|
||||
<string name="settings_views_subtitle">Standartansicht, Anordnung, Menüreihenfolge</string>
|
||||
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
||||
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
||||
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
||||
@@ -498,4 +498,154 @@
|
||||
<string name="event_edit_timezone_none">Keine Zeitzone passt zu “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s deiner Zeit</string>
|
||||
<string name="import_reminder_prompt_title">Deine Standarterinnerung anwenden?</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Dieses Event wurde mit %1$d Erinnerung importiert.</item>
|
||||
<item quantity="other">Dieses Event wurde mit %1$d Erinnerungen importiert.</item>
|
||||
</plurals>
|
||||
<string name="settings_widget_size">Agenda-Widget-Größe</string>
|
||||
<string name="import_reminder_prompt_body_none">Dieser Termin wurde ohne eine Erinnerung importiert.</string>
|
||||
<string name="import_reminder_prompt_apply">Standart anwenden</string>
|
||||
<string name="settings_widgets_subtitle">Agenda-Widget, Schnelleinstellungen-Kachel</string>
|
||||
<string name="import_reminder_prompt_keep">unverändert lassen</string>
|
||||
<string name="event_access_public_summary">Jeder mit Zugriff sieht jedes Detail</string>
|
||||
<string name="event_access_private_summary">Andere sehen nur das du beschäftigt bist</string>
|
||||
<string name="event_edit_recurrence_next">Als nächstes: %1$s</string>
|
||||
<string name="event_edit_recurrence_incomplete">Gebe eine Zahl zwischen 1 und 999 ein</string>
|
||||
<string name="timeline_scale_custom">Benutzerdefiniert</string>
|
||||
<string name="settings_calendar_duration_inherits">Standard (%1$s)</string>
|
||||
<string name="settings_group_about">Über</string>
|
||||
<string name="event_move_gone">Dieser Termin existiert nicht mehr</string>
|
||||
<string name="duration_custom_max">Maximal %1$s</string>
|
||||
<string name="reminder_day_yesterday">Gestern</string>
|
||||
<string name="agenda_no_more_today">Keine Events mehr heute</string>
|
||||
<string name="agenda_span_starts">Beginnt %1$s</string>
|
||||
<string name="agenda_span_ends">Endet %1$s</string>
|
||||
<string name="today_jump_action">Zu Heute springen</string>
|
||||
<string name="event_move_action">Verschieben…</string>
|
||||
<string name="event_move_recurring_title">Wiederkehrenden Termin verschieben</string>
|
||||
<string name="event_move_occurrence_only">Die ganze Serie zu verschieben würde ändern, auf welche Tage sie fällt, deshalb kann nur dieser Termin verschoben werden.</string>
|
||||
<string name="event_move_done">Verschoben auf %1$s</string>
|
||||
<string name="event_move_undo">Rückgängig</string>
|
||||
<string name="event_move_undone">Verschieben rückgängig gemacht</string>
|
||||
<string name="event_move_failed">Termin konnte nicht verschoben werden</string>
|
||||
<string name="event_move_write_denied">Calendula braucht Schreibzugriff, um Termine zu verschieben</string>
|
||||
<string name="event_move_blocked_series_end">Ein Termin kann nicht über das Ende seiner Serie hinaus verschoben werden</string>
|
||||
<string name="event_edit_recurrence_next_none">Diese Regel wiederholt sich nie</string>
|
||||
<string name="event_access_default_summary">Was dieser Kalender normalerweise macht</string>
|
||||
<string name="event_access_confidential_summary">Als vertraulich markiert; was das bedeutet, entscheidet das Kalenderkonto</string>
|
||||
<string name="onboarding_step_counter">Schritt %1$d von %2$d</string>
|
||||
<string name="onboarding_backup_title">Deine Termine liegen nur hier</string>
|
||||
<string name="onboarding_backup_body">Nichts auf diesem Telefon wird mit einem Konto synchronisiert – geht es verloren, ist auch dein Kalender weg. Calendula kann eine Sicherung für dich schreiben.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Ein Ordner deiner Wahl</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Sicherungen sind einfache .ics-Dateien – lege sie irgendwohin, wo synchronisiert wird, oder auf eine SD-Karte.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Einmal am Tag, von selbst</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exportiert deine lokalen Kalender im Hintergrund. Wie oft, änderst du in den Einstellungen.</string>
|
||||
<string name="onboarding_backup_enable_button">Ordner wählen und sichern</string>
|
||||
<string name="onboarding_view_title">Was soll zuerst geöffnet werden?</string>
|
||||
<string name="onboarding_month_style_title">Wie soll Monat aussehen?</string>
|
||||
<string name="onboarding_view_continue_button">Weiter</string>
|
||||
<string name="onboarding_visibility_button">Alles klar</string>
|
||||
<string name="onboarding_done_title">Alles bereit</string>
|
||||
<string name="onboarding_done_body">Deine Kalender sind eingerichtet. Alles, was du gerade gewählt hast, lässt sich später in den Einstellungen ändern.</string>
|
||||
<string name="onboarding_done_button">Meinen Kalender öffnen</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d ausgewählt</item>
|
||||
<item quantity="other">%d ausgewählt</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">In Beschreibungen gefunden</string>
|
||||
<string name="search_selection_close">Auswahl abbrechen</string>
|
||||
<string name="search_select_all">Alle auswählen</string>
|
||||
<string name="search_delete_selected">Ausgewählte löschen</string>
|
||||
<string name="search_delete_partial">%1$d gelöscht, %2$d nicht</string>
|
||||
<string name="search_delete_denied_partial">%1$d gelöscht, dann wurde der Schreibzugriff entzogen</string>
|
||||
<string name="settings_theme_hint">Ob die App hell oder dunkel ist. Die Auswahl gilt sofort.</string>
|
||||
<string name="settings_theme_system_summary">Aktuell %1$s</string>
|
||||
<string name="settings_soften_colors">Kalenderfarben harmonisieren</string>
|
||||
<string name="settings_soften_colors_summary">Behält den Farbton jedes Kalenders, gleicht aber seine Helligkeit an, damit jeder Termin lesbar bleibt und die Farben zusammenpassen. Ausschalten, um die Originalfarben aus der Kalenderquelle zu zeigen.</string>
|
||||
<string name="settings_font_specimen_heading">Donnerstag, 14. Mai</string>
|
||||
<string name="settings_font_specimen_body">Team-Besprechung um 10:00, danach Mittagessen mit Robin im Café am Marktplatz.</string>
|
||||
<string name="settings_week_start_auto_summary">Aktuell %1$s</string>
|
||||
<string name="settings_today_toolbar">Heute-Schaltfläche in der Symbolleiste</string>
|
||||
<string name="settings_today_toolbar_summary">Zeigt die Schaltfläche für den Sprung zu heute in der Symbolleiste statt als schwebende Schaltfläche</string>
|
||||
<string name="settings_app_name">App-Name</string>
|
||||
<string name="settings_app_name_summary">Zeigt Calendula in deinem Launcher als „Kalender“. Nur der Name im Launcher ändert sich; das Symbol kann nach dem Wechsel an einer neuen Stelle liegen.</string>
|
||||
<string name="settings_time_format_auto_summary">Folgt dem System: %1$s</string>
|
||||
<string name="settings_timeline_scale">Stundenhöhe</string>
|
||||
<string name="settings_timeline_scale_hint">Wie viel senkrechten Platz eine Stunde in der Wochen- und Tagesansicht einnimmt. Beide Ansichten teilen sich diese Einstellung. Du kannst die Zeitleiste auch mit zwei Fingern aufziehen, um jede Höhe dazwischen einzustellen.</string>
|
||||
<string name="timeline_scale_fit_day">Ganzer Tag</string>
|
||||
<string name="timeline_scale_fit_day_summary">Alle 24 Stunden auf einem Bildschirm, ohne Scrollen</string>
|
||||
<string name="timeline_scale_compact">Kompakt</string>
|
||||
<string name="timeline_scale_compact_summary">Mehr Stunden pro Bildschirm, kleinere Blöcke</string>
|
||||
<string name="timeline_scale_regular">Normal</string>
|
||||
<string name="timeline_scale_regular_summary">Der Standardabstand</string>
|
||||
<string name="timeline_scale_comfortable">Komfortabel</string>
|
||||
<string name="timeline_scale_comfortable_summary">Größere Blöcke, mehr Scrollen</string>
|
||||
<string name="timeline_scale_custom_summary">Die Höhe, auf die du die Zeitleiste gezogen hast</string>
|
||||
<string name="settings_past_events_sample_morning">Team-Besprechung</string>
|
||||
<string name="settings_past_events_sample_midday">Mittagessen mit Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Chorprobe</string>
|
||||
<string name="settings_widget_size_hint">Wie groß das Agenda-Widget auf dem Startbildschirm seinen Text zeichnet. Für das Monats-Widget gibt es keine Einstellung – sein Raster passt sich immer dem Platz an, den du ihm gibst.</string>
|
||||
<string name="settings_widget_size_small">Klein</string>
|
||||
<string name="settings_widget_size_medium">Mittel</string>
|
||||
<string name="settings_widget_size_large">Groß</string>
|
||||
<string name="settings_widget_size_extra_large">Sehr groß</string>
|
||||
<string name="settings_widget_size_summary">Termintext %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Heute immer zeigen</string>
|
||||
<string name="settings_agenda_show_today_hint">Behält Heute oben in der Agenda und ihrem Widget, auch wenn für heute nichts mehr ansteht.</string>
|
||||
<string name="settings_month_header">Monatsansicht</string>
|
||||
<string name="settings_month_view_style">Stil der Monatsansicht</string>
|
||||
<string name="month_style_paged">Seiten</string>
|
||||
<string name="month_style_paged_summary">Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln.</string>
|
||||
<string name="month_style_continuous">Fortlaufende Monate</string>
|
||||
<string name="month_style_continuous_summary">Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten.</string>
|
||||
<string name="month_style_dense">Nahtlose Wochen</string>
|
||||
<string name="month_style_dense_summary">Die Wochen laufen ohne Unterbrechung weiter, jeder Monat geht ohne Lücke direkt in den nächsten über.</string>
|
||||
<string name="month_style_split">Geteilt</string>
|
||||
<string name="month_style_split_summary">Ein kompaktes Raster markiert die Tage mit Terminen, und der angetippte Tag wird darunter aufgelistet.</string>
|
||||
<string name="month_split_no_events">Nichts geplant</string>
|
||||
<string name="month_split_expand">Ganzen Monat zeigen</string>
|
||||
<string name="month_split_collapse">Termine des Tages zeigen</string>
|
||||
<string name="settings_event_duration">Standarddauer</string>
|
||||
<string name="settings_event_duration_hint">Wie lange ein neuer Termin dauert, bis du seine Endzeit änderst. Ganztägige Termine sind nicht betroffen.</string>
|
||||
<string name="settings_calendar_durations_title">Dauer pro Kalender</string>
|
||||
<string name="settings_calendar_durations_hint">Gib einem Kalender seine eigene Standarddauer – z. B. 8 Stunden für Arbeitsschichten.</string>
|
||||
<string name="settings_calendar_duration_use_default">Standarddauer verwenden (%1$s)</string>
|
||||
<string name="settings_allday_reminder_fires_at">Benachrichtigt um %1$s</string>
|
||||
<string name="settings_group_look">Aussehen & Verhalten</string>
|
||||
<string name="settings_group_data">Daten</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<string name="settings_backup_subtitle">Export, Import, automatische Sicherung</string>
|
||||
<string name="settings_section_widgets">Widgets & Kacheln</string>
|
||||
<string name="settings_widgets_hint">Einstellungen für die Widgets auf dem Startbildschirm und die Schnelleinstellungen-Kachel. Füge ein Widget hinzu, indem du lange auf den Startbildschirm drückst.</string>
|
||||
<string name="settings_section_backup">Sicherung & Wiederherstellung</string>
|
||||
<string name="settings_views_all_header">Alle Ansichten</string>
|
||||
<string name="settings_week_day_header">Woche & Tag</string>
|
||||
<string name="settings_default_view_hint">Die Ansicht, mit der Calendula startet.</string>
|
||||
<string name="settings_week_start_hint">Der Tag, mit dem jede Woche beginnt – in allen Ansichten und Widgets.</string>
|
||||
<string name="settings_time_format_hint">Wie Zeiten in der App geschrieben werden. Automatisch folgt deiner Systemeinstellung.</string>
|
||||
<string name="settings_past_events_hint">Was die Agenda mit Terminen macht, die bereits vorbei sind.</string>
|
||||
<string name="settings_dynamic_color_summary">Nimmt die Farben der App von deinem Hintergrundbild.</string>
|
||||
<string name="settings_about_privacy">Datenschutzerklärung</string>
|
||||
<string name="calendars_visibility_hint">Schalte einen Kalender aus, um ihn auf diesem Gerät zu verbergen – seine Termine verschwinden aus der App und er erinnert dich nicht mehr. Es ist derselbe Schalter, den deine anderen Kalender-Apps verwenden, sie verbergen ihn also ebenfalls. Nichts wird gelöscht, kein anderes Gerät ist betroffen, und du kannst ihn hier jederzeit wieder einschalten.</string>
|
||||
<string name="calendars_visibility_a11y">„%1$s“ anzeigen</string>
|
||||
<string name="calendars_visibility_notice_title">Einige Kalender sind ausgeschaltet</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula zeigt die Kalender, die für dieses Gerät eingeschaltet sind, und einige von deinen sind aus. Schalte sie unter Einstellungen → Kalender ein, um ihre Termine zu sehen.</string>
|
||||
<string name="calendar_picker_missing_title">Fehlt ein Kalender?</string>
|
||||
<string name="calendar_picker_missing_summary">Er kann ausgeschaltet, schreibgeschützt oder aus deinen Kontakten gefüllt sein – verwalte deine Kalender hier.</string>
|
||||
<string name="calendars_state_read_only">Schreibgeschützt</string>
|
||||
<string name="calendars_state_not_synced">Nicht auf dieses Gerät synchronisiert</string>
|
||||
<string name="calendars_state_managed">Aus deinen Kontakten gefüllt</string>
|
||||
<string name="calendars_managed_delete_locked">Dieser Kalender wird aus deinen Kontakten gefüllt, Calendula würde ihn bei der nächsten Synchronisierung neu anlegen. Schalte die besonderen Termine unter Einstellungen → Besondere Termine aus, um ihn zu löschen.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="onboarding_backup_skip_button">Nicht jetzt</string>
|
||||
<string name="onboarding_back">Zurück</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">%d Termin löschen?</item>
|
||||
<item quantity="other">%d Termine löschen?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d Termin gelöscht</item>
|
||||
<item quantity="other">%d Termine gelöscht</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -491,4 +491,5 @@
|
||||
<item quantity="many">Importando %d eventos</item>
|
||||
<item quantity="other">Importando %d eventos</item>
|
||||
</plurals>
|
||||
<string name="settings_soften_colors">Armoniza los colores del calendario</string>
|
||||
</resources>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<string name="event_detail_share">Partager</string>
|
||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||
@@ -95,14 +95,14 @@
|
||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||
@@ -178,7 +178,7 @@
|
||||
<string name="event_access_confidential">Confidentiel</string>
|
||||
<string name="event_attendee_organizer">Organisateur</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="reminder_default">Rappel par défaut</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute avant</item>
|
||||
@@ -283,7 +283,7 @@
|
||||
<string name="event_status_tentative">Provisoire</string>
|
||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||
@@ -331,14 +331,14 @@
|
||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_reminders">rappels d\'événements</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_default_reminder">rappel par défaut</string>
|
||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||
@@ -383,9 +383,9 @@
|
||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||
<string name="settings_section_about">A propos</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
@@ -411,7 +411,7 @@
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
@@ -429,8 +429,8 @@
|
||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||
@@ -481,7 +481,7 @@
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">créer un événement</string>
|
||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="crash_dialog_title">%1$s a planté</string>
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||
<string name="crash_dialog_report">rapport</string>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi.</string>
|
||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||
<string name="permission_retry_button">Riprova</string>
|
||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
||||
<string name="permission_benefit_private_title">Privato by design</string>
|
||||
<string name="permission_benefit_private_body">Nessun accesso a internet, nessun tracking: nulla lascia il tuo dispositivo.</string>
|
||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
|
||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||
@@ -301,8 +301,8 @@
|
||||
<string name="settings_language_auto">Lingua di sistema</string>
|
||||
<string name="settings_translate">Aiuta a tradurre</string>
|
||||
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||
<string name="settings_appearance_subtitle">Tema, colori, font</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default e comportamento</string>
|
||||
<string name="settings_section_about">A proposito</string>
|
||||
<string name="settings_license">Licenza</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -414,7 +414,7 @@
|
||||
<string name="agenda_range_this_week">Questa settimana</string>
|
||||
<string name="settings_reminders">Promemoria per gli eventi</string>
|
||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="settings_font_headings">Carattere del titolo</string>
|
||||
@@ -423,13 +423,13 @@
|
||||
<string name="settings_font_choose_file">Scegli file…</string>
|
||||
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||
<string name="settings_section_views">Viste</string>
|
||||
<string name="settings_section_views">Vista</string>
|
||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
|
||||
<string name="settings_views_subtitle">Vista di default, layout, ordine</string>
|
||||
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
||||
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
||||
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
||||
@@ -478,8 +478,8 @@
|
||||
<string name="reminder_day_yesterday">Ieri</string>
|
||||
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="settings_soften_colors">Attenua i colori del calendario</string>
|
||||
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
|
||||
<string name="settings_soften_colors">Armonizza i colori del calendario</string>
|
||||
<string name="settings_soften_colors_summary">Mantieni i colori del calendario ma attenua la luminosità, così gli eventi rimangono leggibili e i colori sono armonizzati. Deseleziona per mostrare i colori originali.</string>
|
||||
<string name="settings_week_numbers">Numeri di settimana</string>
|
||||
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
|
||||
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
|
||||
@@ -499,4 +499,151 @@
|
||||
<item quantity="many">Importando %d eventi</item>
|
||||
<item quantity="other">Importando %d eventi</item>
|
||||
</plurals>
|
||||
<string name="event_move_action">Sposta…</string>
|
||||
<string name="event_move_recurring_title">Sposta un evento ricorrente</string>
|
||||
<string name="event_move_done">Spostato al %1$s</string>
|
||||
<string name="event_move_undo">Annulla ripianificazione</string>
|
||||
<string name="event_move_undone">Ripianificazione annullata</string>
|
||||
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
|
||||
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato.</string>
|
||||
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
|
||||
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
|
||||
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
|
||||
<string name="event_edit_timezone_search">Seleziona fuso orario</string>
|
||||
<string name="event_edit_timezone_recent">Recenti</string>
|
||||
<string name="event_edit_timezone_all">Tutti i fusi orari</string>
|
||||
<string name="event_edit_timezone_none">Nessun fuso corrisponde a \"%1$s\"</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s nel tuo fuso orario</string>
|
||||
<string name="event_edit_recurrence_incomplete">Inserisci un numero tra 1 e 999</string>
|
||||
<string name="event_edit_recurrence_next">Prossimo evento: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Questa regola non si ripete</string>
|
||||
<string name="event_access_default_summary">In base all\'impostazione del calendario</string>
|
||||
<string name="event_access_public_summary">Chiunque acceda può vedere i dettagli completi</string>
|
||||
<string name="event_access_private_summary">Gli altri vedranno solo che sei impegnato</string>
|
||||
<string name="event_access_confidential_summary">Confidenziale; dipende dalle impostazioni dell\'account</string>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">Al più %1$s</string>
|
||||
<string name="onboarding_step_counter">Step %1$d di %2$d</string>
|
||||
<string name="onboarding_backup_title">I tuoi eventi rimangono qui</string>
|
||||
<string name="onboarding_backup_body">Nulla viene sincronizzato, perdere i dati presenti su questo dispositivo significa perdere i tuoi calendari. Calendula può fare un backup per te.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Una cartella a scelta</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">I backup sono file .ics. Mettili al sicuro sul cloud o su una scheda SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Giornaliero, in automatico</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula esporta i tuoi calendari locali in background. Selezione la frequenza nelle Impostazioni.</string>
|
||||
<string name="onboarding_backup_enable_button">Seleziona la cartella ed esegui il backup</string>
|
||||
<string name="onboarding_backup_skip_button">Non ora</string>
|
||||
<string name="onboarding_view_title">Cosa viene aperto per primo?</string>
|
||||
<string name="onboarding_month_style_title">Seleziona la vista Mese</string>
|
||||
<string name="onboarding_view_continue_button">Continua</string>
|
||||
<string name="onboarding_back">Indietro</string>
|
||||
<string name="onboarding_visibility_button">Capito</string>
|
||||
<string name="onboarding_done_title">Tutto fatto</string>
|
||||
<string name="onboarding_done_body">I tuoi calendari sono pronti. Tutto ciò che hai scelto può essere modificato più tardi nelle Impostazioni.</string>
|
||||
<string name="onboarding_done_button">Apri i miei calendari</string>
|
||||
<string name="agenda_span_starts">Inizia alle %1$s</string>
|
||||
<string name="agenda_span_ends">Finisce alle %1$s</string>
|
||||
<string name="today_jump_action">Vai ad oggi</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selezionato</item>
|
||||
<item quantity="many">%d selezionati</item>
|
||||
<item quantity="other">%d selezionati</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Trovato nelle descrizioni</string>
|
||||
<string name="search_selection_close">Cancella selezione</string>
|
||||
<string name="search_select_all">Seleziona tutti</string>
|
||||
<string name="search_delete_selected">Elimina i selezionati</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Eliminare l\'evento?</item>
|
||||
<item quantity="other">Eliminare l\'evento?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">Evento eliminato</item>
|
||||
<item quantity="other">Evento eliminato</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
|
||||
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
|
||||
<string name="event_move_occurrence_only">Ripianificare l\'intera serie cambia i giorni in cui cade, quindi puoi ripianificare solo questo evento.</string>
|
||||
<string name="settings_theme_hint">Tema chiaro o scuro. La modifica viene applicata immediatamente.</string>
|
||||
<string name="settings_theme_system_summary">Al momento %1$s</string>
|
||||
<string name="settings_font_specimen_heading">Giovedì, 14 Maggio</string>
|
||||
<string name="settings_font_specimen_body">Team review alle 10:00, poi pranzo con Ugo al bar a Piazza di Spagna.</string>
|
||||
<string name="settings_week_start_auto_summary">Al momento %1$s</string>
|
||||
<string name="settings_today_toolbar">Pulsante Oggi nella toolbar</string>
|
||||
<string name="settings_today_toolbar_summary">Mostra il pulsante \"vai alla data\" nella toolbar invece che come pulsante flottante</string>
|
||||
<string name="settings_app_name">Nome app</string>
|
||||
<string name="settings_app_name_summary">Mostra Calendula come \"Calendario\" nel launcher. Il nome cambia solo nel launcher; l\'icona potrebbe spostarsi dopo la modifica.</string>
|
||||
<string name="settings_time_format_auto_summary">Da sistema: %1$s</string>
|
||||
<string name="settings_timeline_scale">Altezza ore</string>
|
||||
<string name="settings_timeline_scale_hint">Spazio occupato verticalmente da un\'ora nella vista Giorno e Settimana. Puoi anche modificare l\'altezza come preverisci pizzicando la timeline.</string>
|
||||
<string name="timeline_scale_fit_day">Occupa la giornata intera</string>
|
||||
<string name="timeline_scale_fit_day_summary">Tutte le 24 ore in una schermata, senza scrolling</string>
|
||||
<string name="timeline_scale_compact">Compatto</string>
|
||||
<string name="timeline_scale_compact_summary">Più ore per schermata, blocchi più piccoli</string>
|
||||
<string name="timeline_scale_regular">Regolare</string>
|
||||
<string name="timeline_scale_regular_summary">La spaziatura standard</string>
|
||||
<string name="timeline_scale_comfortable">Comodo</string>
|
||||
<string name="timeline_scale_comfortable_summary">Blocchi più ampi, più scrolling</string>
|
||||
<string name="timeline_scale_custom">Personalizza</string>
|
||||
<string name="timeline_scale_custom_summary">L\'altezza a cui hai portato la timeline</string>
|
||||
<string name="settings_drag_to_reschedule">Trascina per ripianificare</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Ripianifica un evento trascinandolo in un altro giorno o ora</string>
|
||||
<string name="settings_past_events_sample_morning">Team review</string>
|
||||
<string name="settings_past_events_sample_midday">Pranzo con Ugo</string>
|
||||
<string name="settings_past_events_sample_evening">Allenamento di scacchi</string>
|
||||
<string name="settings_widget_size">Dimensioni del widget Agenda</string>
|
||||
<string name="settings_widget_size_hint">Dimensioni del testo del widget agenda. Il widget mensile non ha impostazioni, si adatta da solo alla dimensione data.</string>
|
||||
<string name="settings_widget_size_small">Piccolo</string>
|
||||
<string name="settings_widget_size_medium">Medio</string>
|
||||
<string name="settings_widget_size_large">Grande</string>
|
||||
<string name="settings_widget_size_extra_large">Molto grande</string>
|
||||
<string name="settings_widget_size_summary">Carattere %1$d%%</string>
|
||||
<string name="settings_month_header">Vista Mese</string>
|
||||
<string name="settings_month_view_style">Stile vista Mese</string>
|
||||
<string name="month_style_paged">Pagine</string>
|
||||
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
|
||||
<string name="month_style_continuous">Scorrimento</string>
|
||||
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
|
||||
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
|
||||
<string name="settings_about_privacy">Politica per la Privacy</string>
|
||||
<string name="calendars_visibility_hint">Deseleziona un calendario per non mostrarlo sul dispositivo: scompariranno gli eventi e non riceverai più promemoria. Questa impostazione agisce anche su altre app calendario. Nulla viene eliminato e il calendario non scompare da nessun altro dispositivo. Puoi riselezionarlo in ogni momento.</string>
|
||||
<string name="calendars_visibility_a11y">Mostra \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Alcuni calendari non sono attivi</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula mostra solo i calendari che sono attivi sul dispositivo, e alcuni non lo sono. Per vedere gli eventi di quest\'ultimi, attivali dalle Impostazioni.</string>
|
||||
<string name="calendar_picker_missing_title">Manca un calendario?</string>
|
||||
<string name="calendar_picker_missing_summary">Può essere disattivato, in sola lettura o compilato in base ai tuoi contatti. Gestisci qui i tuoi calendari.</string>
|
||||
<string name="calendars_state_read_only">Sola lettura</string>
|
||||
<string name="calendars_state_not_synced">Non sincronizzato su questo dispositivo</string>
|
||||
<string name="calendars_state_managed">Compilato dai tuoi contatti</string>
|
||||
<string name="calendars_managed_delete_locked">Questo calendario è compilato in base ai tuoi contatti, quindi Calendula lo creerà di nuovo alla prossima sincronizzazione. Per eliminarlo disattiva l\'opzione \"Date Speciali\" nelle Impostazioni.</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="month_style_continuous_summary">Ogni mese è sotto la sua testata, con un piccolo spazio a separarlo dal mese successivo.</string>
|
||||
<string name="month_style_dense">Settimane continue</string>
|
||||
<string name="month_style_dense_summary">Le settimane scorrono senza interruzione, ogni mese succede a quello precedente.</string>
|
||||
<string name="month_style_split">Diviso</string>
|
||||
<string name="month_style_split_summary">Una griglia compatta indica i giorni con eventi. Gli eventi del giorno selezionato sono elencati sotto.</string>
|
||||
<string name="month_split_no_events">Nulla in programma</string>
|
||||
<string name="month_split_expand">Mostra il mese completo</string>
|
||||
<string name="month_split_collapse">Mostra gli eventi del giorno</string>
|
||||
<string name="settings_event_duration">Durata standard</string>
|
||||
<string name="settings_event_duration_hint">Quanto durano gli eventi se non modifichi l\'orario di fine. Non ha effetto sugli eventi giornalieri.</string>
|
||||
<string name="settings_calendar_durations_title">Durata per calendario</string>
|
||||
<string name="settings_calendar_durations_hint">Assegna a ciascun calendario la sua durata standard (e.g. 8 ore per il calendario dei turni lavorativi).</string>
|
||||
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Usa la durata standard (%1$s)</string>
|
||||
<string name="settings_allday_reminder_fires_at">Promemoria alle %1$s</string>
|
||||
<string name="settings_group_look">Aspetto e Comportamento</string>
|
||||
<string name="settings_group_data">Dati</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<string name="settings_group_about">A proposito</string>
|
||||
<string name="settings_widgets_subtitle">Widget Agenda e riquadro Impostazioni rapide</string>
|
||||
<string name="settings_backup_subtitle">Export, Import, backup automatici</string>
|
||||
<string name="settings_section_widgets">Widget & Riquardi</string>
|
||||
<string name="settings_widgets_hint">Impostazioni per i widget e il riquardo delle Impostazioni rapide. Aggiungi i widget tenendo premuto sulla home.</string>
|
||||
<string name="settings_section_backup">Backup e ripristino</string>
|
||||
<string name="settings_views_all_header">Tutte le viste</string>
|
||||
<string name="settings_week_day_header">Giorno e settimana</string>
|
||||
<string name="settings_default_view_hint">La vista di default all\'apertura dell\'app.</string>
|
||||
<string name="settings_week_start_hint">Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget.</string>
|
||||
<string name="settings_time_format_hint">Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema.</string>
|
||||
</resources>
|
||||
|
||||
82
app/src/main/res/values-ja/strings.xml
Normal file
82
app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_tagline">モダンなカレンダー。</string>
|
||||
<string name="state_loading">読み込み中…</string>
|
||||
<string name="state_retry">リトライ</string>
|
||||
<string name="month_today_action">今日</string>
|
||||
<string name="month_action_settings">設定</string>
|
||||
<string name="event_detail_description">説明</string>
|
||||
<string name="event_edit_add_reminder">リマインダーを追加</string>
|
||||
<string name="event_edit_save">保存</string>
|
||||
<string name="event_detail_all_day">終日</string>
|
||||
<string name="view_month">月</string>
|
||||
<string name="view_week">週</string>
|
||||
<string name="view_day">日</string>
|
||||
<string name="settings_section_backup">バックアップと復元</string>
|
||||
<string name="settings_special_dates_type_birthday">誕生日</string>
|
||||
<string name="settings_special_dates_sync_now">今すぐ同期</string>
|
||||
<string name="settings_special_dates_last_synced">最終同期 %1$s</string>
|
||||
<string name="settings_section_notifications">通知</string>
|
||||
<string name="drawer_jump_to_date">日付に移動</string>
|
||||
<string name="state_failure_permission">カレンダーへのアクセス権限が必要です。</string>
|
||||
<string name="permission_rationale_body">Calendulaで予定を表示・管理するには、カレンダーへのアクセス許可が必要です。</string>
|
||||
<string name="permission_request_button">カレンダーへのアクセスを許可</string>
|
||||
<string name="permission_denied_title">カレンダーへのアクセスが拒否されました</string>
|
||||
<string name="permission_denied_body">Calendulaはカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をお願いします。</string>
|
||||
<string name="permission_open_settings_button">システム設定を開く</string>
|
||||
<string name="permission_retry_button">再試行</string>
|
||||
<string name="permission_benefit_private_title">プライバシーを重視した設計</string>
|
||||
<string name="permission_benefit_private_body">インターネットへのアクセス権限は不要で、テレメトリも送信しません。すべてのデータはスマートフォン内に保持されます。</string>
|
||||
<string name="permission_benefit_sync_title">すべてのカレンダーをひとつに</string>
|
||||
<string name="permission_benefit_sync_body">Google、CalDAV、ローカルなど、デバイスに同期されているすべてのカレンダーが自動的に表示されます。</string>
|
||||
<string name="event_detail_delete">削除</string>
|
||||
<string name="event_detail_share">共有</string>
|
||||
<string name="event_detail_duplicate">複製</string>
|
||||
<string name="event_delete_title">予定を削除しますか?</string>
|
||||
<string name="event_delete_body">この予定はカレンダーだけでなく、同期しているすべてのデバイスからも削除されます。</string>
|
||||
<string name="event_delete_recurring_title">定期的な予定を削除</string>
|
||||
<string name="event_delete_option_occurrence">この予定のみ</string>
|
||||
<string name="event_delete_option_following">この予定とそれ以降すべて</string>
|
||||
<string name="event_edit_recurring_title">定期的な予定を編集</string>
|
||||
<string name="event_delete_failed">予定を削除できませんでした</string>
|
||||
<string name="recurrence_unit_years">毎年</string>
|
||||
<string name="reminder_unit_weeks">週</string>
|
||||
<string name="recurrence_unit_weeks">毎週</string>
|
||||
<string name="recurrence_unit_months">毎月</string>
|
||||
<string name="reminder_unit_minutes">分</string>
|
||||
<string name="reminder_unit_hours">時間</string>
|
||||
<string name="reminder_unit_days">日</string>
|
||||
<string name="recurrence_unit_days">毎日</string>
|
||||
<string name="reminder_day_yesterday">前日</string>
|
||||
<string name="settings_section_widgets">ウィジットとタイル</string>
|
||||
<string name="settings_week_start">週の開始日</string>
|
||||
<string name="settings_week_numbers">週番号</string>
|
||||
<string name="settings_theme_light">ライト</string>
|
||||
<string name="settings_theme_dark">ダーク</string>
|
||||
<string name="settings_font_headings">見出しのフォント</string>
|
||||
<string name="settings_font_body">本文のフォント</string>
|
||||
<string name="settings_font_system">システムの標準</string>
|
||||
<string name="settings_font_choose_file">ファイルを選択…</string>
|
||||
<string name="settings_week_start_auto">自動</string>
|
||||
<string name="settings_week_start_auto_summary">現在は %1$s</string>
|
||||
<string name="settings_app_name">アプリの名前</string>
|
||||
<string name="settings_section_language">言語</string>
|
||||
<string name="settings_language">アプリの言語</string>
|
||||
<string name="settings_language_auto">システムの既定</string>
|
||||
<string name="settings_group_app">アプリ</string>
|
||||
<string name="settings_appearance_subtitle">テーマ、カラー、フォント</string>
|
||||
<string name="settings_about_privacy">プライバシーポリシー</string>
|
||||
<string name="settings_about_support">開発を支援する</string>
|
||||
<string name="settings_license">ライセンス</string>
|
||||
<string name="settings_report_problem">問題を報告</string>
|
||||
<string name="settings_title">設定</string>
|
||||
<string name="settings_theme">テーマ</string>
|
||||
<string name="settings_theme_system">システム</string>
|
||||
<string name="settings_dynamic_color">ダイナミックカラー</string>
|
||||
</resources>
|
||||
8
app/src/main/res/values-night/themes.xml
Normal file
8
app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<!-- Dark backdrop needs light status-bar icons; the base theme's `true` is
|
||||
only right for the light window background. -->
|
||||
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
651
app/src/main/res/values-pt-rBR/strings.xml
Normal file
651
app/src/main/res/values-pt-rBR/strings.xml
Normal file
@@ -0,0 +1,651 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="state_loading">Carregando…</string>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="state_failure_permission">O acesso ao calendário é necessário.</string>
|
||||
<string name="state_failure_no_calendars">Nenhum calendário configurado.</string>
|
||||
<string name="state_failure_no_calendars_action">Abrir configurações do calendário do sistema</string>
|
||||
<string name="state_retry">Tentar novamente</string>
|
||||
<string name="state_failure_permission_action">Conceder acesso</string>
|
||||
<string name="app_tagline">Um calendário moderno.</string>
|
||||
<string name="state_failure_unknown">Algo deu errado.</string>
|
||||
<string name="state_failure_provider">Não foi possível ler o calendário.</string>
|
||||
<string name="permission_rationale_title">Veja todos os seus eventos, lindamente</string>
|
||||
<string name="permission_rationale_body">Calendula precisa acessar seu calendário para mostrar e gerenciar eventos. É tudo que ele pede — e nada sai do seu dispositivo.</string>
|
||||
<string name="permission_request_button">Conceder acesso ao calendário</string>
|
||||
<string name="permission_denied_title">Acesso ao calendário negado</string>
|
||||
<string name="permission_denied_body">Calendula não consegue mostrar eventos sem acesso ao calendário. Você pode conceder novamente nas configurações do sistema.</string>
|
||||
<string name="permission_open_settings_button">Abrir configurações do sistema</string>
|
||||
<string name="permission_retry_button">Tentar novamente</string>
|
||||
<string name="permission_benefit_private_title">Fica no seu dispositivo</string>
|
||||
<string name="permission_benefit_private_body">Seus calendários são lidos localmente e nunca saem do celular.</string>
|
||||
<string name="permission_benefit_sync_title">Todos os seus calendários, juntos</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, local — tudo sincronizado ao dispositivo apenas aparece.</string>
|
||||
<string name="permission_benefit_privacy_title">Nenhum rastreamento, jamais</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero dados para análise, sem anúncios.</string>
|
||||
<string name="permission_privacy_footnote">Fica no seu dispositivo · sem permissão de internet</string>
|
||||
<string name="month_prev">Mês anterior</string>
|
||||
<string name="month_next">Próximo mês</string>
|
||||
<string name="month_today_action">Hoje</string>
|
||||
<string name="month_more_actions">Mais ações</string>
|
||||
<string name="month_open_menu">Abrir menu</string>
|
||||
<string name="month_action_settings">Configurações</string>
|
||||
<string name="month_a11y_today_prefix">Hoje</string>
|
||||
<string name="week_today_action">Esta semana</string>
|
||||
<string name="week_number_label">Sem</string>
|
||||
<string name="day_today_action">Hoje</string>
|
||||
<string name="event_detail_back">Voltar</string>
|
||||
<string name="event_detail_edit">Editar</string>
|
||||
<string name="event_detail_delete">Excluir</string>
|
||||
<string name="event_detail_share">Compartilhar</string>
|
||||
<string name="event_detail_duplicate">Duplicar</string>
|
||||
<string name="event_share_chooser_title">Compartilhar evento</string>
|
||||
<string name="event_share_failed">Não foi possível compartilhar este evento.</string>
|
||||
<string name="event_delete_title">Excluir evento?</string>
|
||||
<string name="event_delete_body">O evento sairá do calendário e de todos os aparelhos que o sincronizam.</string>
|
||||
<string name="event_delete_recurring_title">Excluir evento recorrente</string>
|
||||
<string name="event_delete_option_occurrence">Apenas este evento</string>
|
||||
<string name="event_delete_option_following">Este evento e todos os próximos</string>
|
||||
<string name="event_delete_option_series">Todos os eventos nesta série</string>
|
||||
<string name="event_edit_recurring_title">Editar evento recorrente</string>
|
||||
<string name="event_delete_failed">Não foi possível excluir o evento</string>
|
||||
<string name="event_delete_write_denied">Calendula requer permissão de escrita para excluir eventos</string>
|
||||
<string name="dialog_cancel">Cancelar</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="event_edit_new_title">Novo evento</string>
|
||||
<string name="event_edit_close">Fechar</string>
|
||||
<string name="event_edit_save">Salvar</string>
|
||||
<string name="event_edit_title_hint">Adicionar título</string>
|
||||
<string name="event_edit_managed_hint">Gerenciado por “%1$s” — o título, data e frequência são sincronizados dos seus contatos. Lembretes, locais e notas são seus para editar.</string>
|
||||
<string name="event_edit_starts">Inicia</string>
|
||||
<string name="event_edit_ends">Termina</string>
|
||||
<string name="event_edit_error_end_before_start">Termina antes de iniciar</string>
|
||||
<string name="event_edit_error_no_calendar">Nenhum calendário disponível para escrita</string>
|
||||
<string name="event_edit_save_failed">Não foi possível salvar o evento</string>
|
||||
<string name="event_edit_write_denied">Calendula requer permissão de escrita para criar eventos</string>
|
||||
<string name="event_edit_more_fields">Mais campos</string>
|
||||
<string name="event_edit_add">Adicionar</string>
|
||||
<string name="event_edit_add_reminder">Adicionar lembrete</string>
|
||||
<string name="event_edit_remove_reminder">Remover lembrete</string>
|
||||
<string name="event_edit_attendees">Convidados</string>
|
||||
<string name="event_edit_add_guest">Adicionar convidado</string>
|
||||
<string name="event_edit_add_guest_hint">Adicionar convidado via e-mail…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Adicionar dos contatos</string>
|
||||
<string name="event_edit_location_from_contacts">Escolher endereço dos contatos</string>
|
||||
<string name="event_edit_remove_guest">Remover convidado</string>
|
||||
<string name="event_edit_attendee_required">Obrigatório</string>
|
||||
<string name="event_edit_attendee_optional">Opcional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula não manda convites. Sua conta de calendário poderá enviar e-mails para os convidados quando sincronizar.</string>
|
||||
<string name="event_edit_attendees_note_local">Armazenado neste dispositivo. Ninguém é notificado.</string>
|
||||
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||
<string name="reminder_unit_minutes">minutos</string>
|
||||
<string name="reminder_unit_hours">horas</string>
|
||||
<string name="reminder_unit_days">dias</string>
|
||||
<string name="reminder_unit_weeks">semanas</string>
|
||||
<string name="event_edit_availability">Disponibilidade</string>
|
||||
<string name="event_edit_visibility">Visibilidade</string>
|
||||
<string name="event_edit_timezone_device">Fuso horário do dispositivo</string>
|
||||
<string name="event_edit_timezone_device_summary">Segue onde você estiver</string>
|
||||
<string name="event_edit_timezone_search">Buscar fusos</string>
|
||||
<string name="event_edit_timezone_recent">Recente</string>
|
||||
<string name="event_edit_timezone_all">Todos os fusos</string>
|
||||
<string name="event_edit_timezone_none">Nenhum fuso horário encontrado com “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s seu horário</string>
|
||||
<string name="event_edit_color">Cor</string>
|
||||
<string name="event_edit_color_default">Cor do calendário</string>
|
||||
<string name="event_edit_color_custom">Cor personalizada</string>
|
||||
<string name="event_edit_color_reset">Redefinir</string>
|
||||
<string name="event_edit_color_unsupported">Não disponível neste calendário</string>
|
||||
<string name="event_edit_color_unsupported_hint">Este calendário não publica um esquema de cores. Você pode permitir cores personalizadas nas Configurações.</string>
|
||||
<string name="event_edit_color_sync_warning">Este calendário pode remover ou sobrescrever a cor na próxima sincronização.</string>
|
||||
<string name="event_edit_conflict_title">Evento alterado em outro lugar</string>
|
||||
<string name="event_edit_conflict_body">Enquanto estava editando, este evento foi alterado — por sincronia ou por outro app. O que deve acontecer com suas mudanças?</string>
|
||||
<string name="event_edit_conflict_overwrite">Salvar mudanças</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Apenas campos editados sobrescrevem a mudança externa</string>
|
||||
<string name="event_edit_conflict_discard">Descartar mudanças</string>
|
||||
<string name="event_edit_conflict_discard_hint">Evento fica como está agora</string>
|
||||
<string name="event_edit_gone_title">Evento excluído</string>
|
||||
<string name="event_edit_gone_body">Este evento foi excluído enquanto isso, por exemplo, em outro aparelho. Suas mudanças não podem mais ser salvas.</string>
|
||||
<string name="import_reminder_prompt_title">Aplicar seu lembrete padrão?</string>
|
||||
<string name="import_reminder_prompt_body_none">Este evento foi importado sem lembretes.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Este evento foi importado com %1$d lembrete.</item>
|
||||
<item quantity="many">Este evento foi importado com %1$d lembretes.</item>
|
||||
<item quantity="other">Este evento foi importado com %1$d lembretes.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Aplicar padrão</string>
|
||||
<string name="import_reminder_prompt_keep">Manter assim</string>
|
||||
<string name="event_edit_recurrence_none">Não se repete</string>
|
||||
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||
<string name="event_edit_recurrence_every">A cada</string>
|
||||
<string name="recurrence_unit_days">dias</string>
|
||||
<string name="recurrence_unit_weeks">semanas</string>
|
||||
<string name="recurrence_unit_months">meses</string>
|
||||
<string name="recurrence_unit_years">anos</string>
|
||||
<string name="event_edit_recurrence_incomplete">Insira um número de 1 a 999</string>
|
||||
<string name="event_edit_recurrence_next">Próximo: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Esta regra nunca se repete</string>
|
||||
<string name="event_edit_recurrence_ends">Termina</string>
|
||||
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||
<string name="event_edit_recurrence_end_until">Em uma data</string>
|
||||
<string name="event_edit_recurrence_end_count">Após um número de vezes</string>
|
||||
<string name="event_edit_recurrence_times">vezes</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Repetições terminam antes do evento começar</string>
|
||||
<string name="event_availability_busy">Ocupado</string>
|
||||
<string name="event_access_default">Padrão</string>
|
||||
<string name="event_access_public">Público</string>
|
||||
<string name="event_detail_all_day">O dia todo</string>
|
||||
<string name="event_detail_calendar">Calendário</string>
|
||||
<string name="event_detail_calendar_unknown">Calendário desconhecido</string>
|
||||
<string name="event_detail_location">Local</string>
|
||||
<string name="event_detail_description">Descrição</string>
|
||||
<string name="event_detail_attendees">Participantes</string>
|
||||
<string name="event_detail_recurrence">Recorrência</string>
|
||||
<string name="event_detail_recurring">Evento recorrente</string>
|
||||
<string name="recurrence_daily">Todo dia</string>
|
||||
<string name="recurrence_weekly">Toda semana</string>
|
||||
<string name="recurrence_monthly">Todo mês</string>
|
||||
<string name="recurrence_yearly">Todo ano</string>
|
||||
<string name="recurrence_every_n_days">A cada %1$d dias</string>
|
||||
<string name="recurrence_every_n_weeks">A cada %1$d semanas</string>
|
||||
<string name="recurrence_every_n_months">A cada %1$d meses</string>
|
||||
<string name="recurrence_every_n_years">A cada %1$d anos</string>
|
||||
<string name="recurrence_on_days">%1$s em %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s até %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d vezes</string>
|
||||
<string name="event_detail_not_found">Este evento não existe mais.</string>
|
||||
<string name="event_attendee_accepted">Aceitou</string>
|
||||
<string name="event_attendee_declined">Rejeitou</string>
|
||||
<string name="event_attendee_tentative">Não decidiu</string>
|
||||
<string name="event_attendee_needs_action">Sem resposta</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Lembretes</string>
|
||||
<string name="event_detail_timezone">Fuso horário</string>
|
||||
<string name="event_status_cancelled">Cancelado</string>
|
||||
<string name="event_availability_free">Disponível</string>
|
||||
<string name="event_access_private">Privado</string>
|
||||
<string name="event_access_confidential">Confidencial</string>
|
||||
<string name="event_access_default_summary">O que este calendário normalmente faz</string>
|
||||
<string name="event_access_public_summary">Todos com acesso veem tudo</string>
|
||||
<string name="event_access_private_summary">Outros veem apenas que está ocupado</string>
|
||||
<string name="event_access_confidential_summary">Marcado como confidencial; o que significa depende da conta</string>
|
||||
<string name="event_attendee_organizer">Organizador</string>
|
||||
<string name="event_attendee_optional">Opcional</string>
|
||||
<string name="event_attendee_resource">Recurso</string>
|
||||
<string name="event_detail_self_response">Sua resposta: %1$s</string>
|
||||
<string name="reminder_at_time">Na hora do evento</string>
|
||||
<string name="reminder_default">Lembrete padrão</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minuto antes</item>
|
||||
<item quantity="many">%d minutos antes</item>
|
||||
<item quantity="other">%d minutos antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d hora antes</item>
|
||||
<item quantity="many">%d horas antes</item>
|
||||
<item quantity="other">%d horas antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d dia antes</item>
|
||||
<item quantity="many">%d dias antes</item>
|
||||
<item quantity="other">%d dias antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d semana antes</item>
|
||||
<item quantity="many">%d semanas antes</item>
|
||||
<item quantity="other">%d semanas antes</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuto</item>
|
||||
<item quantity="many">%d minutos</item>
|
||||
<item quantity="other">%d minutos</item>
|
||||
</plurals>
|
||||
<string name="settings_special_dates_grant">Conceder acesso</string>
|
||||
<string name="event_move_action">Mover…</string>
|
||||
<string name="event_move_recurring_title">Mover evento recorrente</string>
|
||||
<string name="event_move_occurrence_only">Mover a série inteira mudaria os dias em que o evento cai, então somente este evento pode se mover.</string>
|
||||
<string name="event_move_done">Movido para %1$s</string>
|
||||
<string name="event_move_undo">Desfazer</string>
|
||||
<string name="event_move_undone">Mover desfeito</string>
|
||||
<string name="event_move_failed">Não foi possível mover o evento</string>
|
||||
<string name="event_move_write_denied">O Calendula precisa de permissão de escrita para mover eventos</string>
|
||||
<string name="event_move_gone">Este evento não existe mais</string>
|
||||
<string name="event_move_blocked_series_end">Impossível mover um evento após o fim de sua série</string>
|
||||
<string name="event_status_tentative">Talvez</string>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hora</item>
|
||||
<item quantity="many">%d horas</item>
|
||||
<item quantity="other">%d horas</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<string name="duration_custom_max">No máximo %1$s</string>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d semana</item>
|
||||
<item quantity="many">%d semanas</item>
|
||||
<item quantity="other">%d semanas</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Sem título)</string>
|
||||
<string name="onboarding_step_counter">Passo %1$d de %2$d</string>
|
||||
<string name="onboarding_backup_title">Seus eventos vivem somente aqui</string>
|
||||
<string name="onboarding_backup_body">Nada neste aparelho sincroniza para uma conta, então perdê-lo levaria seu calendário junto. O Calendula pode criar um backup para você.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">Uma pasta à sua escolha</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Os backups são arquivos .ics — coloque-os em um lugar para sincronizar, ou um cartão SD.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Uma vez por dia, sozinho</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">O Calendula exporta seus calendários locais em segundo plano. Mude a frequência em Ajustes.</string>
|
||||
<string name="onboarding_backup_enable_button">Escolher pasta e fazer backup</string>
|
||||
<string name="onboarding_backup_skip_button">Agora não</string>
|
||||
<string name="onboarding_view_title">O que deve abrir primeiro?</string>
|
||||
<string name="onboarding_month_style_title">Como o Mês deve se parecer?</string>
|
||||
<string name="onboarding_view_continue_button">Continuar</string>
|
||||
<string name="onboarding_back">Voltar</string>
|
||||
<string name="onboarding_visibility_button">Entendi</string>
|
||||
<string name="onboarding_done_title">Tudo pronto</string>
|
||||
<string name="onboarding_done_body">Seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois em Ajustes.</string>
|
||||
<string name="onboarding_done_button">Abrir meu calendário</string>
|
||||
<string name="reminder_channel_name">Lembretes de evento</string>
|
||||
<string name="reminder_channel_description">Notificações na hora dos lembretes dos seus eventos</string>
|
||||
<string name="reminder_onboarding_title">Nunca perca um evento</string>
|
||||
<string name="reminder_onboarding_body">O Android não mostra lembretes sozinho — um app de calendário deve fazer isso. Deixe o Calendula cuidar disso.</string>
|
||||
<string name="reminder_benefit_delivery_title">Lembretes, entregues</string>
|
||||
<string name="reminder_benefit_delivery_body">Todos os seus lembretes de evento chegam em notificação, na hora certa.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Usando outro app de calendário?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Se outro app também postar lembretes, você verá eles em dobro — desligue aqui ou lá.</string>
|
||||
<string name="reminder_benefit_reversible_title">Mude a qualquer hora</string>
|
||||
<string name="reminder_benefit_reversible_body">O botão está em Ajustes, dentro de Notificações.</string>
|
||||
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
|
||||
<string name="reminder_onboarding_skip_button">Agora não</string>
|
||||
<string name="reminder_action_snooze">Soneca</string>
|
||||
<string name="reminder_action_dismiss">Dispensar</string>
|
||||
<string name="reminder_day_tomorrow">Amanhã</string>
|
||||
<string name="reminder_day_yesterday">Ontem</string>
|
||||
<string name="view_month">Mês</string>
|
||||
<string name="view_week">Semana</string>
|
||||
<string name="view_day">Dia</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vista</string>
|
||||
<string name="drawer_jump_to_date">Ir para data</string>
|
||||
<string name="agenda_today_action">Hoje</string>
|
||||
<string name="agenda_header_today">Hoje</string>
|
||||
<string name="agenda_header_tomorrow">Amanhã</string>
|
||||
<string name="agenda_empty_title">Tudo em dia</string>
|
||||
<string name="agenda_no_more_today">Sem mais eventos hoje</string>
|
||||
<string name="agenda_span_starts">Começa às %1$s</string>
|
||||
<string name="agenda_span_ends">Termina às %1$s</string>
|
||||
<string name="today_jump_action">Ir para Hoje</string>
|
||||
<string name="search_action">Buscar</string>
|
||||
<string name="search_hint">Buscar eventos</string>
|
||||
<string name="search_back">Voltar</string>
|
||||
<string name="search_clear">Limpar</string>
|
||||
<string name="search_idle_hint">Busque seus eventos por título, local ou notas.</string>
|
||||
<string name="search_empty">Nenhum evento corresponde a \"%1$s\".</string>
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selecionado</item>
|
||||
<item quantity="many">%d selecionados</item>
|
||||
<item quantity="other">%d selecionados</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Encontrado em descrições</string>
|
||||
<string name="search_selection_close">Cancelar seleção</string>
|
||||
<string name="search_select_all">Selecionar tudo</string>
|
||||
<string name="search_delete_selected">Deletar selecionados</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Deletar %d evento?</item>
|
||||
<item quantity="many">Deletar %d eventos?</item>
|
||||
<item quantity="other">Deletar %d eventos?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d evento deletado</item>
|
||||
<item quantity="many">%d eventos deletados</item>
|
||||
<item quantity="other">%d eventos deletados</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d deletado, %2$d não pôde ser deletado</string>
|
||||
<string name="search_delete_denied_partial">%1$d deletado, depois permissão de escrita removida</string>
|
||||
<string name="widget_agenda_title">A seguir</string>
|
||||
<string name="widget_agenda_label">Agenda do Calendula</string>
|
||||
<string name="widget_month_label">Mês do Calendula</string>
|
||||
<string name="widget_refresh">Atualizar</string>
|
||||
<string name="widget_new_event">Novo evento</string>
|
||||
<string name="widget_needs_permission">Abra o Calendula para conceder acesso ao calendário</string>
|
||||
<string name="widget_prev_month">Mês passado</string>
|
||||
<string name="widget_next_month">Mês que vem</string>
|
||||
<string name="widget_today">Hoje</string>
|
||||
<string name="filter_title">Calendários</string>
|
||||
<string name="settings_title">Ajustes</string>
|
||||
<string name="settings_back">Voltar</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="settings_section_appearance">Aparência</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Sistema</string>
|
||||
<string name="settings_theme_light">Claro</string>
|
||||
<string name="settings_theme_dark">Escuro</string>
|
||||
<string name="settings_theme_hint">Se o app é claro ou escuro. Aplica imediatamente.</string>
|
||||
<string name="settings_theme_system_summary">No momento %1$s</string>
|
||||
<string name="settings_default_view">Vista padrão</string>
|
||||
<string name="settings_dynamic_color">Cor dinâmica</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requer Android 12 ou superior</string>
|
||||
<string name="settings_soften_colors">Harmonizar cores do calendário</string>
|
||||
<string name="settings_soften_colors_summary">Mantenha cada tom do calendário até mesmo sem brilho, para que cada evento continue legível e as cores combinem. Desative para mostrar as cores cruas da raiz do calendário.</string>
|
||||
<string name="settings_font_headings">Fonte dos cabeçalhos</string>
|
||||
<string name="settings_font_body">Fonte do corpo</string>
|
||||
<string name="settings_font_system">Padrão do sistema</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hiperlegível</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Escolher arquivo…</string>
|
||||
<string name="settings_font_custom_selected">Fonte personalizada</string>
|
||||
<string name="settings_font_import_failed">Não pôde ler arquivo como fonte</string>
|
||||
<string name="settings_font_specimen_heading">Quinta-feira, 14 de maio</string>
|
||||
<string name="settings_font_specimen_body">Reunião de equipe às 10:00, depois almoço com Robin no café em Markplatz.</string>
|
||||
<string name="settings_week_start">A semana inicia em</string>
|
||||
<string name="settings_week_start_auto">Automático</string>
|
||||
<string name="settings_week_start_auto_summary">No momento %1$s</string>
|
||||
<string name="settings_week_numbers">Números das semanas</string>
|
||||
<string name="settings_week_numbers_summary">Mostrar números das semanas na vista do mês</string>
|
||||
<string name="settings_today_toolbar">Botão \"Hoje\" na barra de ferramentas</string>
|
||||
<string name="settings_today_toolbar_summary">Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante</string>
|
||||
<string name="settings_app_name">Nome do app</string>
|
||||
<string name="settings_app_name_summary">Mostrar o Calendula como \"Calendário\" no seu launcher. Somente o nome do launcher muda; o ícone pode mudar para outro lugar após a troca.</string>
|
||||
<string name="settings_time_format">Formato de hora</string>
|
||||
<string name="settings_time_format_auto">Automático</string>
|
||||
<string name="settings_time_format_12h">12 horas (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24 horas (14:00)</string>
|
||||
<string name="settings_time_format_auto_summary">Seguir o sistema: %1$s</string>
|
||||
<string name="settings_hour_lines">Linhas de hora</string>
|
||||
<string name="settings_hour_lines_summary">Mostrar um separador a cada hora nas vistas de semana e dia</string>
|
||||
<string name="settings_timeline_scale">Altura da hora</string>
|
||||
<string name="settings_timeline_scale_hint">Quanto espaço vertical que a hora ocupa nas vistas de semana e dia. Ambas as vistas compartilham esse ajuste. Você também pode pinçar a linha do tempo com dois dedos para colocar uma altura no meio.</string>
|
||||
<string name="timeline_scale_fit_day">Encaixar o dia todo</string>
|
||||
<string name="timeline_scale_fit_day_summary">24 horas na tela, sem rolagem</string>
|
||||
<string name="timeline_scale_compact">Compacto</string>
|
||||
<string name="timeline_scale_compact_summary">Mais horas por tela, blocos menores</string>
|
||||
<string name="timeline_scale_regular">Regular</string>
|
||||
<string name="timeline_scale_regular_summary">Espaçamento padrão</string>
|
||||
<string name="timeline_scale_comfortable">Confortável</string>
|
||||
<string name="timeline_scale_comfortable_summary">Blocos maiores, mais rolagem</string>
|
||||
<string name="timeline_scale_custom">Personalizado</string>
|
||||
<string name="timeline_scale_custom_summary">A altura em que você pinçou a linha do tempo</string>
|
||||
<string name="settings_dim_completed">Esmaecer eventos passados</string>
|
||||
<string name="settings_dim_completed_summary">Esmaecer eventos que já terminaram nas vistas do mês e semana</string>
|
||||
<string name="settings_drag_to_reschedule">Arraste para reagendar</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Mova um evento arrastando para outro dia ou hora</string>
|
||||
<string name="settings_past_events">Eventos passados</string>
|
||||
<string name="settings_past_events_show">Mostrar</string>
|
||||
<string name="settings_past_events_dim">Esmaecer</string>
|
||||
<string name="settings_past_events_hide">Ocultar</string>
|
||||
<string name="settings_past_events_sample_morning">Revisão de equipe</string>
|
||||
<string name="settings_past_events_sample_midday">Almoço com Robin</string>
|
||||
<string name="settings_past_events_sample_evening">Treino do coral</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Amplitude da agenda</string>
|
||||
<string name="settings_agenda_range_hint">O quanto à frente a tela de Agenda lista eventos.</string>
|
||||
<string name="settings_agenda_widget_range">Amplitude do widget de Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">O quanto à frente o widget de Agenda lista eventos.</string>
|
||||
<string name="settings_widget_size">Tamanho do widget de Agenda</string>
|
||||
<string name="settings_widget_size_hint">O quão largo o texto do widget de Agenda é desenhado. O widget de mês não tem ajuste — sua grade sempre encaixa no espaço que você der.</string>
|
||||
<string name="settings_widget_size_small">Pequeno</string>
|
||||
<string name="settings_widget_size_medium">Médio</string>
|
||||
<string name="settings_widget_size_large">Grande</string>
|
||||
<string name="settings_widget_size_extra_large">Extra grande</string>
|
||||
<string name="settings_widget_size_summary">Texto do evento %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Sempre mostrar Hoje</string>
|
||||
<string name="settings_agenda_show_today_hint">Mantenha o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje.</string>
|
||||
<string name="settings_agenda_range_bar">Barra de amplitude</string>
|
||||
<string name="settings_agenda_range_bar_hint">Mostrar uma barra no topo da agenda nomeando as datas mostradas, com um botão para mudar a amplitude para a sessão</string>
|
||||
<string name="agenda_range_day">Hoje</string>
|
||||
<string name="agenda_range_this_week">Essa semana</string>
|
||||
<string name="agenda_range_this_month">Esse mês</string>
|
||||
<string name="agenda_range_week">Próximos 7 dias</string>
|
||||
<string name="agenda_range_month">Próximos 30 dias</string>
|
||||
<string name="agenda_range_custom">Personalizado…</string>
|
||||
<string name="agenda_range_custom_hint">Dias</string>
|
||||
<string name="agenda_range_override_hint">Só por agora — reajusta para sua amplitude salva ao reabrir o Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Mostrando eventos a seguir para</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Vistas</string>
|
||||
<string name="settings_month_header">Vista do mês</string>
|
||||
<string name="settings_month_view_style">Estilo de vista do mês</string>
|
||||
<string name="month_style_paged">Páginas</string>
|
||||
<string name="month_style_paged_summary">Um mês ocupa a tela. Deslize para os lados para mudar o mês.</string>
|
||||
<string name="month_style_continuous">Meses contínuos</string>
|
||||
<string name="month_style_continuous_summary">Cada mês tem o próprio cabeçalho, com um espaço separando-o do próximo.</string>
|
||||
<string name="month_style_dense">Semanas integradas</string>
|
||||
<string name="month_style_dense_summary">As semanas seguem sem separação, cada mês terminando no início do outro sem distinção entre eles.</string>
|
||||
<string name="month_style_split">Dividido</string>
|
||||
<string name="month_style_split_summary">Uma grade compacta marca os dias com eventos, e o dia clicado é listado abaixo.</string>
|
||||
<string name="month_split_no_events">Nada agendado</string>
|
||||
<string name="month_split_expand">Mostrar o mês inteiro</string>
|
||||
<string name="month_split_collapse">Mostrar eventos do dia</string>
|
||||
<string name="settings_quick_switch_header">Botão de troca rápida</string>
|
||||
<string name="settings_quick_switch_hint">Escolha por quais vistas o botão superior direito circula, e arraste para reordenar. Vistas desativadas continuam acessíveis pelo menu de navegação.</string>
|
||||
<string name="settings_drawer_order_header">Menu de navegação</string>
|
||||
<string name="settings_drawer_order_hint">Arraste para reordenar vistas listadas no menu de navegação.</string>
|
||||
<string name="reorder_drag_handle">Arraste para reordenar</string>
|
||||
<string name="settings_section_event_form">Novo formulário de evento</string>
|
||||
<string name="settings_form_fields_hint">Campos padrão — o restante está dentro de \"Mais campos\"</string>
|
||||
<string name="settings_autofocus_title">Focar no título em novo evento</string>
|
||||
<string name="settings_autofocus_title_hint">Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado.</string>
|
||||
<string name="settings_event_duration">Duração padrão</string>
|
||||
<string name="settings_event_duration_hint">Quanto tempo um novo evento dura até você mudar o tempo de encerramento. Eventos de dia inteiro não são afetados.</string>
|
||||
<string name="settings_calendar_durations_title">Duração por calendário</string>
|
||||
<string name="settings_calendar_durations_hint">Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho.</string>
|
||||
<string name="settings_calendar_duration_inherits">Padrão (%1$s)</string>
|
||||
<string name="settings_calendar_duration_use_default">Usar duração padrão (%1$s)</string>
|
||||
<string name="settings_color_unsupported">Permitir cores em calendários não suportados</string>
|
||||
<string name="settings_color_unsupported_hint">Alguns calendários (ex.: certos CalDAV) não publicam cores; uma cor de evento personalizada pode ser apagada ou sobrescrita na próxima sincronização. Esta é uma limitação destes calendários, e não é algo que o Calendula pode consertar.</string>
|
||||
<string name="settings_section_notifications">Notificações</string>
|
||||
<string name="settings_reminders">Lembretes de evento</string>
|
||||
<string name="settings_reminders_hint">Vendo lembretes dobrados? Outro calendário está postando também — desligue em um dos dois.</string>
|
||||
<string name="settings_default_reminder">Lembrete padrão</string>
|
||||
<string name="settings_default_reminder_allday">Eventos de dia todo</string>
|
||||
<string name="settings_allday_reminder_time">Tempo de lembrete de dia todo</string>
|
||||
<string name="settings_allday_reminder_time_hint">Lembretes para eventos de dia todo disparam às %1$s</string>
|
||||
<string name="settings_allday_reminder_fires_at">Notifica às %1$s</string>
|
||||
<string name="reminder_none">Nenhum</string>
|
||||
<string name="reminder_use_default">Usar lembrete padrão</string>
|
||||
<string name="reminder_custom_amount">Quantidade</string>
|
||||
<string name="reminder_custom_set">Ajustar</string>
|
||||
<string name="settings_calendar_reminders_title">Lembretes por calendário</string>
|
||||
<string name="settings_calendar_reminders_hint">Mude o padrão por calendário — separadamente para eventos com horário e de dia todo. Um calendário pode manter o padrão, removê-lo, ou ajustar seu próprio.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Padrão (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Entrega confiável</string>
|
||||
<string name="settings_reliable_delivery_hint">O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Isentar de otimização de bateria — lembretes chegam na hora.</string>
|
||||
<string name="settings_snooze_duration">Duração de soneca</string>
|
||||
<string name="settings_section_calendars">Calendários</string>
|
||||
<string name="settings_manage_calendars">Gerenciar calendários</string>
|
||||
<string name="settings_manage_calendars_hint">Crie calendários locais; gerencie os sincronizados</string>
|
||||
<string name="settings_section_language">Idioma</string>
|
||||
<string name="settings_language">Idioma do app</string>
|
||||
<string name="settings_language_auto">Padrão do sistema</string>
|
||||
<string name="settings_translate">Ajude a traduzir</string>
|
||||
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
|
||||
<string name="settings_group_look">Aparência e comportamento</string>
|
||||
<string name="settings_group_data">Dados</string>
|
||||
<string name="settings_group_app">App</string>
|
||||
<string name="settings_group_about">Sobre</string>
|
||||
<string name="settings_appearance_subtitle">Tema, cores, fontes</string>
|
||||
<string name="settings_views_subtitle">Vista, layout e ordem padrão</string>
|
||||
<string name="settings_event_form_subtitle">Campos e comportamento padrão</string>
|
||||
<string name="settings_notifications_subtitle">Lembretes e entrega</string>
|
||||
<string name="settings_special_dates_subtitle">Aniversários e datas de contato</string>
|
||||
<string name="settings_widgets_subtitle">Widget de Agenda, bloco de Ajustes Rápidos</string>
|
||||
<string name="settings_backup_subtitle">Exportar, importar, backup automático</string>
|
||||
<string name="settings_section_widgets">WIdgets e blocos</string>
|
||||
<string name="settings_widgets_hint">Ajustes para widgets da tela inicial e bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando a tela inicial.</string>
|
||||
<string name="settings_section_backup">Backup e restaurar</string>
|
||||
<string name="settings_views_all_header">Todas as vistas</string>
|
||||
<string name="settings_week_day_header">Semana e dia</string>
|
||||
<string name="settings_default_view_hint">A vista que o Calendula abre ao iniciar.</string>
|
||||
<string name="settings_week_start_hint">O dia em que cada semana inicia, em todas as vistas e widgets.</string>
|
||||
<string name="settings_time_format_hint">Como os horários são escritos pelo app. \"Automático\" segue a configuração do sistema.</string>
|
||||
<string name="settings_past_events_hint">O que a agenda faz com eventos que já terminaram.</string>
|
||||
<string name="settings_dynamic_color_summary">Use as cores do app de acordo com seu papel de parede.</string>
|
||||
<string name="settings_section_special_dates">Datas especiais de contatos</string>
|
||||
<string name="settings_special_dates_enable">Mostrar datas de contatos</string>
|
||||
<string name="settings_special_dates_enable_hint">Espelhe os aniversários e outras datas dos contatos para calendários locais. Lê os contatos somente neste aparelho — nada é enviado, e seus contatos nunca são alterados.</string>
|
||||
<string name="settings_special_dates_type_birthday">Aniversários</string>
|
||||
<string name="settings_special_dates_type_anniversary">Aniversários de relacionamento</string>
|
||||
<string name="settings_special_dates_type_custom">Outras datas</string>
|
||||
<string name="settings_special_dates_template">Formato do título</string>
|
||||
<string name="settings_special_dates_template_hint">Use {nome} para o contato e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando não informado).</string>
|
||||
<string name="settings_special_dates_reminders">Lembretes</string>
|
||||
<string name="settings_special_dates_show_year">Mostrar ano</string>
|
||||
<string name="settings_special_dates_show_year_hint">Incluir {ano} nos títulos caso informado</string>
|
||||
<string name="settings_special_dates_sync_now">Sincronizar agora</string>
|
||||
<string name="settings_special_dates_never_synced">Não sincronizado ainda</string>
|
||||
<string name="settings_special_dates_last_synced">Sincronizado por último %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Ajustar cada cor e visibilidade de calendário nos ajustes de Calendários.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Ajustar em datas especiais de Contatos</string>
|
||||
<string name="settings_special_dates_paused_title">Pausado</string>
|
||||
<string name="settings_special_dates_paused_hint">O Calendula não consegue mais ler seus contatos, então estes calendários não estão atualizando.</string>
|
||||
<string name="settings_special_dates_disable_title">Desligar datas de contatos?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Isto deleta calendários de datas de contatos e seus eventos. Qualquer lembrete ou nota adicionada serão perdidos.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Isto deleta o calendário \"%1$s\" e seus eventos. Qualquer lembrete ou nota adicionados serão perdidos.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Desativar</string>
|
||||
<string name="dialog_save">Salvar</string>
|
||||
<string name="settings_section_about">Sobre</string>
|
||||
<string name="settings_license">Licença</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">por Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Código-fonte</string>
|
||||
<string name="settings_about_privacy">Política de privacidade</string>
|
||||
<string name="settings_about_support">Apoie o desenvolvimento</string>
|
||||
<string name="settings_about_version">Versão %1$s</string>
|
||||
<string name="settings_about_logo_desc">Ícone de app do Calendula</string>
|
||||
<string name="settings_report_problem">Reportar um problema</string>
|
||||
<string name="settings_report_problem_hint">Envie um reporte de falha ou abra o tracker de problemas</string>
|
||||
<string name="calendars_title">Calendários</string>
|
||||
<string name="calendars_local_header">Seus calendários</string>
|
||||
<string name="calendars_local_empty">Nenhum calendário local ainda. Crie um para manter eventos somente neste dispositivo.</string>
|
||||
<string name="calendars_add">Adicionar calendário</string>
|
||||
<string name="calendars_visibility_hint">Desative um calendário para escondê-lo neste aparelho — seus eventos desaparecem do app e param de ativar lembretes. Este é o mesmo botão que seus outros apps de calendário usam, então eles também escondem. Nada é deletado, nenhum outro aparelho é afetado, e você pode reativar aqui a qualquer momento.</string>
|
||||
<string name="calendars_visibility_a11y">Mostrar \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Alguns calendários estão desativados</string>
|
||||
<string name="calendars_visibility_notice_message">O Calendula mostra os calendários que estão ativados neste dispositivo, e alguns dos seus estão desativados. Para ver seus eventos, ative-os em Ajustes → Calendários.</string>
|
||||
<string name="calendar_picker_missing_title">Sentindo falta de um calendário?</string>
|
||||
<string name="calendar_picker_missing_summary">Pode estar desativado, somente-leitura, ou preenchido dos seus contatos — gerencie seus calendários aqui.</string>
|
||||
<string name="calendars_state_read_only">Somente leitura</string>
|
||||
<string name="calendars_state_not_synced">Não sincronizado neste dispositivo</string>
|
||||
<string name="calendars_state_managed">Preenchido dos seus contatos</string>
|
||||
<string name="calendars_managed_delete_locked">Este calendário é preenchido de seus contatos, então o Calendula o criaria de novo na próxima sincronização. Desative datas especiais em Ajustes → Datas especiais para deletar.</string>
|
||||
<string name="calendars_synced_header">Calendários sincronizados</string>
|
||||
<string name="calendars_synced_hint">Estes são de contas no seu dispositivo. Crie e edite-os em seus próprios apps.</string>
|
||||
<string name="calendars_manage_in_app">Gerenciar no app</string>
|
||||
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||
<string name="calendars_account_menu_a11y">Mais opções para %1$s</string>
|
||||
<string name="calendars_enable_all">Habilitar todos</string>
|
||||
<string name="calendars_disable_all">Desabilitar todos</string>
|
||||
<string name="calendars_add_account">Adicionar conta</string>
|
||||
<string name="calendars_new_title">Novo calendário</string>
|
||||
<string name="calendars_edit_title">Editar calendário</string>
|
||||
<string name="calendars_name_label">Nome</string>
|
||||
<string name="calendars_color_label">Cor</string>
|
||||
<string name="calendars_description_hint">Adicionar descrição</string>
|
||||
<string name="calendars_delete_confirm_title">Deletar calendário?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" e todos os seus eventos serão permanentemente removidos deste dispositivo.</string>
|
||||
<string name="calendars_write_error">Não foi possível salvar a alteração.</string>
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Calendários locais não são sincronizados, então exporte-os para um arquivo .ics para manter uma cópia.</string>
|
||||
<string name="calendars_backup_action">Exportar como arquivo .ics</string>
|
||||
<string name="calendars_export_title">Exportar calendários</string>
|
||||
<string name="calendars_export_hint">Escolha quais calendários incluir no arquivo .ics.</string>
|
||||
<string name="calendars_export_action">Exportar</string>
|
||||
<string name="calendars_restore_header">Restaurar</string>
|
||||
<string name="calendars_restore_action">Restaurar de um arquivo .ics</string>
|
||||
<string name="calendars_restore_hint">Importar eventos de um backup ou outro app de calendário.</string>
|
||||
<string name="calendars_auto_backup">Backup automático</string>
|
||||
<string name="calendars_auto_backup_hint">Exportar periodicamente seu calendário local para uma pasta como um arquivo .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">Pasta de backup</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Toque para escolher uma pasta</string>
|
||||
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||
<string name="calendars_auto_backup_every">A cada %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Mínimo de 30 minutos.</string>
|
||||
<string name="calendars_auto_backup_status_never">Nenhum backup automático ainda</string>
|
||||
<string name="calendars_auto_backup_status_ok">Último backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Último backup falho: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Avisa se os backups automáticos falharem repetidamente.</string>
|
||||
<string name="backup_failed_title">Backup automático falhou</string>
|
||||
<string name="backup_failed_text">O Calendula não pôde salvar o arquivo de backup. Verifique a pasta de backup em Ajustes.</string>
|
||||
<string name="calendars_backup_failed">Não foi possível exportar o backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Exportou %d evento.</item>
|
||||
<item quantity="many">Exportou %d eventos.</item>
|
||||
<item quantity="other">Exportou %d eventos.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importar eventos</string>
|
||||
<string name="import_target_header">Adicionar ao calendário</string>
|
||||
<string name="import_empty">Nenhum evento encontrado neste arquivo.</string>
|
||||
<string name="import_failed">Não foi possível ler este arquivo.</string>
|
||||
<string name="import_no_calendar">Nenhum calendário salvável para importar. Crie um calendário local primeiro.</string>
|
||||
<string name="import_done_title">Importação concluída</string>
|
||||
<string name="import_done_dedup_note">Eventos já no calendário foram pulados.</string>
|
||||
<string name="import_done_added_label">Adicionado</string>
|
||||
<string name="import_done_skipped_label">Duplicatas</string>
|
||||
<string name="import_close">Fechar</string>
|
||||
<string name="import_warning_recurrence">Algumas ocorrências alteradas de eventos recorrentes foram puladas.</string>
|
||||
<string name="import_warning_no_start">Um evento sem horário de início foi pulado.</string>
|
||||
<string name="import_warning_attendees">Listas de convidados não foram importadas.</string>
|
||||
<string name="import_warning_timezone">Um fuso-horário desconhecido foi alterado para o do seu dispositivo.</string>
|
||||
<string name="import_button">Importar</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importando %d evento</item>
|
||||
<item quantity="many">Importando %d eventos</item>
|
||||
<item quantity="other">Importando %d eventos</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d evento neste arquivo.</item>
|
||||
<item quantity="many">%d eventos neste arquivo.</item>
|
||||
<item quantity="other">%d eventos neste arquivo.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importar %d evento</item>
|
||||
<item quantity="many">Importar %d eventos</item>
|
||||
<item quantity="other">Importar %d eventos</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Importou %d evento.</item>
|
||||
<item quantity="many">Importou %d eventos.</item>
|
||||
<item quantity="other">Importou %d eventos.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Pulou %d já neste calendário.</item>
|
||||
<item quantity="many">Pulou %d já neste calendário.</item>
|
||||
<item quantity="other">Pulou %d já neste calendário.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_short">Novo evento</string>
|
||||
<string name="shortcut_new_event_long">Criar novo evento</string>
|
||||
<string name="qs_tile_new_event_label">Novo evento</string>
|
||||
<string name="settings_qs_tile">Adicionar bloco Ajustes Rápidos</string>
|
||||
<string name="settings_qs_tile_hint">Adicionar bloo \"Novo evento\" no painel Ajustes Rápidos.</string>
|
||||
<string name="crash_dialog_title">%1$s quebrou</string>
|
||||
<string name="crash_dialog_message">%1$s fechou inesperadamente da última vez. Você pode ajudar a consertar enviando este reporte como um problema. Ele fica no seu dispositivo até que decida enviá-lo, e não inclui dados pessoais ou conteúdo de calendário — somente os detalhes técnicos abaixo.</string>
|
||||
<string name="crash_dialog_report">Reportar</string>
|
||||
<string name="crash_dialog_dismiss">Agora não</string>
|
||||
<string name="crash_report_issue_title">Reportar quebra</string>
|
||||
<string name="crash_report_clip_label">%1$s relatório de quebra</string>
|
||||
<string name="crash_report_copied">Relatório copiado para a área de transferência</string>
|
||||
<string name="crash_report_open_failed">Não foi possível abrir o tracker de problemas. O relatório está na área de transferência.</string>
|
||||
<string name="crash_report_body_template">Obrigado por reportar uma quebra em %1$s. Por favor, escreva o que estava fazendo, depois envie. \n### O que aconteceu \n\n\n### Relatório de quebra \n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(O reporte era muito longo para este link — cole da área de transferência aqui.)_</string>
|
||||
<string name="special_dates_calendar_birthday">Aniversários</string>
|
||||
<string name="special_dates_calendar_anniversary">Aniversários de relacionamento</string>
|
||||
<string name="special_dates_calendar_custom">Datas especiais</string>
|
||||
<string name="special_dates_default_title_birthday">Aniversário de {nome} ({ano})</string>
|
||||
<string name="special_dates_default_title_anniversary">Aniversário de relacionamento de {nome} ({ano})</string>
|
||||
<string name="special_dates_default_title_custom">{nome}</string>
|
||||
</resources>
|
||||
@@ -17,14 +17,14 @@
|
||||
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
|
||||
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
|
||||
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
|
||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваш девайс.</string>
|
||||
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваше устройство.</string>
|
||||
<string name="permission_request_button">Дать доступ к календарю</string>
|
||||
<string name="permission_denied_title">В доступе к календарю отказано</string>
|
||||
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
|
||||
<string name="permission_open_settings_button">Открыть настройки телефона</string>
|
||||
<string name="permission_retry_button">Попытайтесь снова</string>
|
||||
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
|
||||
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
|
||||
<string name="permission_benefit_private_title">Приватность по умолчанию</string>
|
||||
<string name="permission_benefit_private_body">Нет доступа к интернету, нету телеметрии — ничто не покидает ваше устройство.</string>
|
||||
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается.</string>
|
||||
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
|
||||
@@ -62,4 +62,118 @@
|
||||
<string name="event_edit_close">Закрыть</string>
|
||||
<string name="event_edit_save">Сохранить</string>
|
||||
<string name="event_edit_title_hint">Добавить имя</string>
|
||||
<string name="event_move_action">Перенести…</string>
|
||||
<string name="event_move_recurring_title">Перенести повторяющиеся события</string>
|
||||
<string name="event_move_done">Перенесено на %1$s</string>
|
||||
<string name="event_move_undo">Отменить</string>
|
||||
<string name="event_move_occurrence_only">Перемещение всей серии изменит дни на которые она попадёт, поэтому можно переместить только это событие.</string>
|
||||
<string name="event_move_undone">Перемещение отменено</string>
|
||||
<string name="event_move_failed">Не удалось переместить событие</string>
|
||||
<string name="event_move_write_denied">Приложению Calendula требуется доступ на запись, чтобы перемещать события</string>
|
||||
<string name="event_move_gone">Это событие больше не существует</string>
|
||||
<string name="event_move_blocked_series_end">Нельзя переместить событие дальше за пределы его серии</string>
|
||||
<string name="event_edit_managed_hint">Управляется «%1$s» — название, дата и повторение синхронизируются с ваших контактов. Напоминания, местоположения и заметки могут быть редактированы вами.</string>
|
||||
<string name="event_edit_starts">Начинается</string>
|
||||
<string name="event_edit_ends">Заканчивается</string>
|
||||
<string name="event_edit_error_end_before_start">Заканчивается раньше, чем начинается</string>
|
||||
<string name="event_edit_error_no_calendar">Нет календарей с правами на запись</string>
|
||||
<string name="event_edit_save_failed">Не удалось сохранить событие</string>
|
||||
<string name="event_edit_write_denied">Приложению Calendula требуется доступ на запись, чтобы создавать события</string>
|
||||
<string name="event_edit_more_fields">Больше полей</string>
|
||||
<string name="event_edit_add">Добавить</string>
|
||||
<string name="event_edit_add_reminder">Добавить напоминание</string>
|
||||
<string name="event_edit_remove_reminder">Удалить напоминание</string>
|
||||
<string name="event_edit_attendees">Гости</string>
|
||||
<string name="event_edit_add_guest">Добавить гостя</string>
|
||||
<string name="event_edit_add_guest_hint">Добавить гостя по почте…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Добавить из контактов</string>
|
||||
<string name="event_edit_location_from_contacts">Выбрать адрес из контактов</string>
|
||||
<string name="event_edit_remove_guest">Удалить гостя</string>
|
||||
<string name="event_edit_attendee_required">Обязательный</string>
|
||||
<string name="event_edit_attendee_optional">Необязательный</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula не отправляет приглашения. Ваша учётная запись календаря может отправить письма гостям при синхронизации.</string>
|
||||
<string name="event_edit_attendees_note_local">Хранится на этом устройстве. Никто не получит уведомление.</string>
|
||||
<string name="event_edit_reminder_custom">Другое</string>
|
||||
<string name="reminder_unit_minutes">минуты</string>
|
||||
<string name="reminder_unit_hours">часы</string>
|
||||
<string name="reminder_unit_days">дни</string>
|
||||
<string name="reminder_unit_weeks">недели</string>
|
||||
<string name="event_edit_availability">Занятость</string>
|
||||
<string name="event_edit_visibility">Видимость</string>
|
||||
<string name="event_edit_timezone_device">Часовой пояс устройства</string>
|
||||
<string name="event_edit_timezone_device_summary">Следует за вами, где бы вы ни были</string>
|
||||
<string name="event_edit_timezone_search">Поиск часовых поясов</string>
|
||||
<string name="event_edit_timezone_recent">Недавние</string>
|
||||
<string name="event_edit_timezone_all">Все часовые пояса</string>
|
||||
<string name="event_edit_timezone_none">Часовые пояса, соответсвующие «%1$s», не найдены</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s по вашему времени</string>
|
||||
<string name="event_edit_color">Цвет</string>
|
||||
<string name="event_edit_color_default">Цвет календаря</string>
|
||||
<string name="event_edit_color_custom">Свой цвет</string>
|
||||
<string name="event_edit_color_reset">Сбросить</string>
|
||||
<string name="event_edit_color_unsupported">Недоступно для этого календаря</string>
|
||||
<string name="event_edit_color_unsupported_hint">Этот календарь не публикует набор цветов. Вы можете разрешить пользовательские цвета для таких календарей в Настройках.</string>
|
||||
<string name="event_edit_color_sync_warning">Этот календарь может потерять или перезаписать цвет при следующей синхронизации.</string>
|
||||
<string name="event_edit_conflict_title">Событие изменено в другом месте</string>
|
||||
<string name="event_edit_conflict_body">Пока вы редактировали, это событие было изменено — синхронизацией или другим приложением. Что вы хотите сделать с вашими изменениями?</string>
|
||||
<string name="event_edit_conflict_overwrite">Сохранить мои изменения</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Только изменённые вами поля, перезаписывают внешние изменения</string>
|
||||
<string name="event_edit_conflict_discard">Отменить мои изменения</string>
|
||||
<string name="event_edit_conflict_discard_hint">Событие остаётся таким, как сейчас</string>
|
||||
<string name="event_edit_gone_title">Событие удалено</string>
|
||||
<string name="event_edit_gone_body">Это событие было удалено, пока вы его редактировали, например на другом устройстве. Ваши изменения больше не могут быть сохранены.</string>
|
||||
<string name="import_reminder_prompt_title">Применить ваше стандартное напоминание?</string>
|
||||
<string name="import_reminder_prompt_body_none">Это событие было импортировано без каких-либо напоминаний.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Это событие было импортировано с %1$d напоминанием.</item>
|
||||
<item quantity="few">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
<item quantity="many">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
<item quantity="other">Это событие было импортировано с %1$d напоминаниями.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Применить стандартное</string>
|
||||
<string name="import_reminder_prompt_keep">Оставить как есть</string>
|
||||
<string name="event_edit_recurrence_none">Не повторяется</string>
|
||||
<string name="event_edit_recurrence_custom">Свой</string>
|
||||
<string name="event_edit_recurrence_every">Каждый</string>
|
||||
<string name="recurrence_unit_days">дни</string>
|
||||
<string name="recurrence_unit_weeks">недели</string>
|
||||
<string name="recurrence_unit_months">месяцы</string>
|
||||
<string name="recurrence_unit_years">года</string>
|
||||
<string name="event_edit_recurrence_incomplete">Введите число от 1 до 999</string>
|
||||
<string name="event_edit_recurrence_next_none">Это правило никогда не повторяется</string>
|
||||
<string name="event_edit_recurrence_ends">Заканчивается</string>
|
||||
<string name="event_edit_recurrence_end_never">Никогда</string>
|
||||
<string name="event_edit_recurrence_end_until">До</string>
|
||||
<string name="event_edit_recurrence_end_count">После определённого количества</string>
|
||||
<string name="event_availability_busy">Занят</string>
|
||||
<string name="event_access_default">Стандартный</string>
|
||||
<string name="event_access_public">Публичный</string>
|
||||
<string name="event_detail_all_day">Весь день</string>
|
||||
<string name="event_detail_calendar">Календарь</string>
|
||||
<string name="event_detail_calendar_unknown">Неизвестный календарь</string>
|
||||
<string name="event_detail_location">Местоположение</string>
|
||||
<string name="event_detail_description">Описание</string>
|
||||
<string name="event_detail_recurring">Повторяющееся событие</string>
|
||||
<string name="recurrence_daily">Каждый день</string>
|
||||
<string name="recurrence_weekly">Каждую неделю</string>
|
||||
<string name="recurrence_monthly">Каждый месяц</string>
|
||||
<string name="recurrence_yearly">Каждый год</string>
|
||||
<string name="recurrence_every_n_days">Каждые %1$d дней</string>
|
||||
<string name="recurrence_every_n_weeks">Каждые %1$d недель</string>
|
||||
<string name="recurrence_every_n_months">Каждые %1$d месяцев</string>
|
||||
<string name="recurrence_every_n_years">Каждые %1$d лет</string>
|
||||
<string name="event_detail_not_found">Это событие больше не существует.</string>
|
||||
<string name="event_attendee_accepted">Принято</string>
|
||||
<string name="event_attendee_declined">Отклонено</string>
|
||||
<string name="event_attendee_needs_action">Без ответа</string>
|
||||
<string name="event_detail_reminders">Напоминания</string>
|
||||
<string name="event_detail_timezone">Временная зона</string>
|
||||
<string name="event_status_cancelled">Отменён</string>
|
||||
<string name="event_availability_free">Бесплатно</string>
|
||||
<string name="event_access_private">Приватно</string>
|
||||
<string name="event_access_confidential">Конфиденциально</string>
|
||||
<string name="event_access_public_summary">Любой с доступом может видеть все детали</string>
|
||||
<string name="event_access_private_summary">Другие видят только, что вы заняты</string>
|
||||
<string name="event_detail_self_response">Ваш ответ: %1$s</string>
|
||||
<string name="reminder_at_time">Во время начала события</string>
|
||||
</resources>
|
||||
|
||||
190
app/src/main/res/values-sk/strings.xml
Normal file
190
app/src/main/res/values-sk/strings.xml
Normal file
@@ -0,0 +1,190 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Moderný kalendár.</string>
|
||||
<string name="state_loading">Načítava sa…</string>
|
||||
<string name="state_retry">Opakovať</string>
|
||||
<string name="state_failure_unknown">Niečo sa pokazilo.</string>
|
||||
<string name="state_failure_permission">Je potrebný prístup ku kalendáru.</string>
|
||||
<string name="state_failure_permission_action">Udeliť prístup</string>
|
||||
<string name="state_failure_no_calendars">Nie sú nakonfigurované žiadne kalendáre.</string>
|
||||
<string name="state_failure_no_calendars_action">Otvoriť systémové nastavenie kalendára</string>
|
||||
<string name="state_failure_provider">Nepodarilo sa načítať kalendár.</string>
|
||||
<string name="permission_rationale_title">Prehľadávajte všetky svoje udalosti v krásnom prevedení</string>
|
||||
<string name="permission_rationale_body">Calendula potrebuje prístup k vášmu kalendáru, aby mohla zobraziť a spravovať vaše udalosti.</string>
|
||||
<string name="permission_request_button">Udeliť prístup k kalendáru</string>
|
||||
<string name="permission_denied_title">Prístup ku kalendáru bol zamietnutý</string>
|
||||
<string name="permission_denied_body">Calendula nemôže zobraziť udalosti bez prístupu ku kalendáru. Môžete ho znova udeliť v nastavení systému.</string>
|
||||
<string name="permission_open_settings_button">Otvoriť systémové nastavenia</string>
|
||||
<string name="permission_retry_button">Skúsiť znova</string>
|
||||
<string name="permission_benefit_private_title">Súkromné už od začiatku</string>
|
||||
<string name="permission_benefit_private_body">Žiadny prístup k internetu, žiadna telemetria - nikdy nič z vášho telefónu neunikne.</string>
|
||||
<string name="permission_benefit_sync_title">Všetky vaše kalendáre pohromade</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, lokálne kalendáre - čokoľvek synchronizované zo zariadením sa proste objaví.</string>
|
||||
<string name="permission_benefit_privacy_title">Nikdy žiadne sledovanie</string>
|
||||
<string name="permission_benefit_privacy_body">Žiadna telemetria, žiadne analytické nástroje, bez reklám.</string>
|
||||
<string name="permission_privacy_footnote">Ostáva na vašom zariadení · bez prístupu k internetu</string>
|
||||
<string name="month_prev">Predchádzajúci mesiac</string>
|
||||
<string name="month_next">Nasledujúci mesiac</string>
|
||||
<string name="month_today_action">Dnes</string>
|
||||
<string name="month_more_actions">Ďalšie možnosti</string>
|
||||
<string name="month_open_menu">Otvoriť menu</string>
|
||||
<string name="month_action_settings">Nastavenia</string>
|
||||
<string name="month_a11y_today_prefix">Dnes</string>
|
||||
<string name="week_today_action">Tento víkend</string>
|
||||
<string name="week_number_label">týžd.</string>
|
||||
<string name="day_today_action">Dnes</string>
|
||||
<string name="event_detail_back">Späť</string>
|
||||
<string name="event_detail_edit">Upraviť</string>
|
||||
<string name="event_detail_delete">Vymazať</string>
|
||||
<string name="event_detail_share">Zdielať</string>
|
||||
<string name="event_detail_duplicate">Duplikovať</string>
|
||||
<string name="event_share_chooser_title">Zdielať udalosť</string>
|
||||
<string name="event_share_failed">Túto udalosť nieje možné zdieľať.</string>
|
||||
<string name="event_delete_title">Odstrániť udalosť?</string>
|
||||
<string name="event_delete_body">Udalosť je odstránená z vášho kalendára aj zo všetkých synchronizovaných zariadení.</string>
|
||||
<string name="event_delete_recurring_title">Odstrániť opakujúcu sa udalosť</string>
|
||||
<string name="event_delete_option_occurrence">Iba túto udalosť</string>
|
||||
<string name="event_delete_option_following">Túto aj všetky nasledujúce udalosti</string>
|
||||
<string name="event_delete_option_series">Všetky udalosti v sérii</string>
|
||||
<string name="event_edit_recurring_title">Upraviť opakujúcu sa udalosť</string>
|
||||
<string name="event_delete_failed">Túto udalosť nebolo možné odstrániť</string>
|
||||
<string name="event_delete_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla odstrániť udalosti</string>
|
||||
<string name="event_move_action">Presunúť…</string>
|
||||
<string name="event_move_recurring_title">Presunúť opakujúcu sa udalosť</string>
|
||||
<string name="event_move_occurrence_only">Presunútie celej série by sa zmenilo poradie dňou, na ktorú séria pripadá, takže presunúť je možné iba túto udalosť.</string>
|
||||
<string name="event_move_done">Presunuté na %1$s</string>
|
||||
<string name="event_move_undo">Vrátiť späť</string>
|
||||
<string name="event_move_undone">Navrátené naspäť</string>
|
||||
<string name="event_move_failed">Nemožno presunúť udalosť</string>
|
||||
<string name="event_move_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla presúvať udalosti</string>
|
||||
<string name="event_move_gone">Táto udalosť už neexistuje</string>
|
||||
<string name="event_move_blocked_series_end">Nie je možné presunúť udalosť za koniec danej série</string>
|
||||
<string name="dialog_cancel">Zrušiť</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="event_edit_new_title">Nová udalosť</string>
|
||||
<string name="event_edit_close">Zavrieť</string>
|
||||
<string name="event_edit_save">Uložiť</string>
|
||||
<string name="event_edit_title_hint">Pridať názov</string>
|
||||
<string name="event_edit_managed_hint">Spravové kalendárom “%1$s” — názov, dátum a opakovanie je synchronizované z vašich kontaktov. Pripomienky, poloha a poznámky môžte upraviť.</string>
|
||||
<string name="event_edit_starts">Začiatok</string>
|
||||
<string name="event_edit_ends">Koniec</string>
|
||||
<string name="event_edit_error_end_before_start">Končí pred začiatkom</string>
|
||||
<string name="event_edit_error_no_calendar">Není dostupný kalendár, ktorý je možno upravovať</string>
|
||||
<string name="event_edit_save_failed">Nemožno udalosť uložiť</string>
|
||||
<string name="event_edit_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla vytvárať udalosti</string>
|
||||
<string name="event_edit_more_fields">Viac poli</string>
|
||||
<string name="event_edit_add">Pridať</string>
|
||||
<string name="event_edit_add_reminder">Pridať pripomienku</string>
|
||||
<string name="event_edit_remove_reminder">Odstrániť pripomienku</string>
|
||||
<string name="event_edit_attendees">Hostia</string>
|
||||
<string name="event_edit_add_guest">Pridať hosťa</string>
|
||||
<string name="event_edit_add_guest_hint">Pridať hosťa e-mailom…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Pridať z kontaktov</string>
|
||||
<string name="event_edit_location_from_contacts">Vybrať adresu z kontaktov</string>
|
||||
<string name="event_edit_remove_guest">Odstrániť hosťa</string>
|
||||
<string name="event_edit_attendee_required">Nutné</string>
|
||||
<string name="event_edit_attendee_optional">Voliteľné</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula neposiela pozvánky. Váš kalendárny účet ich môže poslať keď sa synchronizuje.</string>
|
||||
<string name="event_edit_attendees_note_local">Uložené na tomto zariadení. Nikomu nebude poslaná notifikácia.</string>
|
||||
<string name="event_edit_reminder_custom">Vlastné</string>
|
||||
<string name="reminder_unit_minutes">minúty</string>
|
||||
<string name="reminder_unit_hours">hodiny</string>
|
||||
<string name="reminder_unit_days">dni</string>
|
||||
<string name="reminder_unit_weeks">týždne</string>
|
||||
<string name="event_edit_availability">Dostupnosť</string>
|
||||
<string name="event_edit_visibility">Viditeľnosť</string>
|
||||
<string name="event_edit_timezone_device">Časové pásmo zariadenia</string>
|
||||
<string name="event_edit_timezone_device_summary">Sleduje vás, nech ste kdekoľvek</string>
|
||||
<string name="event_edit_timezone_search">Vyhľadať časové pásmo</string>
|
||||
<string name="event_edit_timezone_recent">Nedávne</string>
|
||||
<string name="event_edit_timezone_all">Všetky časové pásma</string>
|
||||
<string name="event_edit_timezone_none">Žiadne časové pásmo nezodpovedá “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s vášho času</string>
|
||||
<string name="event_edit_color">Farba</string>
|
||||
<string name="event_edit_color_default">Farba kalendáru</string>
|
||||
<string name="event_edit_color_custom">Vlastná farba</string>
|
||||
<string name="event_edit_color_reset">Obnoviť</string>
|
||||
<string name="event_edit_color_unsupported">Není k dispozícii pre tento kalendár</string>
|
||||
<string name="event_edit_color_unsupported_hint">Tento kalendár nemá vlastnú sadu farieb. Pre takýto kalendár môžte povoliť vlastné farby v nastaveniach.</string>
|
||||
<string name="event_edit_color_sync_warning">Tento kalendár môže vymazať alebo prepísať farbu pri ďalšej synchronizácii.</string>
|
||||
<string name="event_edit_conflict_title">Udalosť bola zmenená niekde inde</string>
|
||||
<string name="event_edit_conflict_body">Počas úprav sa táto udalosť zmenila — synchronizáciou alebo inou aplikáciou. Čo sa má stať s vašimi zmenami?</string>
|
||||
<string name="event_edit_conflict_overwrite">Uložiť zmeny</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Iba u upravených polí sa prepíše zmena z iného zdroja</string>
|
||||
<string name="event_edit_conflict_discard">Zrušiť zmeny</string>
|
||||
<string name="event_edit_conflict_discard_hint">Udalosť ostáva bez zmeny</string>
|
||||
<string name="event_edit_gone_title">Udalosť odstránená</string>
|
||||
<string name="event_edit_gone_body">Počas úprav bola táto udalosť odstránená, napríklad na inom zariadení. Vaše zmeny už nie je možné uložiť.</string>
|
||||
<string name="import_reminder_prompt_title">Chcete použiť predvolenú pripomienku?</string>
|
||||
<string name="import_reminder_prompt_body_none">Táto udalosť bola importovaná bez pripomienky.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Táto udalosť bola importovaná s %1$d pripomienkou.</item>
|
||||
<item quantity="few">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
|
||||
<item quantity="many">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
|
||||
<item quantity="other">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
|
||||
</plurals>
|
||||
<string name="import_reminder_prompt_apply">Nastaviť predvolené</string>
|
||||
<string name="import_reminder_prompt_keep">Nechať bez zmeny</string>
|
||||
<string name="event_edit_recurrence_none">Neopakuje sa</string>
|
||||
<string name="event_edit_recurrence_custom">Vlastné</string>
|
||||
<string name="event_edit_recurrence_every">Každý</string>
|
||||
<string name="recurrence_unit_days">dni</string>
|
||||
<string name="recurrence_unit_weeks">týždne</string>
|
||||
<string name="recurrence_unit_months">mesiace</string>
|
||||
<string name="recurrence_unit_years">roky</string>
|
||||
<string name="event_edit_recurrence_incomplete">Zadajte číslo od 1 do 999</string>
|
||||
<string name="event_edit_recurrence_next">Nasledujúce: %1$s</string>
|
||||
<string name="event_edit_recurrence_next_none">Toto pravidlo sa nikdy neopakuje</string>
|
||||
<string name="event_edit_recurrence_ends">Koniec</string>
|
||||
<string name="event_edit_recurrence_end_never">Nikdy</string>
|
||||
<string name="event_edit_recurrence_end_until">K určitému dátumu</string>
|
||||
<string name="event_edit_recurrence_end_count">Po určitom počtu opakovaní</string>
|
||||
<string name="event_edit_recurrence_times">opakovaní</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Opakovanie končí pred začiatkom udalosti</string>
|
||||
<string name="event_availability_busy">Zaneprázdnený</string>
|
||||
<string name="event_access_default">Predvolený</string>
|
||||
<string name="event_access_public">Verejný</string>
|
||||
<string name="event_detail_all_day">Celý deň</string>
|
||||
<string name="event_detail_calendar">Kalendár</string>
|
||||
<string name="event_detail_calendar_unknown">Neznámy kalendár</string>
|
||||
<string name="event_detail_location">Poloha</string>
|
||||
<string name="event_detail_description">Popis</string>
|
||||
<string name="event_detail_attendees">Účastníci</string>
|
||||
<string name="event_detail_recurrence">Opakovanie</string>
|
||||
<string name="event_detail_recurring">Opakovaná udalosť</string>
|
||||
<string name="recurrence_daily">Každý deň</string>
|
||||
<string name="recurrence_weekly">Každý týždeň</string>
|
||||
<string name="recurrence_monthly">Každý mesiac</string>
|
||||
<string name="recurrence_yearly">Každý rok</string>
|
||||
<string name="recurrence_every_n_days">Každý %1$d. deň</string>
|
||||
<string name="recurrence_every_n_weeks">Každý %1$d. týždeň</string>
|
||||
<string name="recurrence_every_n_months">Každý %1$d. mesiac</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_count">%1$s, %2$d opakovaní</string>
|
||||
<string name="event_detail_not_found">Táto udalosť už neexistuje.</string>
|
||||
<string name="event_attendee_accepted">Akceptované</string>
|
||||
<string name="event_attendee_declined">Zamietnuté</string>
|
||||
<string name="event_attendee_tentative">Predbežne</string>
|
||||
<string name="event_attendee_needs_action">Bez odozvy</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Pripomienky</string>
|
||||
<string name="event_detail_timezone">Časové pásmo</string>
|
||||
<string name="event_status_tentative">Predbežne</string>
|
||||
<string name="event_status_cancelled">Zrušené</string>
|
||||
<string name="event_availability_free">Voľno</string>
|
||||
<string name="event_access_private">Súkromné</string>
|
||||
<string name="event_access_confidential">Tajné</string>
|
||||
<string name="event_access_default_summary">Bežná činnosť kalendáru</string>
|
||||
<string name="event_access_public_summary">Kdekoľvek s prístupom vidí všetky detaily</string>
|
||||
<string name="event_access_private_summary">Ostatný vidia iba že ste zaneprázdnený</string>
|
||||
<string name="event_access_confidential_summary">Označené ako utajené; záleží na kalendárnom účtu čo toto znamená</string>
|
||||
</resources>
|
||||
@@ -18,16 +18,20 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
<string name="permission_open_settings_button">Open system settings</string>
|
||||
<string name="permission_retry_button">Try again</string>
|
||||
<string name="permission_benefit_private_title">Stays on your device</string>
|
||||
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
|
||||
<string name="permission_benefit_private_title">Private by design</string>
|
||||
<string name="permission_benefit_private_body">No internet permission, no telemetry — nothing ever leaves your phone.</string>
|
||||
<string name="permission_benefit_sync_title">All your calendars, together</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
|
||||
<!-- Unused since the wizard trim (#163) — the privacy point is now
|
||||
permission_benefit_private_*. Kept until Weblate drops them from the
|
||||
translations: removing a base key a translation still carries is a
|
||||
fatal ExtraTranslation. -->
|
||||
<string name="permission_benefit_privacy_title">No tracking, ever</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
|
||||
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
|
||||
@@ -65,6 +69,20 @@
|
||||
<string name="event_edit_recurring_title">Edit recurring event</string>
|
||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||
|
||||
<!-- Drag an event to another time or day (#68) -->
|
||||
<string name="event_move_action">Move…</string>
|
||||
<string name="event_move_recurring_title">Move recurring event</string>
|
||||
<string name="event_move_occurrence_only">Moving the whole series would change which days it falls on, so only this event can move.</string>
|
||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||
<string name="event_move_done">Moved to %1$s</string>
|
||||
<string name="event_move_undo">Undo</string>
|
||||
<string name="event_move_undone">Move undone</string>
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
|
||||
@@ -254,6 +272,25 @@
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
<!-- First-launch wizard (#163) -->
|
||||
<string name="onboarding_step_counter">Step %1$d of %2$d</string>
|
||||
<string name="onboarding_backup_title">Your events live only here</string>
|
||||
<string name="onboarding_backup_body">Nothing on this phone syncs to an account, so losing it would lose your calendar. Calendula can write a backup for you.</string>
|
||||
<string name="onboarding_backup_benefit_folder_title">A folder you choose</string>
|
||||
<string name="onboarding_backup_benefit_folder_body">Backups are plain .ics files — put them somewhere that syncs, or on an SD card.</string>
|
||||
<string name="onboarding_backup_benefit_daily_title">Once a day, by itself</string>
|
||||
<string name="onboarding_backup_benefit_daily_body">Calendula exports your local calendars in the background. Change how often in Settings.</string>
|
||||
<string name="onboarding_backup_enable_button">Choose folder and back up</string>
|
||||
<string name="onboarding_backup_skip_button">Not now</string>
|
||||
<string name="onboarding_view_title">What should open first?</string>
|
||||
<string name="onboarding_month_style_title">How should Month look?</string>
|
||||
<string name="onboarding_view_continue_button">Continue</string>
|
||||
<string name="onboarding_back">Back</string>
|
||||
<string name="onboarding_visibility_button">Got it</string>
|
||||
<string name="onboarding_done_title">You\'re all set</string>
|
||||
<string name="onboarding_done_body">Your calendars are ready. Everything you just chose can be changed later in Settings.</string>
|
||||
<string name="onboarding_done_button">Open my calendar</string>
|
||||
|
||||
<!-- Reminder notifications (v1.4) -->
|
||||
<string name="reminder_channel_name">Event reminders</string>
|
||||
<string name="reminder_channel_description">Notifications at the reminder times of your events</string>
|
||||
@@ -263,6 +300,7 @@
|
||||
<string name="reminder_benefit_delivery_body">Every reminder on your events arrives as a notification, right on time.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Using a second calendar app?</string>
|
||||
<string name="reminder_benefit_duplicates_body">If another app also posts reminders, you\'ll see them twice — turn them off there or here.</string>
|
||||
<!-- Unused since the wizard trim (#163); see the note above. -->
|
||||
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||
@@ -304,6 +342,26 @@
|
||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||
<string name="search_empty">No events match “%1$s”.</string>
|
||||
|
||||
<!-- Selecting search results to delete several at once (#80) -->
|
||||
<plurals name="search_selected_count">
|
||||
<item quantity="one">%d selected</item>
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
<string name="search_in_descriptions">Found in descriptions</string>
|
||||
<string name="search_selection_close">Cancel selection</string>
|
||||
<string name="search_select_all">Select all</string>
|
||||
<string name="search_delete_selected">Delete selected</string>
|
||||
<plurals name="search_delete_title">
|
||||
<item quantity="one">Delete %d event?</item>
|
||||
<item quantity="other">Delete %d events?</item>
|
||||
</plurals>
|
||||
<plurals name="search_delete_done">
|
||||
<item quantity="one">%d event deleted</item>
|
||||
<item quantity="other">%d events deleted</item>
|
||||
</plurals>
|
||||
<string name="search_delete_partial">%1$d deleted, %2$d couldn\'t be</string>
|
||||
<string name="search_delete_denied_partial">%1$d deleted, then write access was withdrawn</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
@@ -381,6 +439,8 @@
|
||||
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
|
||||
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
@@ -563,7 +623,7 @@
|
||||
<string name="calendars_visibility_hint">Turn a calendar off to hide it on this device — its events disappear from the app and it stops reminding you. This is the same switch your other calendar apps use, so they hide it too. Nothing is deleted, no other device is affected, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula shows the calendars that are switched on for this device, and some of yours are off. To see their events, switch them on under Settings → Calendars.</string>
|
||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
Compose theme; this is essentially the launch/backdrop theme. -->
|
||||
<style name="Theme.Calendula" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="ar" />
|
||||
<locale android:name="cs" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="it" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="pl" />
|
||||
<locale android:name="pt-BR" />
|
||||
<locale android:name="ru" />
|
||||
<locale android:name="zh-CN" />
|
||||
</locale-config>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user